Repository navigation
Description
Observed with solid-js / @solidjs/web 2.0.0-rc.13 and @solidjs/router next. Context: solidjs/solid-router#654.
Problem
The router marks plain anchors with data-active and aria-current="page" through element claims. On the server it has nowhere to do that, so these attributes never appear in server HTML.
Neither of today's claim paths helps:
registerElementClaim(client). The compiledclaimElementcalls and the frame client's sweep over server-component HTML both hand a live element to the handler. They only run in the browser. The server build'sregisterElementClaimis a no-op.ssrClaim(server components). It only coversrefandonXxxpositions, so it never seeshref. WithserverComponentson, a plain<a href="/about">gets no hole at all.
A static anchor is baked into the template string, so there is no point at runtime where an attribute could be added:
var _tmpl$ = ["<nav", "><a href=\"/about\">About</a><a", ">Dyn</a></nav>"];Why it matters:
- Pages that never hydrate (
noScripts, HTML-only routes) never mark the current page. Screen readers are never told which link is the current page. - On hydrated pages, the highlight shows up late, once hydration reaches the anchor. This is easy to see on slow connections or long streamed pages.
The router already has a pure matching function (matchLink) and knows the request URL. What it lacks is a place in SSR output to write the result.
Proposal
Compiler (Babel and Rust, kept in parity). In SSR output, give each anchor the DOM build claims (an a with an href, or with a spread) one guarded hole after its attributes. This follows ssrClaim's pattern:
var _tmpl$ = ["<nav", "><a href=\"/about\"", ">About</a><a", "", ">Dyn</a></nav>"];
const _a$1 = { href: "/about" }; // static attributes, hoisted: no allocation per render
_$ssr(_tmpl$, _$ssrHydrationKey(),
_$ssrLinkClaim(_a$1), // static anchor
_$ssrAttribute("href", _$escape(_v$ = p.to, true)),
_$ssrLinkClaim({ href: _v$ })); // dynamic: same group, href read once- Static anchors: the hole passes a hoisted object of the anchor's attributes.
- Dynamic anchors: the claim joins the element's existing attribute group and reuses the value already evaluated for
href. - Spread anchors:
ssrElementpasses the mergedhref/target/rel/download/link/aria-currentvalues, with the same source-order precedence itsclaimsargument already follows. - The handler needs every attribute, not just
href. It has to skip links the author excluded (target,download,rel="external") and leave alone attributes the author wrote (an existingaria-current).
Runtime. A small ssrLinkClaim(attrs) helper returns "" when no handler is set for the current render. Otherwise it returns the handler's extra attribute markup.
Server components. The frame renderer clears the handler for server-component renders. Their output is cached by function and arguments, which deliberately exclude the URL, and a refetch renders without a router. So links inside them must stay unmarked on the server. The frame client's existing claim sweep keeps marking them in the browser, as it does today. This needs the explicit clear because the document-face render context is created with Object.create(page) and would otherwise inherit the page's handler.
The API for setting the handler
The router needs a public way to set this handler per render. The prototype set a field directly, sharedConfig.context.linkClaim = fn. That's how it was tested, not a proposed API:
sharedConfigis internal, and the router moved off it in Migrate off sharedConfig onto public Solid 2.0 hydration APIs solid-router#641.- The runtime's own comments warn that the global context can belong to another request.
Something next to getHydrationWriter(), which already finds its render through the owner chain, seems like the right home. Options, for core to decide:
setLinkClaim(handler): called during component setup, scoped to the render found from the current owner. It returnsfalse, or throws in dev, outside a render.- A method on the render handle, such as
getRenderContext()?.setLinkClaim(handler), if more per-render hooks are expected. - A generic
setElementClaim(tag, handler), if forms or other claimed elements should get the same treatment later. The router only needsafor now.
Handler shape, in every option: (attrs: Readonly<Record<string, unknown>>) => string. It's synchronous and pure, and returns extra attribute markup ("", " data-active", " data-active aria-current=\"page\"").
The router would call it once from <Router>'s server render, with a handler that runs matchLink against that request's location and base path.
Constraints
- Return a string synchronously. A function hole inside a tag gets a
data-lhaid in live frame renders, so the hole must evaluate eagerly to a string. - No hydration keys or owners. The hole must not create reactive owners or take ids. I checked: with the hole, a router app renders byte-identical HTML once the link attributes are stripped, including the
_hkvalues of components after the nav. - Don't reuse
context.claimsas the switch. Setting it also switches on server-component handling of object-valued attributes inssrElement, which is only meant to happen inside a server component.
Cost for apps without a router
- HTML: zero extra bytes.
- Runtime: one guarded check per anchor per render.
- Server bundle: a hoisted attribute object and a call per static anchor.
- Client bundles: unchanged.
Concurrency
I rendered two renderToStream requests at once, each behind a <Loading> boundary, with the second request resolving first. Each response got its own request's state:
/aboutmarked only the About link./marked only the Home link.
A handler scoped through the owner chain, as getHydrationWriter() already is, keeps this safe without relying on the global context.
Open questions
- Links outside or before
<Router>(for example a nav in the document shell): the server won't mark them, because the handler is set when the router renders. The client still marks them at hydration. Is that acceptable, or should the handler be settable before the shell renders? - Forms: should
form[action]get a hole too? Busy state never exists on the server, so I'd leave forms out.
Activity
ryansolid commentedon Oct 8, 2026
Additional motivation from solidjs/solid-router#665: beyond correctness, this is also the load-time cost of link claims.
On a HackerNews server-component page (1,475 anchors, 11k elements; Chrome, warm reloads, medians), router link claims cost ≈ 12 ms at load — ≈ 19% of the page's script — almost all of it resolving each anchor's href (
new URL+ getters ≈ 8.5 ms) so thataria-current/data-activeare correct at first paint.With link state in the server HTML, the hydration-time claim only needs to register the anchor and trust the server's attributes; URL resolution moves to the first routing change.
One router-side consequence for the design: a router-written
aria-current="page"coming from the server must be recognizable as router-owned at hydration (today the router assumes server HTML never carries its state). So either the hole marks ownership (e.g. a data attribute), or the router treats a server-renderedaria-current="page"on a claimed anchor as owned.ryansolid commentedon Oct 8, 2026
ssr: guarded link-claim hole on anchors — the router can render aria-…
ryansolid commentedon Oct 8, 2026