Repository navigation
dom: claim elements after their initial attributes are applied; consumer-declared re-claim attributes (#3923) - #3924
Conversation
…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 detectedLatest commit: a52e686 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)
|
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>
Coverage Report for CI Build 37880168598Coverage remained the same at 76.014%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
… 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>
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 onhref/actionwrites. A router's link-state layer was told about anchors without theirhref(and withtarget/rel/downloadstill unset) and compensated by re-resolving every anchor on every location change.Now:
claimElementis once-per-element (_$claimedmark): one mount is exactly one claim. Writes before the mount claim never re-claim.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 stayshref/action.prop:hrefandxlink:hrefmap tohref.spread()→claimElementafter the firstassign), not by compiled output — which also bringsdynamic("a")/<Dynamic component="a">anchors under the contract for the first time.claimHandlersis only ever assigned byregisterElementClaim), so the every-page size scenarios are byte-identical tonext.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:
_$claimElement(_el$)_$claimElement(_el$, _o$),_o$captured at creationspread()'s effect, first runspread(), after the firstassign— no compiled claimA 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, andclaimElement's once-per-element mark makes the rerun cost one property read. The owner capture exists becauserunEffectdoes 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 withonCleanupof the claim-time owner.Before (
next),dom-source-names/bindings:After — the spread anchor has no compiled claim (the runtime claims it after the first application); a bound anchor:
Single-binding shape, and a nested target under a root with bindings on other elements (
dom/elementClaims):Deviation from the brief: it expected the
bindingsfixture 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 (hrefcovers thehrefattribute, theprop:hrefproperty path and thexlink:hrefnamespaced 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 typeElementClaimOptions.claimElement(node, owner?)(@internalcompiler-emitted primitive, client and server) — new optional second parameter: the reactive owner the handlers run under (Owner | null, whatgetOwner()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.claimElementis now once-per-element;spread()claimsa/formelements after the first application;claimElementTreemarks 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/actionhard-coded) is untouched.Router note
What
setupLinkClaimsdid only to correct claim timing, and can now drop:linkState(a)callsmanagedUrl(a)on every location change —getAttribute("href"),target,relparsing,download,new URL(...), origin and base checks — for every registered anchor, because the claim-time call could not see thehref. With the claim arriving after the initial attributes, andtarget/rel/downloadre-claims available on request,managedUrlcan be resolved once at claim time and cached on the per-element record; the location-change sweep then reduces tomatchLink(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.registerElementClaim(handler, { attributes: ["href", "action", "target", "rel", "download", "link"] })(linkforexplicitLinks) and treat theexistingbranch as "re-resolve and refresh".document.baseURIis 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 whilehasServerLinkState()(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
elementClaimsfixture (dom + dom-hydratable) in both compilers — static link, non-reactive writes + ref, one binding, many bindings, nested under a root with other bindings, dynamicform[action], spread, spread mixed with attributes;attributeExpressions/dom-source-names/bindingsoutputs 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 finalhref/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,onCleanupdisposes 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:hrefthrough spread;xlink:hrefcompiled (setAttributeNS) and through spread; a router-shaped consumer asserting it is never told about an anchor without itshref, 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 clienthrefwrite; hydrated static anchor + form; hydrated spread anchor under the hydrating root's owner.cargo test×3 feature sets ✓; web client 1296 ✓, server 1524 ✓, hydrate 466 ✓ (both with the native compiler; the claims spec also underJSX_COMPILER=babel).CONSISTENCY_IGNORE=C1,C9,C19,E0 findings. Recorder artifacts reverted.Size
node scripts/size/size.mjsvsorigin/next(a963ec1), same machine, both built fresh. Minified is the attributable number; brotli is reported as the gate reads it. No cap raised.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) paysvar _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 becausenextwas 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 pagesclaimElement,claimElementTreeandspreadare tree-shaken, so the whole delta is the live registry: per-consumer attribute lists (+ push/splice), the declared-attribute check replacing the hard-codedhref/actiontest, the shared handler loop, and the two new write-site calls (setAttributeNS,setProperty).live + routeris back under its brotli cap;base + routeris 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
Setunion (+9), moving the_$claimedmark into the shared handler loop (+15 here becauseclaimElementis 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 intoassign(0 —spreadis dead here; andassignruns 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