Repository navigation
ssr: guarded link-claim hole on anchors — the router can render aria-current/data-active (#3878) - #3919
Conversation
…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 detectedLatest commit: 6d3f23d The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
Size (brotli, eager entry chunk)
|
Coverage Report for CI Build 37773499066Coverage remained the same at 76.43%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will not alter performance
Performance Changes
Comparing |
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 withoutaria-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:@solidjs/babel-plugin,@solidjs/compiler, at parity — the parity ratchet has no expectation file for the newlinkClaimfixture): the hole fora[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 sossrElementsees them.target, adownload, arelnamingexternal, an author-writtenaria-current, an empty href, a non-HTTP scheme (mailto:,tel:,javascript:…) → no hole, no call.http(s):and//hosthrefs keep their hole (see "Anything to decide").@solidjs/webserver):ssrLinkClaim(attrs)reads the handler off the current render context (the one the compiledssrClaimguard reads; every derived context — Loading buffers, the frame renderer's document-face scope — inherits it); an author-writtenaria-currentwins before the handler is consulted.ssrElementcollects a spread anchor'shref/target/rel/download/link/aria-currentfrom 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 thelinksrecord 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).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/hackernewsstoryView + 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).URLper anchor)a[href]a[href]throughssrElementBuilt (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 twotarget="_blank"links; the 61 externalrel=nofollowlinks of the real page areinnerHTMLcontent, 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 URLper 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);falseoutside a render (dev warns);undefinedclears. Client: stub,false.LinkClaimHandler = (attrs: Readonly<Record<string, unknown>>) => string— type, both entries.attrscarries 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 thelinkshydration record non-destructively; server:false.ssrLinkClaim(attrs): string— compiler-emitted primitive (@internal), server entry; clientserver-mockstub.ssrElementsignature unchanged; it now collects link attributes for anchors under a render with a handler.LinkAttributesadded toweb/src/constants.ts(shared with the Babel plugin); not re-exported.Wire
setLinkClaimof a render writes the hydration recordlinks = 1once —_$HY.r["links"]=1in the shell's hydration script on the document face (renderToString: the single script; renderToStream: the shell flush), a keyeddatarecord on the first flush of a frame stream (createJSONDataTable().resolve({ $ref: "links" }) === 1).noScriptsdrops it with every other record.linksis Solid's; library keys are prefixed by convention.Router contract (for solidjs/solid-router#654)
Tests
linkClaimfixture (static / excluded / dynamic / spread shapes) in__ssr_fixtures__and__ssr_hydratable_fixtures__, Oxc outputs regenerated;attributeExpressionsandattributeSlotsoutputs updated for the hole; parity ratchet green (no expectation forlinkClaim); 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; evaluatedtarget) / excluded / authoraria-current(static, literal, dynamic set, dynamic unset) / spread (trailing static + dynamic href, excluded, authored, no href); hydration ids identical with and without a handler;setLinkClaimoutside a render; record once per response (and only with a handler);undefinedclears; 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 viadynamic) 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;claimElementTreeover adopted content during hydration is trusted the same way.@solidjs/websuites 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.mjshead vsorigin/nextbuilt 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 onnextalready, +0 B over base):server: floorserver: renderToStringThe server floor does not move: nothing new is reachable from
renderToString(setLinkClaim,ssrLinkClaim,hasServerLinkStateand 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-shapedURL-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 hastarget="_blank"). Easy to flip if you prefer the shorter list.writeis ≈ 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.datachunk 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.