Skip to content

Latest commit

 

History

History

deps

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

deps — a router, a UI kit and a reactive primitive, from npm

Three third-party Solid packages, declared in solid-rs.toml, installed by solid-rs install, and driven from Rust:

package what it does here
@solidjs/router the route tree, the nav, and /hello/:name read back with useParams
@kobalte/core a Progress bar, a Button, a Checkbox and Tabs — behaviour and ARIA, no styles
@solid-primitives/timer createIntervalCounter, a signal that counts on its own schedule

Nothing this page does is solid-rs's behaviour. What solid-rs contributes is that Rust could express it.

Bindings are generated, not guessed

solid-rs install imports every installed package and writes a ready-to-paste extern block per subpath into bindings/. That is not a convenience — it is the difference between working and not:

  • The Solid 2.0 router has no <Router> or <Route>. It is config-first: createRouter({ routes }) returns an instance that is the provider component. There is no <A> either; the router installs one document-level click handler and claims every a[href].

  • @kobalte/core publishes only a wildcard subpath export ("./*"), so there is nothing at the package root to read. The generator expands the wildcard against the filesystem and finds all 28 components.

  • A headless library publishes state, not styles. Progress.Fill sets --kb-progress-fill-width inline and sets no width at all — the fill could as easily be a height, a transform or a clip path, so choosing is the app's job. Miss the rule that consumes it and the fill is a block element inside the track, i.e. a bar that reads as 100% full at every value:

    .bar { width: var(--kb-progress-fill-width); }

    The same shape as .tab[data-selected]: select on what the library exposes.

  • A Kobalte export is the root component, with the parts hung off it. Progress.Track exists; Progress.Root does not. The generated file says so:

    // `<Progress>` is the root; its parts are `<Progress.Fill>`, `<Progress.Label>`,
    // `<Progress.Track>`, `<Progress.ValueLabel>`
    fn Progress();

bindings/ is reference material, not compiled — solid-rs parses src/ only. Binding a package's whole surface would collide with names the app and Solid already own (@solidjs/router alone exports action and query), so you copy what you need. Names that cannot be imported are kept in the file as comments saying why, rather than silently dropped.

Regenerate any time with solid-rs bindings.

What this example needed from the language

Three things, added for it:

  • Dotted component tags. <Tabs.List> is a member expression on the imported value, so only the head — Tabs — is imported. This is the entire authoring style of Kobalte and libraries shaped like it.
  • on* on a component is an ordinary prop. On an element it is a DOM handler and solid-rs requires a zero-arg closure; on a component the component decides what it is called with. Kobalte's onChange={set_agreed} receives the new state. The element rule applied to components rejected every callback prop in the ecosystem.
  • A bare event handler need not be the last attribute. A bare closure has no delimiters, so where it ends and the next attribute begins is decided by parsing the longest prefix that is a valid expression. Reading greedily to the tag's > used to swallow everything written after a handler.

A module-level const used as a JSX tag already worked, which is what makes <Routes> possible.

Deep links, refresh, and 404s

Client routes are URLs the server has never heard of. /hello/solid-rs is a real page when you navigate to it and a missing file the moment you press refresh — so solid-rs preview and solid-rs dev serve index.html for any path with nothing behind it, and the router takes it from there.

The fallback is conditional, because doing it unconditionally is the classic way to break a site: a page fetching a missing script would get HTML back, and the browser would report a syntax error at <!doctype rather than a missing file. So it applies only to requests that are actually navigations — a readable method, a path that does not name an asset extension, and an Accept that asks for text/html. Both halves are needed: a script tag sends */*, and an asset URL typed into the address bar sends text/html.

The catch-all route is last in the tree, because matching is in order:

RouteDef { path: "/*path", component: NotFound },

The router splits the path on /*, so what follows the star names the parameter that captures the remainder — params.path here. A bare /* matches equally well but captures nothing, which leaves the page unable to say what was missing.

The status is 200, and that is the honest answer. A client-rendered server cannot tell a route it does not know about from a route that does not exist, and only one of those is an error. Serving a 404.html here instead — the convention static hosts use — was tried and is actively wrong: this branch is reached by every client route, so /hello/solid-rs would answer 404 with an error page and the app would never boot.

A real 404 status needs the routing to happen on the server, which is what mode = "ssr" is for: there the app matches the URL itself and sets the status with http_status(404). SSR mode already answers deep links, since every document request goes through the render rather than through the filesystem.

Two rules the router enforces, worth knowing

  • Router hooks need the provider. useLocation and useParams read a context the instance provides, so the whole layout lives inside the instance's render-prop child. A nav rendered as a sibling of <Routes> throws ContextNotFoundError the moment it asks which page it is on. It still stays mounted across navigations — only props.children is swapped.
  • Links must be real paths. href="#/about" is a fragment; the router resolves it against the current page and navigates nowhere. This is what made an earlier version of this example look like a dead page.

Run it

# from the repo root
cargo build -p solid-rs

cd examples/deps
solid-rs install     # installs the three packages, then writes bindings/
solid-rs build
solid-rs preview     # http://localhost:3000

install passes --legacy-peer-deps: the RC ecosystem pins peer ranges inconsistently (@kobalte/core pins @solidjs/web to an exact rc the toolchain has moved past), and npm's strict resolution would refuse an install that is fine at run time. Note that no second copy of Solid is installed — solid-js resolves out of the toolchain through esbuild's nodePaths, so the bundle contains exactly one reactive runtime.

Tests

node e2e/render.mjs      # the page, and navigation
node e2e/deeplink.mjs    # refresh on a deep link, and the catch-all

e2e/render.mjs is 22 checks that assert the libraries' behaviour rather than that markup reached the page: the progress bar's role="progressbar" and aria-valuenow, the data-checked attribute the CSS selects on, the roving tab selection, and navigation driven by dispatching a real click on an anchor so the router's own handler is what runs.

Two things the suite has to accommodate, neither a solid-rs concern:

  • jsdom implements no layout, so scrollIntoView and getBoundingClientRect are shimmed — Kobalte calls both while managing focus.
  • Two clicks inside one flush both read the pre-flush count() and both write 1. That is Solid's batching being correct, so the test lets each settle, as a user would.

e2e/deeplink.mjs covers the refresh case, and covers it on both sides: it starts a real solid-rs preview and checks over HTTP that deep links serve the document while missing assets stay 404, then boots the app in jsdom at /hello/solid-rs — the first URL it ever sees, as a refresh would — and checks the route matched on the very first render.