Skip to content

frames: residue 3 — every fill through insert; createFrame marker-less - #3866

Closed
ryansolid wants to merge 2 commits into
size/frames-residue-2-pull-formfrom
size/frames-residue-3-insert
Closed

ryansolid wants to merge 2 commits into
size/frames-residue-2-pull-formfrom
size/frames-residue-3-insert

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Residue step 3 of the frames size pass (documentation/plans/frames-residue-pass.md §2 row 3 / §5 row 4 — R.insert) on size/frames-residue-2-pull-form (#3864) @ f12708386. Draft; the base has not moved. Two commits: the item (source + tests + changeset) and the ratchet / docs. Measured on an edited copy of this head's dist through the size harness's own bundler before it was written (§0 method); the table below is measured vs built.

Summary

  • Frames eager: 35,250 / 11,654 → 34,272 min / 11,367 br (−978 / −287). 1,367 B above the ≤ 10 KB target (was 1,654 B above after residue 1; residue 2 built nothing).
  • Every fill through insert. Each slot fill of the Solid binding is one insert(end.parentNode, value, end, [...ctx.existing]) under the fill's owner: a static value is placed once with no effect created, a reactive one binds the range as before, an adopted fill claims through insertExpression's claim pass, disposal is the owner's. The frame-side copies of that lifecycle are gone — normalizeSlotContent, isReactiveContent, the in-place settle test, the no-range snapshot fallback — and so is the frame runtime's own range writer: #replaceRange and the returned-nodes path through #syncSlots / #invokeSlot / regions.bind(…, start).
  • createFrame marker-less. The runtime's never-used comment-marker range mode (#start / #end — null on both constructors since the element-seams decision; #parent, #firstContent, #clearContent, the end bounds passed as null to every walker) is deleted: the frame element IS the range, FrameImpl(element, options). −196 min / −67 br alone; not in the residue doc's §2 — found while making the row's "the frame element is the range" literal.
  • Two things the build found that the edit could not (both in claimRender / slotsFor, ≈ +14 br over the edited copy): the fill's second claim window — the one around the insert — must not re-gather (gathering the prefix again put every key the evaluation had just claimed back as unclaimed: 20 hydrate files red until claimRender took a bound flag; the reactive path had the same latent re-gather, masked because its content claims inside that window); and the minted binding owner must be transparent (a non-transparent createOwner() inside the hydrate pass consumed a child id from the adopting component's counter, so a keyed sibling after the frame keyed by timing — container-trace-hold-id-determinism).
  • One pin re-pinned — a ruling for the maintainer (see Pins). c01-claim-once (b).

Measured-before-written

Edited dist copies through scripts/size's bundler (.wt-logs/res3-edit.mjs, res3-measure.mjs); base = f12708386 (frames eager 35,250 / 11,654; page base 114,422 / 36,488; page live 126,294 / 40,125). min / br. The four non-SC scenarios 0 / 0 on every row.

edited dist frames eager page base page live note
ceiling — §2 row 3's cut re-applied (normalizeSlotContent, isReactiveContent, settle, the no-range fallback, #replaceRange + both call sites) −769 / −230 −770 / −195 −770 / −172 the row measured −713 / −207 on #3860's head
carrier, #replaceRange KEPT (the raw Slot "return nodes" contract preserved; insert(end.parentNode, value, end, [...existing]); a range without an end marker left as rendered) −600 / −160 −601 / −185 −601 / −166 compatibility would cost 69 br
carrier, #replaceRange gone (the Slot callback owns its range) −791 / −229 −792 / −233 −792 / −210
the dead comment-marker range mode alone −196 / −67 −197 / −3 −197 / −99
carrier (no #replaceRange) + range mode, one copy −988 / −301 −989 / −268 −989 / −247
built head −978 / −287 → 34,272 / 11,367 −979 / −217 → 113,443 / 36,271 −979 / −221 → 125,315 / 39,904 +10 min / +14 br over the copy: the bound flag and the transparent owner

Ceiling −230 (the doc's −207); carrier as written −229 → with the range mode −301 measured, −287 built; the stop rule was net < −120.

vs next @ 9d89df731 (the pass's recorded measurement): frames eager −9,142 / −2,420; page base −32,314 / −8,611; page live −32,405 / −8,691; the four non-SC scenarios as the base (hydrating +110 / +142 / +52 br vs next are the base's A0 Size-Exceptions — 0 vs base; CSR 0); server renderToString +4 / −9 (as the base).

Public API changes

All @experimental, on @solidjs/web/frames.

  • Changed — Slot (FrameOptions.slots of createFrame / createFrameElement). Before: (props, ctx) => Node | Node[] | undefined — returned nodes were placed by the frame between the range's markers (#replaceRange), undefined claimed ctx.existing in place. After: (props, ctx) => void — the callback owns its range: it places or binds its output before ctx.range.end over ctx.existing (the Solid binding does this through insert); the return value is not read. The four raw-frame tests (lifecycle-matrix/call-driven-slots ×2, server/frame-hn ×2) place through ctx.range. createFrame(boundary: Element, options?) and createFrameElement(options) keep their signatures; the internal constructor is FrameImpl(element, options) (was (element, start, end, options) with the markers always null).
  • Changed — SlotContext.range is documented as absent for a range whose end marker is missing (FRAME_MARKER_CORRUPTED, reported at discovery in dev); the Solid binding leaves such a range as the server rendered it. Before, it filled the range at the parent's end from a snapshot of the value (the "No range handle … degrade to the snapshot" path the residue doc named as the item's surface question).
  • Behaviour (no surface): a fill answering a t=0 claim with nodes that never entered the DOM is the core's hydration mismatch — the server node stays, the render is dropped, hydration reports the node unclaimed (what every compiled hole does). Before, the frame replaced the range. Pinned by the re-pinned C1 (b); the contract's C1 text amended.
  • Behaviour (no surface): a static fill creates no render effect (one insertExpression); a fill's binding owner is transparent (no ambient id consumed at t=0 — the reactive path used to consume one).
  • Internal: regions-tier.ts bind(frame, occurrence) loses its start re-scan parameter (the frame never reads a fill's output back).

Pins

  • Re-pinned — a ruling: c01-claim-once (b) "a fill returning fresh nodes replaces its range; no server node of the range is left beside the clone" → "a fill returning fresh nodes at the claim is a hydration mismatch: the server node stays, nothing is duplicated, hydration reports it". The old arm pinned settle + #replaceRange's rule — a frame-side replacement during a claim pass — which is exactly what R.insert deletes; under insert, a claim pass moves nothing (insertExpression's phantom rule), so the fresh render is dropped and the completion sweep names sc-<fid>-item#1- unclaimed. The new arm asserts that (the server node, no .fresh, textContent "onetwo", one "unclaimed" warning, no error). documentation/server-components/frames-consistency-contract.md C1's second arm reads "left as the server rendered it and reported". If the frame-side replacement is wanted instead, R.insert's static path cannot be insert (a claim pass cannot replace) — the item would keep normalizeSlotContent + settle + #replaceRange for the mismatch case, ≈ 70 br of the item.
  • Green unchanged: C1 (a, c), C9 ×3 (no phantom), C10 ×3 (ids timing-independent), C14 (dispose clean), c01-claim-window-roots, adopted-swap-post-done, boundary-arrival, tier-bind-hold (the two replay arms), the lifecycle matrix (remount, call-driven-slots, document-adoption, …), frames-* mount / unmount / dispose specs, the hydrate suite.
  • Suites: web client 129 files / 1,194 green; hydrate + consistency 87 / 446; server 159 / 1,511; pnpm types + test-types clean. Harness 500 cases × seeds 3289 / 91501: SC arm 0 findings; generic arm (CONSISTENCY_IGNORE=C1,C9,C19,E) 0 findings.
  • Artifacts: not re-recorded — this branch changes no server output. welcome-status-streamed.json moved on this machine as it does on the untouched base (the streamed render's chunk order); reverted.

Size

scripts/size on this head: every frames / page scenario under its cap; check-floor-caps.mjs HEAD: no cap raised. Caps lowered (measured + 10 B at the 0.01 KB step): frames eager 11.67 → 11.38 KB (recorded minified 34,272); page base 36.50 → 36.29 (113,443); page live 40.14 → 39.92 (125,315). The three scenarios over their cap at the base (signals floor, isPending, CSR) are byte-identical to the base.

Frames eager absolute: 11,367 br — 1,367 B above the ≤ 10 KB target.

Not done

  • The frame-side replacement for a t=0 mismatch (C1 (b)'s old rule) — removed with the item, re-pinned to the core's rule; the maintainer's to confirm or reverse (above).
  • A dev diagnostic for a Slot callback that still returns nodes (its return is silently ignored now). Not added: a new diagnostic code is its own surface item; the type (=> void) and the Slot doc carry the contract. Worth a "_SOLID_DEV_"-only warning if raw consumers exist outside the test suite.
  • The .fails C10 parity pin in container-trace-hold-id-determinism (the server's root-id consumption for a boundary component) is untouched; the client now consumes no ambient id per fill (the reactive path consumed one before), which is the id-neutral side of that gap.

Docs

  • frames-residue-pass.md: status line; §2 row 3, §4 row 4, §5 row 4 marked landed; §7c "Landed — residue step 3" with the measured → built table, what insert owns, the two build findings, the C1 (b) re-pin, the surface change, the running number.
  • frames-savings-pass.md §3: row R3.
  • frames-consistency-contract.md: C1's statement, mechanism, pin and table row.

Every slot fill of the Solid binding is one `insert(end.parentNode, value,
end, [...existing])` under the fill's owner: a static value is placed once
with no effect, a reactive one binds the range as before, an adopted fill
claims through `insertExpression`'s claim pass, disposal is the owner's.
The frame-side copies of that lifecycle go — `normalizeSlotContent`,
`isReactiveContent`, the in-place `settle` test, the no-range snapshot
fallback — and so does the runtime's own range writer: `#replaceRange`
and the returned-nodes path through `#syncSlots` / `#invokeSlot` /
`regions.bind(…, start)`. A `Slot` callback owns its range (places or
binds before `ctx.range.end` over `ctx.existing`); its return value is no
longer read. The runtime's never-used comment-marker range mode
(`#start` / `#end`, `#parent`, `#firstContent`, `#clearContent`) goes: the
frame element IS the range; `FrameImpl(element, options)`.

The second claim window of a fill (the insert) gathers nothing — the
first window's gather is in the registry, and gathering again put the
keys the evaluation had claimed back as unclaimed. The binding's minted
owner is transparent, so a fill mounting inside the hydrate pass consumes
no id from the adopting component's counter.

C1 (b) re-pinned to the core's rule: a fill answering a t=0 claim with
fresh nodes is a hydration mismatch — the server node stays, nothing is
duplicated, hydration reports it unclaimed (it asserted the frame's
replacement before). The raw-frame pins place their output through
`ctx.range` themselves.

frames eager 35,250 / 11,654 → 34,272 / 11,367 (−978 min / −287 br);
page base −979 / −217; page live −979 / −221; non-SC scenarios 0 / 0.
…/ live 39.92 KB); residue doc §7c landed table; plan §3 row R3; C1's contract text

Caps lowered to measured + 10 B at the 0.01 KB step: frames eager 11.67 →
11.38 KB (34,272 min / 11,367 br), page base 36.50 → 36.29 (113,443 /
36,271), page live 40.14 → 39.92 (125,315 / 39,904); no cap raised.

Residue doc: status line, §2 row 3 / §4 row 4 / §5 row 4 marked landed,
§7c the measured → built table (ceiling −769 / −230; carrier −791 / −229,
−600 / −160 with `#replaceRange` kept; the dead range mode −196 / −67;
one copy −988 / −301; built −978 / −287), what `insert` owns, the two
things the build found (no re-gather on the second claim window; the
transparent binding owner), the C1 (b) re-pin, the `Slot` surface change.
Plan §3 row R3. Consistency contract: C1's second arm is the core's
mismatch rule; the mechanism and table rows name `insert`.
@changeset-bot

changeset-bot Bot commented Oct 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0b090

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/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler 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

@ryansolid

Copy link
Copy Markdown
Member Author

Folded into #3860 (wip/frames-tiers-integration) per the maintainer — "merging is a bottleneck." The stack was linear, so the integration branch was fast-forwarded over this PR's two commits (87127a4fd, 8a0b090cf) to #3868's head d9395e398. The branch is kept.

On the integrated head every fill goes through insert and createFrame is marker-less as this PR built them; the Slot contract change ((props, ctx) => void — the callback owns its range) and SlotContext.range's missing-end-marker documentation are in #3860's Public API changes. The C1 (b) re-pin — a fill answering a t=0 claim with fresh nodes is the core's hydration mismatch, not a frame-side replacement — is #3860's decision 10 (confirm or reverse); the silent Slot return is decision 14.

— Claude via Cursor

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.

1 participant