Skip to content

dom: claim elements after their initial attributes are applied; consumer-declared re-claim attributes (#3923) - #3924

Merged
ryansolid merged 14 commits into
nextfrom
fix/claim-after-attributes
Oct 9, 2026
Merged

ryansolid merged 14 commits into
nextfrom
fix/claim-after-attributes

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Fixes #3923. The client half of the link-claim work #3919 does for server-rendered anchors.

Summary

Compiled DOM output claimed a[href] / form[action] at creation, before the binding effect's first run applied their dynamic attributes, and the runtime re-claimed only on href/action writes. A router's link-state layer was told about anchors without their href (and with target/rel/download still unset) and compensated by re-resolving every anchor on every location change.

Now:

  • Both compilers (Babel + Oxc, at parity) emit the claim after the element's initial attributes are applied — see the ordering decision below.
  • claimElement is once-per-element (_$claimed mark): one mount is exactly one claim. Writes before the mount claim never re-claim.
  • Re-claims are consumer-declared: compiler-owned writes (setAttribute, setAttributeNS, setProperty, the spread's property path) re-invoke the handlers for an already claimed element only when the attribute is in the union of the registered consumers' declared sets. Default stays href/action. prop:href and xlink:href map to href.
  • Spread elements are claimed by the runtime (spread() → claimElement after the first assign), not by compiled output — which also brings dynamic("a") / <Dynamic component="a"> anchors under the contract for the first time.
  • Handlers run under the element's creating owner in every case, including a mount that lands from a held flush (where the binding effect's callback runs ownerless): compiled output captures the owner at creation and hands it to the claim.
  • Dormant cost is zero: without a registered consumer every hook folds away under tree-shaking (claimHandlers is only ever assigned by registerElementClaim), so the every-page size scenarios are byte-identical to next.

Ordering decision (design point 1)

"Initial attributes applied" is decided per element shape, and the claim is placed at the first point that observes the applied values in both the synchronous and the held-mount case:

Element Where its initial attributes land Claim
fully static (attributes in the template, or non-reactive expression writes at creation) creation statements last creation statement, after static writes and refs: _$claimElement(_el$)
dynamic bindings the template root's binding effect, first run — synchronous at creation, or at the landing when the mount is held last statement of the effect callback: _$claimElement(_el$, _o$), _o$ captured at creation
carries a spread spread()'s effect, first run by the runtime inside spread(), after the first assign — no compiled claim

A compiled claim placed textually after _$spread(...) / after the _$effect(...) statement would be wrong for a held mount: the effect's first run has not happened yet when that statement executes. The tail-of-callback placement is correct in both cases, and claimElement's once-per-element mark makes the rerun cost one property read. The owner capture exists because runEffect does not set the effect node as the owner — a claim trailing a held landing would otherwise run ownerless, and consumers scope their per-element state with onCleanup of the claim-time owner.

Before (next), dom-source-names/bindings:

var _el$22 = _tmpl$8();
_$claimElement(_el$22);
_$spread(_el$22, [{ get href() { return href(); } }, { title: "static" }], false, undefined, "a");

After — the spread anchor has no compiled claim (the runtime claims it after the first application); a bound anchor:

var _el$23 = _tmpl$9();
var _o$ = _$getOwner();
_$effect(() => ({ e: href(), t: target() }), ({ e, t }, _p$) => {
  e !== _p$?.e && _$setAttribute(_el$23, "href", e);
  t !== _p$?.t && _$setAttribute(_el$23, "target", t);
  _$claimElement(_el$23, _o$);
}, { name: "a.href, a.target" });

Single-binding shape, and a nested target under a root with bindings on other elements (dom/elementClaims):

var _el$3 = _tmpl$3();
var _o$ = _$getOwner();
_$effect(() => href(), (_v$) => {
  _$setAttribute(_el$3, "href", _v$);
  _$claimElement(_el$3, _o$);
});

var _el$5 = _tmpl$5(), _el$6 = _el$5.firstChild, _el$7 = _el$6.nextSibling, _el$8 = _el$7.nextSibling;
var _o$3 = _$getOwner();
_$claimElement(_el$8);                 // static sibling: at creation
_$effect(() => ({ e: first(), t: title() }), ({ e, t }, _p$) => {
  e !== _p$?.e && _$setAttribute(_el$6, "href", e);
  t !== _p$?.t && _$setAttribute(_el$7, "title", t);
  _$claimElement(_el$6, _o$3);         // bound anchor: tail of the root's effect
});

Deviation from the brief: it expected the bindings fixture to "show the claim after _$spread". It shows no compiled claim for the spread anchor instead, for the held-mount reason above; the spread runtime owns that claim.

Public API changes

  • registerElementClaim(handler, options?) — new options bag (@solidjs/web, client and the server no-op). options.attributes?: readonly string[]: the attribute names whose compiler-owned writes re-claim an already claimed element, as plain names (href covers the href attribute, the prop:href property path and the xlink:href namespaced path). The write sites consult every registered consumer's set (their union); handlers must check relevance themselves. Default ["href", "action"] — existing consumers keep today's behaviour. New exported type ElementClaimOptions.
  • claimElement(node, owner?) (@internal compiler-emitted primitive, client and server) — new optional second parameter: the reactive owner the handlers run under (Owner | null, what getOwner() returns). Omitted → the current owner, as before. This is the shape change the brief said to avoid; it is required for the held-landing owner, and only compiled output calls it.
  • Behavioural, not signature: claimElement is now once-per-element; spread() claims a/form elements after the first application; claimElementTree marks what it sweeps (a sweep is never deduped) so later compiler-owned writes to swept elements re-claim.

No other export, prop, option, or diagnostic changed. The frames client's own importless morph re-claim (frame-client.ts, href/action hard-coded) is untouched.

Router note

What setupLinkClaims did only to correct claim timing, and can now drop:

  • Per-navigation re-resolution of each anchor. linkState(a) calls managedUrl(a) on every location change — getAttribute("href"), target, rel parsing, download, new URL(...), origin and base checks — for every registered anchor, because the claim-time call could not see the href. With the claim arriving after the initial attributes, and target/rel/download re-claims available on request, managedUrl can be resolved once at claim time and cached on the per-element record; the location-change sweep then reduces to matchLink(location, rec.target) + apply — no DOM reads per anchor per navigation. The sweep itself stays (active/pending/current depend on the location); what goes is the re-derivation.
  • Register as registerElementClaim(handler, { attributes: ["href", "action", "target", "rel", "download", "link"] }) (link for explicitLinks) and treat the existing branch as "re-resolve and refresh".
  • The "claims fire during component setup, so an owner is present in practice" hedge becomes a guarantee: the owner is present for a held landing too.
  • The "element is still in the template's inert fragment" comment still holds — the claim fires before the element is inserted into the live DOM, so resolving the raw attribute against document.baseURI is still the right read.

Hydration (design point 3): under hydrate() the write sites return early and adopt the server's attributes, and the claim fires at the effect tail with the server-rendered link state — exactly what #3919's trust rule says to rely on during hydration; a router that skips resolution while hasServerLinkState() (once #3919 lands) composes unchanged, since it is still one claim per element. Later client writes to declared attributes re-claim as on a client mount.

Tests

  • Compiler fixtures: new elementClaims fixture (dom + dom-hydratable) in both compilers — static link, non-reactive writes + ref, one binding, many bindings, nested under a root with other bindings, dynamic form[action], spread, spread mixed with attributes; attributeExpressions / dom-source-names/bindings outputs updated once, identical across compilers. Parity ratchet: no recorded divergence moved. "form" added to the dynamic-renderer element lists so the dynamic fixtures compile it.
  • packages/web/test/element-claims.spec.tsx (24): claim sees final href/target/rel/download; static, single, multi-binding, nested; late mount under <Show>; held mount (branch gated by an async memo — nothing claimed while held, one claim at landing, owner present, onCleanup disposes with the branch); spread, spread mixed with attributes, <Dynamic component="a">, spread form; default set vs declared set (each declared attribute re-claims, undeclared does not, removal counts as a write), union across consumers, unregister shrinks the union, writes before the mount claim never re-claim; prop:href through spread; xlink:href compiled (setAttributeNS) and through spread; a router-shaped consumer asserting it is never told about an anchor without its href, never twice per mount, and disposes per element.
  • packages/web/test/hydration/element-claims.spec.tsx (3): hydrated bound anchor claimed once with the server's attributes then re-claimed on a client href write; hydrated static anchor + form; hydrated spread anchor under the hydrating root's owner.
  • Suites: babel-plugin 297 ✓; compiler 6166 ✓ (parity included), cargo test ×3 feature sets ✓; web client 1296 ✓, server 1524 ✓, hydrate 466 ✓ (both with the native compiler; the claims spec also under JSX_COMPILER=babel).
  • Consistency harness, 500 cases × seeds 3289 / 91501: SC arm 0 findings; generic arm with CONSISTENCY_IGNORE=C1,C9,C19,E 0 findings. Recorder artifacts reverted.

Size

node scripts/size/size.mjs vs origin/next (a963ec1), same machine, both built fresh. Minified is the attributable number; brotli is reported as the gate reads it. No cap raised.

Scenario brotli head (base) Δ cap minified head (base) Δ
signals: core floor 7435 (7435) +0 7450 20331 +0
signals: + createStore 14695 (14695) +0 14700 44647 +0
signals: + isPending/latest 9634 (9634) +0 9650 27170 +0
app: render + one signal 9917 (9917) +0 9930 27887 +0
app: hydrating (no stores) 17894 (17894) +0 17910 52992 +0
app: hydrating + every store primitive family 29150 (29150) +0 29190 92328 +0
app: CSR 12921 (12921) +0 12960 36770 +0
app: CSR, observe tier 14525 (14525) +0 14530 41299 +0
app: CSR, observe tier + attribution 28854 (28854) +0 28890 86926 +0
app: compiled floor 10121 (10121) +0 10130 28418 +0
app: compiled CSR 25223 (25223) +0 25240 79150 +0
app: compiled hydrating 31213 (31213) +0 31170 (over on base too) 99682 +0
frames: eager client consumer 11112 (11112) +0 11130 33418 +0
page: base server components 33910 (33910) +0 33920 105413 +0
page: live server components 37610 (37610) +0 37590 (over on base too) 117456 +0
page: compiled base server components 35146 (35146) +0 35130 (over on base too) 109461 +0
page: compiled live server components 40748 (40651) +97 40660 over 122943 (122933) +10
page: base + router 46046 (45996) +50 46020 over 144363 (144238) +125
page: live + router 47288 (47326) −38 47290 148808 (148683) +125
server: floor 1331 (1331) +0 1340 3324 +0
server: renderToString 20403 (20403) +0 20420 71817 +0
  • The three every-page scenarios (app: CSR, app: compiled CSR, app: compiled hydrating) are +0 B minified and +0 B brotli — identical output. Everything without a registered consumer folds away.
  • page: compiled live server components +10 B minified: the only compiled page fixture with an anchor (sc-shell.jsx, one anchor in one template) pays var _o$ = _$getOwner() and the owner argument. Within the 20 B allowance over base; the gate reads it as 5 B past the allowance measured against the recorded minified because next was already +15 B over that record before this PR.
  • page: base + router / page: live + router +125 B minified (first shape was +252; trimmed in 922fe1c). In these hand-written pages claimElement, claimElementTree and spread are tree-shaken, so the whole delta is the live registry: per-consumer attribute lists (+ push/splice), the declared-attribute check replacing the hard-coded href/action test, the shared handler loop, and the two new write-site calls (setAttributeNS, setProperty). live + router is back under its brotli cap; base + router is 26 B over it and 120 B past the 20 B minified allowance — per the brief this is reported, not absorbed by a cap raise, and needs a decision.

Trims measured and rejected (router Δ minified vs the +252 shape): a lazily built union (+23), stripping the namespace prefix at the lookup while keeping a Set union (+9), moving the _$claimed mark into the shared handler loop (+15 here because claimElement is dead in these pages; it would save ~30 B on a page with compiled anchors), per-component-scope _o$ hoisting (0 — every fixture scope has at most one claimed element; the emission already shares one _o$ across all claimed elements of a template root), folding the spread claim into assign (0 — spread is dead here; and assign runs inside the spread's effect, so it would lose the creation owner on a held landing).

Changesets

.changeset/claim-after-initial-attributes.md (@solidjs/web, patch) and .changeset/claim-after-initial-attributes-compilers.md (@solidjs/babel-plugin + @solidjs/compiler, patch).

Size-Exception: maintainer accepted +125 B min / +57 B br on page: live + router for consumer-declared re-claim attributes (#3923), 2026-10-08

Size-Exception: maintainer accepted +10 B min / +97 B br on page: compiled live server components for consumer-declared re-claim attributes (#3923), 2026-10-08

…consumer-declared re-claim attributes (#3923)

Compiled DOM output claimed `a[href]` / `form[action]` at creation, before
the binding effect's first run applied their dynamic attributes, and the
runtime re-claimed only on `href`/`action` writes — so a router's link-state
layer was told about anchors without their `href` and had to re-process every
anchor on every location change to compensate.

Both compilers (Babel and Oxc, at parity) now claim a static element at the
end of its creation statements and an element with bindings as the last
statement of its binding effect's callback, under the owner captured at
creation (`var _o$ = _$getOwner()`; the callback runs ownerless when a held
mount lands). A spread element emits no compiled claim: `spread()` claims an
`a`/`form` after its first application, which also covers `dynamic("a")`.
`claimElement` is once-per-element (`_$claimed`), and compiler-owned writes
(`setAttribute`, `setAttributeNS`, `setProperty`, the spread's property
path) re-claim a marked element only for attributes a consumer declared —
`registerElementClaim(handler, { attributes })`, default `href`/`action`,
union across consumers; `prop:href` and `xlink:href` map to `href`.

Without a registered consumer every hook folds away under tree-shaking: the
every-page size scenarios are byte-identical to `next`.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a52e686

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

This PR includes changesets to release 12 packages
Name Type
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/web Patch
test-integration Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html 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.44 KB 0 B 0 B +23 B 7.45 KB ✅
signals: + createStore 14.64 KB 0 B 0 B 0 B 14.71 KB ✅
signals: + isPending/latest 9.73 KB 0 B 0 B +21 B 9.73 KB ✅
app: render + one signal (the simple-app floor) 9.94 KB 0 B 0 B 0 B 9.94 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB 0 B 0 B 0 B 17.93 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.28 KB 0 B 0 B 0 B 29.29 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.94 KB 0 B 0 B +21 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.56 KB 0 B 0 B 0 B 14.56 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.89 KB 0 B 0 B +20 B 28.89 KB ⚠️ over by 3 B, 0 B minified headroom lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB 0 B 0 B +23 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.25 KB 0 B 0 B 0 B 25.30 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.17 KB 0 B 0 B 0 B 31.20 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.41 KB 0 B 0 B 0 B 11.41 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.21 KB 0 B 0 B +16 B 34.22 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.25 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.98 KB 0 B 0 B 0 B 37.98 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.24 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.41 KB 0 B 0 B +16 B 35.47 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.24 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.05 KB +24 B (+0.1%) +10 B +174 B 41.06 KB ✅ eager (counted): web.js 22.01 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.25 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.56 KB +27 B (+0.1%) +125 B 0 B 41.57 KB ✅ assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 8.28 KB, wire.js 0.94 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.34 KB +59 B (+0.1%) +125 B 0 B 47.34 KB ✅ eager (counted): client.js 27.85 KB; assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 8.23 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)

  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 3 B; minified 86,931 B vs 86,911 B recorded with the cap (+20 B) — 0 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).

@codspeed

codspeed Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 195 untouched benchmarks


Comparing fix/claim-after-attributes (c131d5a) with next (1c14992)

Open in CodSpeed

ryansolid and others added 4 commits October 8, 2026 12:15
The router scenarios' growth was all registry and write-site code (the
hand-written pages tree-shake `claimElement`, `claimElementTree` and
`spread`). Drop the cached union `Set` and its namespaced-form expansion:
the write sites scan the per-consumer attribute lists directly and look a
qualified name up by its local part (`xlink:href` → `href`, no allocation
for a plain name). Inline the default list; optional-chain the options.

page: base + router / live + router: +252 → +125 B minified vs next.
Every-page scenarios stay byte-identical.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ibutes (#3923)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

coveralls commented Oct 8, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37880168598

Coverage remained the same at 76.014%

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: 1312
Covered Lines: 1061
Line Coverage: 80.87%
Relevant Branches: 1031
Covered Branches: 720
Branch Coverage: 69.84%
Branches in Coverage %: Yes
Coverage Strength: 26.92 hits per line

💛 - Coveralls

ryansolid and others added 9 commits October 8, 2026 14:46
… attributes (#3923)

CI measured the one compiled anchor at 40,748 B brotli, 88 B over 40.66 KB, and the recorded minified was already 15 B over on next. Maintainer accepted the exception.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The compiled and router scenarios this change grew are set to the CI
measurement, rounded up to 0.01 KB, with no extra cushion.

Co-authored-by: Cursor <cursoragent@cursor.com>
Measured on next after the loading-wake landing. Caps match that
brotli size, rounded up to 0.01 KB. Hello world stays under 10KB.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the caps #3884 just landed. The overlapping compiled scenarios
are remeasured on the combination before this lands.

Co-authored-by: Cursor <cursoragent@cursor.com>
Measured on next after the superseded-verdict landing. Caps match
that brotli size, rounded up to 0.01 KB. Hello world stays under 10KB.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the caps #3898 just landed. This branch's router caps stay;
the overlapping compiled-live scenario is remeasured on the combination.

Co-authored-by: Cursor <cursoragent@cursor.com>
Measured on next after #3898. Cap matches that brotli size, rounded
up to 0.01 KB. Hello world stays under 10KB.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit 617d195 into next Oct 9, 2026
7 checks passed
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