Skip to content

ssr: guarded link-claim hole on anchors — the router can render aria-current/data-active (#3878) - #3919

Merged
ryansolid merged 1 commit into
nextfrom
feat/ssr-link-claim
Oct 8, 2026
Merged

ryansolid merged 1 commit into
nextfrom
feat/ssr-link-claim

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Closes #3878. The core half of solidjs/solid-router#654 (and the deferral solidjs/solid-router#665 needs).

Summary

Element claims never fire during SSR, so a plain <a href> arrived without aria-current="page" / data-active — late on hydrated pages, never on pages that do not hydrate. Both compilers now give every candidate anchor in SSR output one hole after its attributes, ssrLinkClaim(attrs), where the render's link handler writes the anchor's link state, and "" otherwise:

var _tmpl$ = ["<nav", "><a href=\"/about\"", ">About</a><a", "", ">Dyn</a></nav>"];
var _lk$ = { href: "/about" };                       // hoisted: no allocation per render
_$ssr(_tmpl$, _$ssrHydrationKey(),
  _$ssrLinkClaim(_lk$),                               // static anchor: eager
  _g$, _g$);                                          // dynamic: joins the attribute group …
var _g$ = _$ssrGroup(() => [
  _$ssrAttribute("href", _$escape(_lv$ = p.to, true)),
  _$ssrLinkClaim({ href: _lv$ })                      // … and reuses the raw href
], 2);
  • Compilers (@solidjs/babel-plugin, @solidjs/compiler, at parity — the parity ratchet has no expectation file for the new linkClaim fixture): the hole for a[href] in hydratable and plain SSR; static anchors through a hoisted, deduped attributes object (_lk$N); dynamic ones capture the raw value in a function-local temp (_lv$N) at the attribute hole and read it in a groupable thunk after it (a template-literal href keeps its inline-quoted slot — the whole value escaped at runtime instead of its parts, the same bytes). An anchor's link attributes written after a spread stay a source instead of baked tail markup so ssrElement sees them.
  • Compile-time exclusions (method D below): a static non-empty target, a download, a rel naming external, an author-written aria-current, an empty href, a non-HTTP scheme (mailto:, tel:, javascript: …) → no hole, no call. http(s): and //host hrefs keep their hole (see "Anything to decide").
  • Runtime (@solidjs/web server): ssrLinkClaim(attrs) reads the handler off the current render context (the one the compiled ssrClaim guard reads; every derived context — Loading buffers, the frame renderer's document-face scope — inherits it); an author-written aria-current wins before the handler is consulted. ssrElement collects a spread anchor's href/target/rel/download/link/aria-current from the winning sources during its walk and appends the handler's markup after the tail. setLinkClaim(handler) scopes the handler to the render found through the owner chain (getHydrationWriter()'s lookup), so concurrent renders never share one, and writes the links record once. No owner, no hydration id: with link state stripped, the HTML of a render with a handler is byte-identical to one without (pinned).
  • Server components: the document face renders under the page's handler (its scope is Object.create(page)); a server component's own frame stream has none unless its render sets one — refetched content arrives unmarked and the client's re-claim after the morph reasserts the state, as today. Pinned both ways.

Method comparison (Phase 1)

HN story page shape (examples/hackernews storyView + navView over a 1,406-comment thread: 1,414 compiled anchors, 9.3k nodes, 480 KB), rendered through the built server dist, medians of 100 warm iterations, Node 26. Hand-compiled variants of the real Babel output before building; the real implementation reproduces the (D) numbers (babel and oxc output render byte-identical HTML).

method HTML Δ (no handler / router handler) render Δ, no handler Δ with router handler (URL per anchor) Δ with canonical-href fast path server bundle, minified B/anchor
base (today) — 0.45 ms page — — —
(A) hole per a[href] 0 B / +192 B (6 marked × 32 B) +0.00–0.05 ms (noise floor; ≤ 0.04 µs/anchor) +1.20 ms (0.85 µs/anchor) +0.15 ms (0.10 µs/anchor) 37
(D) A minus compile-time-excluded anchors same same same same 29 (7 of 9 source anchors keep a hole)
(C) a[href] through ssrElement +3 B / +195 B +0.23 ms (+50 % of the page render; 0.16 µs/anchor) +1.76 ms (1.25 µs/anchor) +0.48 ms (0.34 µs/anchor) 12 (markup moves into props; walk cost instead)

Built (D): it is (A)'s emission with a compile-time guard that is free, and every excluded anchor is one a handler would answer "" for whatever the request. On this page (D) and (A) differ by 2 anchors (the two target="_blank" links; the 61 external rel=nofollow links of the real page are innerHTML content, invisible to both) — the saving is per excluded anchor (~37 B of server bundle and one "" slot per render), not per page. (C) is out: the spread path costs more with no handler than (A)/(D) cost with one. Client bundles: 0 B (server-only). The hole's own cost with a handler set is ≈ 0.03 ms for 1,414 calls (a () => "" handler is indistinguishable from "one unrelated hydration record"); the per-anchor cost is the handler's — new URL per anchor ≈ 0.85 µs vs ≈ 0.10 µs with a canonical-href string fast path, which is the router's follow-up from #665.

Public API changes

All @experimental, additive, @solidjs/web:

  • setLinkClaim(handler: LinkClaimHandler | undefined): boolean — server: sets the link handler of the render the caller belongs to (owner chain, else the request scope's single open render); false outside a render (dev warns); undefined clears. Client: stub, false.
  • LinkClaimHandler = (attrs: Readonly<Record<string, unknown>>) => string — type, both entries. attrs carries the anchor's link-relevant attributes that are present (href, target, rel, download, link, aria-current; raw values as written, a bare attribute as ""). Returns the extra attribute markup, each with its leading space, or "".
  • hasServerLinkState(): boolean — client: reads the links hydration record non-destructively; server: false.
  • ssrLinkClaim(attrs): string — compiler-emitted primitive (@internal), server entry; client server-mock stub.
  • ssrElement signature unchanged; it now collects link attributes for anchors under a render with a handler.
  • LinkAttributes added to web/src/constants.ts (shared with the Babel plugin); not re-exported.

Wire

  • Attributes only when a handler runs: without one the HTML is byte-identical to today's, hydration ids included.
  • The trust marker: the first setLinkClaim of a render writes the hydration record links = 1 once — _$HY.r["links"]=1 in the shell's hydration script on the document face (renderToString: the single script; renderToStream: the shell flush), a keyed data record on the first flush of a frame stream (createJSONDataTable().resolve({ $ref: "links" }) === 1). noScripts drops it with every other record.
  • The key links is Solid's; library keys are prefixed by convention.

Router contract (for solidjs/solid-router#654)

On the server, <Router> calls setLinkClaim(attrs => …) during its render with a handler that applies managedUrl's rules to attrs (target, download, rel~=external, link under explicitLinks, origin and base against the request URL) and returns linkMatcher(location, base)(target)'s state as markup — ' data-active aria-current="page"', ' data-active', or ''; a canonical-href fast path (a root-relative href with no query/hash/escapes/dot segments compared as a string) keeps this at ≈ 0.1 µs per anchor instead of ≈ 0.85 µs with a URL per anchor. On the client, setupLinkClaims reads hasServerLinkState() once: when it is true, an anchor claimed while the document hydrates (isHydrating() and el.isConnected — a server node, not a fresh clone) is registered and its aria-current/data-active are taken as the router's own (owned for a server-written aria-current="page"), with no URL resolution at claim; the first routing change's sweep resolves and rewrites as today. When it is false, or for an anchor claimed after hydration (client-created, or frame content arriving later), resolve at claim exactly as now. Anchors serialized before <Router> rendered (a document shell above it) carry no state; a shell that has links can call setLinkClaim itself, earlier in the same render.

Tests

  • Compiler: linkClaim fixture (static / excluded / dynamic / spread shapes) in __ssr_fixtures__ and __ssr_hydratable_fixtures__, Oxc outputs regenerated; attributeExpressions and attributeSlots outputs updated for the hole; parity ratchet green (no expectation for linkClaim); cross-mode expectations regenerated (recorded identifier-numbering divergence shifted by the new _lk$ declaration — same divergence, new line numbers). Babel 293/293, compiler 6166/6166 + the three cargo suites.
  • packages/web/test/server/link-claim.spec.tsx (15, both compilers): no handler → nothing; static / dynamic (raw, unescaped href; evaluated target) / excluded / author aria-current (static, literal, dynamic set, dynamic unset) / spread (trailing static + dynamic href, excluded, authored, no href); hydration ids identical with and without a handler; setLinkClaim outside a render; record once per response (and only with a handler); undefined clears; a handler set inside <Loading> covers the streamed fragment with the record in the shell; two concurrent streams settle in the opposite order and each marks its own; document-face SC (inline and late via dynamic) inherits the handler; stream-face SC has none unless it sets one, record on the response.
  • packages/web/test/hydration/link-claim-trust.spec.tsx (5): the #654 rule simulated — with the record, anchors claimed during hydration are trusted (0 URL resolutions) and the server's state survives hydration; without it everything resolves; a post-hydration anchor resolves; claimElementTree over adopted content during hydration is trusted the same way.
  • Full @solidjs/web suites against the rebuilt dist: server 162 files, client 136, hydrate 94 — green. Consistency harness, 500 cases × seeds 3289 and 91501: SC arm 0 findings; generic arm (CONSISTENCY_IGNORE=C1,C9,C19,E) 0 findings. The recorder's nondeterministic artifacts moved under the server suite and were reverted.

Size

node scripts/size/size.mjs head vs origin/next built the same way (gate: every scenario passes; the three WARNs — page: live server components, page: compiled base server components, page: live + router, over their brotli cap within the minified allowance — are on next already, +0 B over base):

scenario head base Δ
every client scenario (13 app/page + 3 compiled + frames + 3 signals) — — 0 B brotli, 0 B minified
server: floor 1,331 B / 3,324 min same 0 / 0
server: renderToString 20,403 B / 71,817 min (cap 20,420) 20,403 / 71,817 0 / 0

The server floor does not move: nothing new is reachable from renderToString (setLinkClaim, ssrLinkClaim, hasServerLinkState and the constant are shaken; the render context gains no field). No cap raised.

Bench

packages/web/test/server/link-claim.bench.tsx (CodSpeed lane, pnpm bench:server): the 1,475-anchor page with no handler, with a router-shaped URL-per-anchor handler, and with the canonical-href fast path. Local: 0.86 ms / 2.29 ms / 1.25 ms per render (source-compiled; the dist renders the same page in 0.45 / 1.56 / 0.92 ms).

Anything to decide

  • http(s): and // hrefs keep their hole — the brief's exclusion list had them, but its acceptance criterion for (D) was "a skipped anchor could never be the current page", and a same-origin absolute URL can be; only the handler knows the request origin. On the HN page this changes nothing (its one absolute link has target="_blank"). Easy to flip if you prefer the shorter list.
  • The record costs a hydration script. On a page that otherwise writes no hydration record, the first write is ≈ 0.3 ms (seroval's first write + the script); on any page that already has one it is one more record. A raw <script>_$HY.r.links=1</script> would avoid seroval, at the cost of a second emission path.
  • Stream-face record: emitted uniformly (one data chunk when a frame stream's render sets a handler); the frames client does not resolve URLs itself, so nothing in it reads the record — it is in the response's data table for an integration that wants it.

…current/data-active (#3878)

Element claims never fire during SSR, so plain anchors arrived without
aria-current="page" / data-active. Both compilers now give every candidate
<a href> in SSR output one hole after its attributes, ssrLinkClaim(attrs),
where a render's link handler (setLinkClaim, per render through the owner
chain) writes the anchor's link state and "" otherwise. A static anchor's
hole is an eager call over a hoisted attributes object; a dynamic anchor's
joins the attribute group and reuses the raw value its href hole evaluated.
Anchors the compiler can rule out (static non-empty target, download,
rel~=external, an author's aria-current, an empty or non-HTTP href) get no
hole. ssrElement collects a spread anchor's link attributes from the walk.
The first setLinkClaim of a render writes the `links` hydration record once
(hasServerLinkState() on the client) — the router's signal to trust the
server's link state at hydration instead of resolving every anchor's URL.

Co-authored-by: Claude <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6d3f23d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.43 KB 0 B 0 B +15 B 7.45 KB ✅
signals: + createStore 14.69 KB 0 B 0 B 0 B 14.70 KB ✅
signals: + isPending/latest 9.63 KB 0 B 0 B +15 B 9.65 KB ✅
app: render + one signal (the simple-app floor) 9.92 KB 0 B 0 B +15 B 9.93 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB 0 B 0 B +15 B 17.91 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.17 KB 0 B 0 B +55 B 29.19 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.92 KB 0 B 0 B +15 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.53 KB 0 B 0 B +15 B 14.53 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.85 KB 0 B 0 B +15 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB 0 B 0 B +15 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.22 KB 0 B 0 B +55 B 25.24 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.16 KB 0 B 0 B 0 B 31.17 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.11 KB 0 B 0 B 0 B 11.13 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.91 KB 0 B 0 B +161 B 33.92 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.61 KB 0 B 0 B +15 B 37.59 KB ⚠️ over by 20 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.15 KB 0 B 0 B +15 B 35.13 KB ⚠️ over by 16 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.18 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.65 KB 0 B 0 B +15 B 40.66 KB ✅ eager (counted): web.js 22.02 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.00 KB 0 B 0 B +15 B 46.02 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.33 KB 0 B 0 B +15 B 47.29 KB ⚠️ over by 36 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, trace.js 8.20 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 20 B; minified 117,456 B vs 117,441 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 16 B; minified 109,461 B vs 109,446 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 36 B; minified 148,683 B vs 148,668 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37773499066

Coverage remained the same at 76.43%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.57 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 188 untouched benchmarks
🆕 3 new benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 hn story page (1,475 anchors): fast link handler (canonical-href string path) N/A 28.2 ms N/A
🆕 hn story page (1,475 anchors): no link handler N/A 22.9 ms N/A
🆕 hn story page (1,475 anchors): router link handler (URL per anchor) N/A 40.9 ms N/A

Comparing feat/ssr-link-claim (6d3f23d) with next (8d23a5a)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants