Repository navigation
frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) - #3860
Merged
Merged
Conversation
…er hydration ends (frames-rulings 3.6 (iii), the 3e port) S1's third commit (9927ddd) ported onto next, minus its `claiming` hint: - `materializeContainerTrace` parks a replayed backlog beyond the snapshot (`limit = 1`) until hydration ends — `onHydrationEnd`, the next microtask when none is in progress — then releases it with a version bump so the compute drains the backlog as one ordinary update. A trace is materialized at a fill's arg-read; when that fill CLAIMS adopted markup the snapshot is what the markup was rendered from, the claim trusts the markup (a text hole is never rewritten during a claim), and a store already past it left the DOM diverged for good. Release order (3.2): claim → the frame's hold release → done → backlog. A failure applies in order, after the parked patches. The park is unconditional (every replayed backlog), not keyed on hydration being in progress at materialization: the projection's first compute runs at creation, materialization precedes the frame's claim window, and a claim made AFTER hydration-done — an occurrence inside a server <Loading> whose fragment reveals after done (corollary 4: the server's inner loading state registers nothing) — has no hydration state that says "claim". Keyed on `isHydrationInProgress()` the harness kept 7 / 7 such cases red on the two seeds. A fresh mount pays one beat for not being told apart: its backlog lands a microtask after the snapshot, before any paint. - The projection's root is DETACHED (`runWithOwner(null, createRoot)`): rooted under the reading owner, a trace revived at t=0 consumed one child id while one revived by a late claim consumed none, and a keyed sibling after the frame hydrated under different keys in the two runs. Pins: harness C19 ×2 flip to `test`; `c19-claim-reads-snapshot.spec.tsx` (the t=0 claim past the markup, the deferred claim under the hold, the release order, the post-done claim at a reveal, the C11 consequence, id determinism — S1's container-trace-hold-{snapshot, hydration-end, id-determinism} re-cut for next); solid `container-trace.spec.ts` (id neutrality, the park's three timings, the in-order failure). Harness oracle: the settled trace law exempts a settle point inside a claim's park (contract C11, "every observable point" read as "outside a claim's park"); the end stays strict. Campaign, 500 cases: seed 3289 83 → 0, seed 91501 79 → 0, every law un-ignored. Contract: C11 amended, C19 re-read under (iii), R10 corrected (S1's path is the park, evidence for (iii) not (i)), the fix order's item 3 decided. Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ation window (hydrateWindow); R.claim deleted The savings plan's A2, S-hold's window form (frames-rulings 3.1 / 3.2): the frame's hold is `initBoundaryResume`'s registration (#3831's `holdBoundary`, kept), and the resumed fill re-enters hydration through the same window a streamed <Loading> resume opens — instead of a registry and a hydrating flag of the frames client's own. solid: `hydrateWindow(id, fn, scope?, o = getOwner())` factored out of `resumeBoundaryHydration` and exposed as `sharedConfig.hydrateWindow` (@internal): the keys under `id` gathered into the registry (the captured `scope` pair when another hydrate() root replaced the live one, #2917), hydrating on, `o` the claim owner (#3504 — a render the window forces elsewhere is a client render), and `o` the snapshot + live scope when none is open: a late claim no longer re-marks the root's scope through `markTopLevelSnapshotScope` and leaves it unreleased (two C11 cases on seed 91501 with a first cut that skipped this), and inside an open scope (the root pass, an enclosing window) the claim joins it and releases with it. A window opened after hydration-done turns snapshot capture on for its span and clears it, as the old setter dance did. `sharedConfig.claimRoots` typed; the claimant sets it. The resume path's behaviour is unchanged (hydration mode off after a resume, whatever the flag read before). web/frames: `claimRender` is one `createOwner({ id: prefix })` and the window call. Deleted: `gatherClaims` (the range walk), `hasPendingFragment` (the window always engages), the root-registry hand-over, and the flip of `sharedConfig.hydrating` through its setter — which reset hydration-done and re-ran `checkHydrationComplete` from outside the runtime. `adoptBoundary` captures the registry/gather pair it adopts under so a claim long after (under the hold, at a fragment's reveal) gathers against the frame's root — pinned by c01-claim-window-roots (fails without the capture). web: `gatherHydratable`'s prefix gather selects natively (`[_hk^="…"]`); it runs once per adopted occurrence now, not only per late resume. Size (local, vs the integration branch): frames eager 43,414 → 42,932 min / 13,787 → 13,678 br (−482 / −109); app hydrating (no stores) 52,626 → 52,744 min (+118) / 17,728 → 17,833 br; compiled hydrating 99,257 → 99,381 (+124) / 31,023 → 31,099; page base 145,757 → 145,501 (−256) / 44,882 → 44,885; page live 157,720 → 157,464 (−256) / 48,595 → 48,631. The hydrating scenarios are over their caps (brotli); no cap raised. Pins: solid hydrate-window.spec (the seam's contract), web c01-claim-window-roots (multi-root). Suites: solid 830; web client 1142 / 1 xfail, server 1474 / 3 xfail, hydrate 367 / 4 xfail; harness 500 cases × 2 seeds, 0 findings. Co-authored-by: Claude via Cursor <noreply@cursor.com>
… claim window (A2b); the multi-root pin listed Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ark is unconditional; hydrateWindow's hydrating cost accepted - frames-rulings 3.6: "Landed" — the park is unconditional (post-done claims under corollary 4 have no hydration state that says "claim"); S1's `claiming` hint at plan step C3 keys it on the claim again. - frames-rulings 3.2: cost as landed — #3837 + #3840, frames −109 br / hydrating +105 br; the hydrating cost accepted, caps raised under a Size-Exception at the next integration PR. - consistency contract R10 / C19: the park's key described as it landed. - savings-pass plan §3 row A2: landed in two parts, measured vs estimate, further solid-side seams measured on an edited dist copy before they are written.
…e response's table; the dedupe, the latches, stageTables/STAGED_DATA/onStream and the threaded resolver deleted
S-ref (frames-rulings 1.3, L1). `createFrameHost.apply` settles a `slot`
chunk's `{$ref}` args at the write, through the integration's table for
the chunk's RESPONSE (`FrameHostOptions.resolve(ref, frameId, version,
current)`), so the record the store holds — and every mount reads —
carries values, never refs, and a later response's data can answer none
of them. A key the response has not delivered yet resolves to a pending
read owned by the store (keyed by version and key; stamped `s`/`v` as a
serialized promise once it settles): its `data` chunk settles it and
re-applies the records it was the last open read of, the stream's
`complete` or `error` rejects it (L1 — a value that never comes surfaces
where it is read, instead of leaving the record silently unapplied), a
bump drops the superseded response's waits unanswered. A FRESH mount waits
for its record to settle (`record.pending`), so the frame's shell shows at
its landing with the range as the server left it and the mount's covering
boundary pends on the landing alone (A0, corollary 4 — a fill whose first
read pended into that boundary would hold the frame's own address follow
behind the fallback); a MOUNTED occurrence takes the read as it is and its
prop holds what it shows (A17, the DR-2 value tier). The frame no longer
resolves refs: `#refsUnresolved`, `#resolveRef`, `#slotResolvedRefs`,
`#refArgsUnchanged`, `#reconcileRegions` and the `resolve` parameter
threaded through `preview` delete; the host notes a record's `{$frame}`
args (`record.regions`) and decoded args (`record.decoded`) so the frame
never probes a value (a decoded value may be a live container).
Codec data tables are per RESPONSE (frames-rulings 1.2): `client.ts` keys
them by frame id and version — the shown response's and a staged refetch's
coexist — and drops versions below the store's `current` at the next use.
`stageTables`, `STAGED_DATA`, `beginStream`, the prefix walk and
`ServerComponentHandlerOptions.onStream` delete; a staged response's data
chunks write through to the host as they arrive (into their own table).
R.dedupe. A re-sent record always updates the live occurrence's props; the
fill's props read through one memo per prop whose `equals` (`sameArg`:
identity, then structural for decoded plain data; containers, async values
and nodes by identity) is the dedupe — a record whose refs decode to equal
values churns no reader. A live container is held boxed in its memo (the
core probes a memo's result for `.then`; a pending container's trap throws).
`FrameHostOptions.isContainer` / `FrameHost.isContainer` have no caller and
delete.
R.version / R.error. The applied root and the notified error are keyed by
record identity (`#appliedRoot`, `#appliedError`; frames-rulings 2.1/2.2);
`Frame.rebase()` (no caller since #3830) deletes.
NOT deleted — the gap, reported: `stage`'s buffer + `CONTENT_TOKEN` +
`stagedContent.preview/commit` + `FrameImpl#preview`/`host.preview`
(reduced). The token is how a refetch of the address a mount SHOWS enters
the reactive graph at all (`dynamic` delivers a kept resolution only when
its address differs), and the compute-half preview is what stages the
refetch's slot args with the transaction that read it: without it a fill
deriving optimistic intent over an arg re-derives from the OLD arg in the
pass that dissolves the intent (`frames-optimistic-hold` ×3, principles
§9.2.2) — one flush before the effect-half commit pushes the new one. C15
itself stays green either way (the DOM never shows the tear; a memo does).
Pins: C5 (b, c) re-pinned to observe the rotation through the fill (the
host's resolver is no longer a surface); `frames-flight-delivery`'s
`onStream` probe → a probe registration seeded from the resident store.
… its marker; the #2968 poll deletes The document face (`createDocumentSlotProps`) writes `sc:slot:<fid>:<occ>` as a DECLARED pending value under its key — serialized at the marker, ahead of the fill and of any fragment that carries the range — and settles it with the args once they are classified: the shape a fragment's `<key>_fr` takes, so the adopting client awaits a record that trails its range's reveal through the value's own `.then` instead of polling the registry for a plain write. `adoptBoundary.drainRecords` reads a settled declaration's stamp synchronously (an adopt-time claim must not take a pending beat — readHydratedValue's rule), awaits a pending one, observes a rejected one. A sync render (`renderToString`) has no later script and no serializer for a promise: it writes the settled value, as before. A fill that throws settles its declaration empty so the response is not held open. Output shape: ≈ +32–38 B per document slot record (measured over the eight artifacts carrying one; the resolver/settle helpers are the page's `_fr` declarations' already). The 148 artifacts re-recorded: 8 changed. Client: `#recordRefresh` and the `setTimeout` re-drain poll delete, with `FrameOptions.recordsPending` / `FrameOptions.drainRecords` (no caller); a recordless called occurrence waits — the declaration's settle is the write that re-syncs it. The hold (3.2) is unchanged. No new wire TYPE: the declaration is the hydration-value mechanism the page already uses for fragments, applied to slot records (document face only). Pins: C2 (a2) flips to `test` (the record settled after the reveal, parser done, no fragment pending). The late-record pins re-shaped to the declared protocol (declared at boot/with the fragment, settled where the plain write was): c01 (window roots), c03 (a), c04 (b), c09 (c), c10 (b), c14 (a), c19 (b, c, e), hydration/adopted-slot-late-record; the harness declares a render occurrence's record with the markup that carries its range and settles it at the `record` event. Campaign: 500 cases × seeds 3289, 91501 — 0 findings.
…A1b removals landed (A1b + A4); the preview kept as the Transaction's carrier; public surface updated
…landing rejects on :error, the content node throws to the client <Errored>, reset re-asks (a new flight for the same address) A0, corollary 4 outward (frames-rulings 3.3): a frame is one async value, and its `:error` is that value rejecting — exactly as any `createAsync` that rejects. `FrameHost.landing` rejects with the error record at the response's error write; the mount's content node throws it to the nearest client `<Errored>`, so the covering `<Loading>` never releases over an empty `<solid-frame>`. An error AFTER the landing (the same response erroring after its root, a cut-off stream, a refetch's response erroring) is the L2 "errored flight after a landing" case and errors the node as an async iterable that yielded and then threw does — the shown value is not kept beside the error. With no client `<Errored>` the core halts (REACTIVITY_HALTED), as for any uncaught async error. `reset` re-asks: the `<Errored>`'s reset recomputes the node that threw; it re-reads `host.landing(address)`, and an errored landing is not a landing for a fresh consumer — the re-read is a promise for the next flight, which the mount opens itself (`dynamic`'s factory is hoisted and never re-runs for a reset): the handler records the call behind every address it handles or the document answered (`callFor`, @internal) and the client re-invokes it through the same declaration shape (a GET read stays GET). A response for an address whose mounts show an error writes through instead of being staged. Pins: frames-errored-reset-refetch.spec (new, A6's .fails pin made test + 8 arms); the lifecycle error arms, the switch-errored arm, C17 (b) and frames-live's error arms re-pinned to the new reading; four switch pins now send `complete` before closing (a close before complete is a death, which now errors the frame). Co-authored-by: Claude <noreply@anthropic.com>
…bound RPC seam, not by importing the transport The first cut imported `createServerReference`/`GET`/`invoke`/`withMeta` from the server-function client entry, which retained the whole transport in the frames eager graph (+11 KB min on the frames scenario). The registry's RPC slot (`provideServerFunctionRPC`, read by routers for the same reason) now carries `createServerReference(id)` on the client half, and the frames client reads the slot and the declaration-metadata brand through their registered symbols — the pattern the frame runtime already uses for every cross-bundle brand — so neither bundle retains a second registry copy. The error tick shared by both mount faces is one helper (`failing`). Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…s/retry model deleted frames-rulings 2.3 (a reveal is an apply) applied to the stream face's own segments: a revealed segment's content is applied against the store as it is revealed — its fills mount (as before) and the segments whose placeholders it carries reveal INSIDE it, in the same flush and before a reconstructed boundary commits the content — so `#flush` makes one pass over the frame's segments instead of retrying until no pass progresses (the store-model analogue of `$dfd` the re-attribution classed R.reveal), and a segment nested in content a pending fill holds reveals into the detached fragment instead of waiting for a chunk that never comes (pinned: two nested arms in the lifecycle matrix). The second ledger goes with it: `#revealed` / `#fallbackShown` / `isRevealed` — a reveal's applied state is the content record it applied, a fallback's the gate it materialized, by identity (2.1), in the one applied map the hole appliers already use; whether a segment is SHOWN is the DOM's to say (2.4: its placeholder is gone once it swapped). The segment swap's DOM half, the style gate and the per-flush content + gate + placeholder test stay (T.morph, F.assets, and the test the re-attribution says always stays). `Frame.isRevealed(segment)` (public, @experimental) is removed — no caller. Measured (frames eager, min): −45 against A7's head; the estimate (≈ −140 br) counted the fallback pass and the style gate, both kept. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…A7: the landing rejects, a later error errors the value, reset re-asks; the reach gap recorded), 2.3's stream-face half landed (A3); the plan's A3 row measured Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ror op errors a dynamic mount over the document's element, reset re-asks the intercepted call; the direct-placeholder gap Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…onFragments deleted
Ruled 2026-10-06 (12:55): a placeholder inside a server component's element
is the frame's content by rendering, not by adoption.
solid-js (hydration ledger):
- fragmentPolicy swaps post-done when the fragment is claimed OR owned —
ownedFragment(id): the pl-<id> template is in the document and
_$HY.fa(pl) says the integration owns it. No _adoptedRoots, no replay.
- _$HY.fr is { pending, subscribe }; claim/release removed (releaseFragment
deleted; claimFragment stays for initBoundaryResume).
- fragmentPending reads a revealed fragment from _$HY.v before its _fr
stamp (the producer's real order: swap script, fe, then the settle) —
the latent exhaustion bug under which a page's last reveal never read
as exhaustion.
@solidjs/web (frames client):
- installRevealHook installs _$HY.fa once: pl.closest("[data-fid]") not
in disposedFrames (C14: a boundary disposed in place marks its element
and the region elements inside it).
- claimRegionFragments / claimedFragments / cascade claim half / release
loop deleted; the dev-only (c1) rejection report stays as an IS_DEV
pl-* scan (0 prod bytes).
- documentBoundary pends on awaitBoundary(id) — the intercept's one
deferred answer; boundaryWaiters deleted (G9).
Pins: adopted-swap-post-done.spec (G4 — adopted/then post-done; swap
before the adoption with synchronous record read; nested region;
held-outside control), boundary-arrival.spec (G9 — pend then adopt;
exhaustion in the real order through the shipped $df; shared answer;
disposed during the wait), C14 (e) dispose in place + control, C12 (c3)
post-done client half, C2 (c2) re-pinned (no hold, no replay),
frames-late-boundary-client swapIn stamps _fr after fe.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ng (measured table), decided-by-default 8 superseded, contract mechanism refs, plan row A5 Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The sink's `sweep()` collects the hole / attr re-emissions one pass
produces and emits them as ONE `{ type: "ops", id, version, ops: [...] }`
chunk on the stream face (one wire line) and one `sc:live` op of the same
shape on the document face; a sweep that changes one binding emits that
member alone, addressed, as before. Members ride unaddressed — the
envelope addresses them.
Client: `chunkToRecords` merges a unit's members into one record map, so
`FrameImpl.apply` writes them in one write and `#flush` lands them in one
hole pass; the hole pass now announces `frame:applied` ONCE per flush (it
announced per hole, which let a listener read the DOM with one hole of a
sweep moved and its sibling not). `applyFrames.drain` and `applyLiveOp`
pass the unit through unchanged. The document op log (`liveOps`,
last-value-wins per target) flattens a unit into its members.
Pins: `c13-sweep-atomic` (a, b) flip to `test`, fed the unit as the sink
now emits it, plus a (log) arm for the catch-up replay;
`test/server/frame-sweep-ops.spec.tsx` pins the sink on both faces (a
two-hole sweep → one unit; a one-hole sweep → the plain member).
`frame-live-holes-projection` reads the unit's members.
Wire: additive `FrameChunk` member — RFC addendum in
documentation/server-components/frame-streams-rfc.md, with what an old
consumer does with it (drops the write; under-updates, never tears).
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A plain (non-`live`) server component whose content reads a standing source kept its response open until the source settled — never, for a generator that never returns — with none of `live`'s reconnect semantics. `frameStream` now ends such a response at a bound, detectably: - `bound: "yields"` after `maxYields` emitting sweeps past the first flush (default 64; a sweep that emits nothing is not a yield — the sink's new `onYield` hook counts them); - `bound: "time"` `maxDurationMs` after the first flush (default 30 000), or when the request's `signal` aborts after the first flush (a platform deadline the client can tell from a death); before the first flush, or for a `live` response, the abort stays the death it was. The sink's end-latch runs as for any completion, `complete` carries the bound, the body closes, and the render is torn down quietly — the abort reason carries `quiet: true`, which `renderToStream` reads as "the response chose to end" (no SSR_STREAM_ABANDONED). A body's own cancel (the reader left) stays a death: never dressed as a bound. A `live` response is never bounded. Client: `:bound` is stored beside `:complete`; the frame lands as on any `complete`; in dev the host warns once per cut-off, naming `live()`. Public surface: `FrameStreamOptions.maxYields` / `maxDurationMs` (new); `createFrameSink(emit, frame, have, hooks?)` (fourth parameter, `onYield`) and the sink's `end(bound?)`; `FrameChunk`'s `complete` gains `bound?`. RFC addendum in frame-streams-rfc.md. `frameTransformResult` takes the defaults (it is a policy function with no options object). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…C12 c)
A server `<Loading>` inside a server component that fails after the first
flush has no client twin to render over its position; the fragment used
to carry a blank (`" "`) and the position emptied silently (R6). The
document face now renders what the SERVER rendered for the outcome, as
the stream face's `meta.error` path already revealed (frames-rulings 3.3,
A0 corollary 4 inward):
- the nearest SERVER `<Errored>`'s fallback for the error, at the
`<Loading>`'s position — asked through the boundary error handler's new
`outcome` mode (`createErrorBoundary` answers with its fallback as
finished markup when it is inside the component's scope; a `<Loading>`
between passes the question up; an `<Errored>` outside the component —
the app's twin at t = 0 — answers nothing). Rule: "a post-flush error
inside a server component's <Loading> shows the nearest <Errored>'s
fallback at the boundary's position". Ids are the component's own
hydration-free scope's; head / asset registrations drop as before;
- with no server `<Errored>` the error ESCAPES the component — the frame
as one async value errored, the outward face: the stream face's unkeyed
`error` chunk (`:error`); the document face's `sc:live`
`{ type: "error", fid, error }` op, which only the owning adopted
boundary applies (`applyLiveOp`'s `fid` gate now covers unkeyed error
ops) — and the position keeps the boundary's own fallback, never a
blank. `_fr` still rejects and the keyed error chunk still rides.
Outside a server component nothing changes: the blank the client twin
renders fresh over.
Pins: C12 (c2) flips to `test` (the page carries the Errored's fallback
as the server now writes it); (c3) added for the escape arm;
`test/server/frame-fragment-error-outcome.spec.tsx` pins the sink on
both faces, the nested-Loading pass-up, and the unchanged non-component
case. The maintainer's refetch question (a) is pinned
`test.fails` in `test/frames-errored-reset-refetch.spec.tsx`: it fails
at its first step — the client `<Errored>` never catches a frame's
`:error` on this branch (the landing resolves on it) — and the two-part
fix is described in the pin and in frames-rulings 3.3.
Internal surface: `HydrationContext.registerFragment`'s resolver takes a
third `escaped?: { frame?: string }`; `HydrationContext.frameId`
(`@internal`, set by `frameTransformDirectResult`); the module-internal
`ErrorContext` handler takes `(err, outcome?: true)`.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… the 2026-10-06 approvals, the refetch rule under 3.3
- The server-half drafts section gets a status block: the three drafts as
built on `fix/frames-a6-server-half`, each with where it differs from the
draft (the per-flush `frame:applied`, the signal-as-time-bound, the
document face's escape op) and what is not built (the client's outward
`:error` face).
- The maintainer's approvals of 2026-10-06 recorded: A4 = the declared
slot record (not the `_$HY.r` hook); A1b's surface removals approved
("not even beta"); S-adopted to be built; the 30 KB target stated against
`page: base` without the router, to be reviewed after the first size pass.
- 3.3 gains the refetch rule (a) as a ruling-by-A0 with its pin status
(`test.fails`, failing at the client <Errored> never catching) and the
two-part fix; (b) what slot content can reach today (nothing
frame-specific; full app context) and the smallest addition
(`SlotContext.address`).
- The as-landed table's A6 row, the still-red list and the public-surface
list updated with A6's items.
- Savings pass §6 decision 5: "accepted for now; review after the first
size pass" (maintainer, 2026-10-06).
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
`chunkToRecords`' `ops` arm as one `Object.assign` over the mapped members; `:bound` written beside `:complete` unconditionally (`undefined` for a settled response, as `holes` is for a root without them); `applyLiveOp`'s frame gate as `op.fid && op.fid !== id`. Frames eager: +99 B min / +46 B br over the branch (was +155 / +42). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Answers "are the frames consistency reds SC-only, or generic hydration holes?" for Solid 2's plain `hydrate()`: classifies C1–C19 / R1–R10, drives the generic twin of every candidate through a frames-free page (two streamed `<Loading>` boundaries over module-level state, rendered by `renderToStream` in both fragment orders), by hand and under a property harness, and pins what it finds. Tests and docs only; no engine change. Six generic reds (`test.fails`, each with a control): - GH1 C19 — a memo created before snapshot capture (module-level) is read live by a boundary resume's claim pass; the server text stays. - GH2 C19 — a shell async memo adopted pending (no creation snapshot), re-run by a client write before a later boundary resumes — same. - GH3 C19/C1 — a store write to a leaf no reader materialized is not snapshotted: `<For>` at the resume renders the post-write length, key miss for the extra row, list short until the next structural change. - GH4 C9/C12/events — a boundary resuming while a shell async source is in flight commits its fallback over the settled server content at the snapshot release (unrevealed core boundary); the server nodes are detached then re-attached, a click queued on them is lost. - GH5 C3 — hydration-done does not count a root's module preload: a second root finishing synchronously drains done while the first still waits; the bootstrap then stops capturing events for it. - GH6 C14 — disposing a root during its module preload does not cancel the deferred render; the root mounts after dispose and stays live. Holds confirmed on plain pages (1000 harness cases, two seeds, nothing outside the six): C2, C3 for `<Loading>` holds, C10, C12 at settle points, C14 for boundary disposal, queued-event replay, the #3504 snapshot for plain signals. packages/web/test/consistency/generic/: scenario.ts, run.tsx (oracle), campaign.spec.tsx (opt-in, `CONSISTENCY_FUZZ=1`, same knobs as test/consistency/harness), replay.spec.tsx (GH1–GH4 + holds), preload-hold.spec.tsx (GH5, GH6), README.md. Shared page test/harness/generic-hydration.tsx; artifacts from test/server/generic-hydration.gen.spec.tsx. Contract doc: new section "Generic hydration — classification and pins (2026-10-06)". Co-authored-by: Claude via Cursor <noreply@cursor.com>
…aps unchanged The three hydrating scenarios carry A2b's sharedConfig.hydrateWindow and A5′'s _$HY.fa in solid-js — costs the maintainer accepted 2026-10-06 (frames-rulings 3.2 "Cost as landed", 3.3). Caps set at measured + 10 B at the 0.01 KB step, recorded minified updated: app: hydrating (no stores) 17.73 -> 17.85 KB (52,626 -> 52,794 min) app: hydrating + stores 28.93 -> 29.08 KB (91,684 -> 91,858 min) app: compiled hydrating 31.03 -> 31.09 KB (99,257 -> 99,431 min) No ratchet: frames eager / page base / page live measure over their caps by 14 / 143 / 115 B brotli with minified −435 / −138 / −138 vs the recorded sizes (the gate's minified rule passes them with a warning); nothing is below its cap by a 0.01 KB step that this PR moved.
…table, harness clean on two seeds, frames eager measured against the target; rulings status → Phase A landed Co-authored-by: Cursor <cursoragent@cursor.com>
…stepped 1 ms — its three flush points are three chunks on every host CI (#3849) ran the server suite under load: the page's 5 ms data value and its 15 ms fragment fell due in one event-loop turn, deferFlush wrote them as one chunk, and the hydrate suite then read a two-chunk artifact — every generic schedule's chunk indices shifted (C2 with no chunk → the C14 dispose pin threw at 'matchAll'; the GH1/GH2 'write between the reveals' pins became writes after both reveals and passed, failing as test.fails). Locally the same artifact self-re-recorded under the parallel suite. The generator now steps a fake clock (setTimeout + setImmediate faked) one millisecond at a time so each timer fires in its own turn with its flush; the chunks are byte-identical to the committed artifacts and stable under load (10/10 runs beside the full server suite). The assertions pin the shape: exactly three chunks, the first a script with no template, the other two fragment templates. Co-authored-by: Cursor <cursoragent@cursor.com>
…tier cut)
The server knows at render time which frames-client capabilities a
response or a document needs — it mints each feature — and announces
their names so the client starts the tier's import in parallel with the
content instead of at first use (frames savings pass §2; decision 3's
additive wire):
- sink: `needs(tier)` at the mint sites — a binding-slot position read
(`bind`), a nested region (`regions`), an assets chunk (`assets`), a
traced container in a slot arg (`trace`), a `live` response (`wire`);
`announce()` for the response head.
- stream face: `X-Frame-Tiers` set by `serverComponentResponse` from
what the sync render pass minted (the `ReadableStream` start runs
before the Response is built); a tier minted after the head rides
in-band as `tiers` on the next chunk out (`FrameChunk.tiers`) — the
trace mint precedes the serializer's synchronous emission of the
record's initial node, so it lands on the very `data` chunk whose node
tree needs it.
- document face: `_$HY.r["sc:tiers"]`, re-written cumulative at each
mint (every pre-shell write lands in the shell script; the last wins);
a `modulepreload` per tier whose URL the integration gave
(`frameTransformDirectResult(value, { id, args, tierUrls })`), through
`registerAsset("module")`. A sync `renderToString` document announces
nothing (no shared render slot) — the client detects.
- client: `installServerComponents(host?, { tiers })` takes the loader
map, reads the record and starts each load; `applyFrameResponse` reads
the header before the body and `chunk.tiers` per chunk; a `data` chunk
awaits the tiers it names before decoding. `prepareTier` is idempotent
per name; a name with no loader is resident (every name today). The
install calls the module's `install()` and flushes every live frame.
- the held set is A2's registered set (frames-rulings 3.1 / 3.2): a fresh
mount whose tier is absent — a data occurrence's `bind`, a
region-carrying record's `regions` — waits like a recordless called
occurrence; on the adopt path the frame's hold registers (hydration-done
waits, the replay window stays open), on the stream path the record
stays pending; the readiness check starts the load itself when nothing
announced it (detection is the fallback), and the install's flush
mounts it with the record it was held on.
Measured before written (edited dist copies, scripts/size): frames eager
+473 min / +145 br (≤ +150 budget; the ≈ +100 estimate's ×2.5 pre-estimate
was ≈ +250; the first honest cut measured +745 / +233 and was golfed),
pages +478 min, non-SC scenarios 0; frames server dist +793 min, the web
server entry 0.
Pins: test/server/tier-announce.spec.tsx (both faces announce what was
minted, nothing when nothing is; the in-band form; the trace on its data
chunk; the record cumulative; links only with URLs; sync renders silent),
test/consistency/tier-prepare.spec.tsx (idempotence; the record and
header reads; in-band + the codec await; the 3.1 hold with two frames
and one install — positions untouched, hydration-done waits, both mount;
the stream path's buffer-and-retry). preload-links.spec: the late assets
chunk now carries `tiers: ["assets"]`. Artifacts: 5 of 150 re-recorded
(frame-live-document-* announce `wire`, welcome-status-* `trace`).
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…s ≈ +100 est.), decision 3 sub-item: tier chunk URLs (tierUrls + the client loader map) Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…hanism
The container-trace materializer is the store engine's one edge into a
server-component page (signals `store/*`, the projection/reconcile machinery,
~8 KB brotli) — and the frames client imported it from `solid-js/internal`
and installed it at module load. It is now the frames client's TRACES TIER
(frames savings pass §3 row C3 — S1 re-based onto Phase B's tier mechanism,
not merged as built), loaded through `prepareTier("trace")`:
- `solid-js/internal/container-trace` — a NEW `solid-js` dist entry carrying
`materializeContainerTrace(marker, claiming?)` (S1 commit 1's entry, with
commit 3's `claiming`). Its own entry because the main build is one flat
module: any binding in it that reaches the engine welds the engine to
whoever imports the binding. It reaches `createProjection` through
`@solidjs/signals` and reads the store wrappers' hydration dispatch
(`withStoreHydration`, new, `@internal`), `applyPatches` and
`forwardIteratorReturn` back from `solid-js` by name; the server mirrors
the three as inert stubs. The materializer leaves `solid-js`'s main and
`solid-js/internal` entries.
- `@solidjs/web/frames/trace` — a NEW `@solidjs/web` export path, the tier
module (`frames/src/trace-tier.ts`): solid's entry plus the plugin's client
half (`frame-container-plugin.js`: the shared hook state, the memo, the
marker test, the deep revive walk). Its `install()` — B's `install()`
shape, unchanged — sets the materializer on the plugin's registered state
and the shared host's `revive` (`getFrameHost().revive`, assignable; the
eager client wires no reviver). In the dist the tier's import of the
client entry is externalized to `@solidjs/web/frames` (instance identity).
- The eager client keeps the trigger alone: the loader entry
(`tierLoaders.trace`), the container probe read off the plugin's
registered state (no copy of the plugin rides the eager client), and the
held-set predicate — `needsTrace`: a `{ $tr }` marker anywhere in an
adopt-time record's literal args while the tier is not resident holds the
occurrence (its server interior on screen; the frame's hold registered as a
pending boundary under frames-rulings 3.1, hydration-done waits) and
starts the load. The walk runs only while the tier is absent: once it is
resident a decoded arg may be a live container whose traps throw, and
before it is resident no live container can exist.
- S1 commit 3's two frame-side fixes ported: the `claiming` hint
(`#invokeSlot`'s `adopted` → `#resolveArgs` → `FrameHostOptions.revive
(value, claiming?)` → `reviveContainerTraces` → `materialize`) keys the
materializer's parked backlog on the claim again (rulings 3.6 "Landed":
the port's unconditional park made every fresh mount pay one beat; now a
fresh mount reads the fold of its whole backlog at once); the held-record
mount (`#heldRecords`): an adopt-path occurrence held — on its tier, on
its record's reads — mounts with the record it was held on (what the
server interior was rendered from) and applies a record that replaced it
meanwhile as the args change it is. Generalized: every adopt-path hold
(`bind`, `regions`, `trace`) uses it.
- Not shipped: S1's `FrameHostOptions.prepareData(chunk)` / `prepareArgs`
/ `#argsUnprepared` (B's `prepareTier` + the held set replace them) and
S1's codec-face node scan — B's in-band `tiers` rides the very `data`
chunk that carries the plugin's node, so the transport awaits the tier
before the chunk decodes without a scan (measured +79 B min / +25 B br to
keep; dropped). An un-announced chunk from a producer that predates the
tier decodes the inert marker — a skew one package never ships.
Measured before written (edited dist copies through scripts/size's bundler,
re-attribution §7; then the real build) against Phase B's head e05ba02:
frames eager 43,452 / 13,949 -> 42,968 / 13,866 -484 min / -83 br
page base 146,097 / 45,210 -> 122,028 / 38,598 -24,069 / -6,612
page live 158,060 / 48,873 -> 133,899 / 42,147 -24,161 / -6,726
app hydrating (no stores), + stores, compiled hydrating, CSR: 0 / 0
trace.js (lazy, reported not counted) 25,409 min / 8,170 br
The deletion alone (F.trace's eager half out of the frames client, the
engine + materializer out of the pages) measured -951 / -239 on frames and
-28,763 / -8,016 on page base; the trigger left behind +467 min / +156 br
on frames (loader entry + probe +160 / +45, the held-set predicate +126 /
+67, the claiming thread +6 / -9, the held-record mount +175 / +53) — about
3x the plan's ~150-min estimate, which counted the loader alone. Against
`next` @ 9d89df7: frames -446 min / +79 br (Phase A's and B's bytes), page
base -6,284 br, page live -6,448 br. `@solidjs/web` server floor +4 B min
(`materialize`'s second parameter in the codec plugin the SSR runtime
bundles; brotli -9). Caps lowered (the ratchet): page base 44.89 -> 38.61 KB,
page live 48.60 -> 42.16 KB; frames eager's cap stays 13.79 KB (76 B over by
the earlier steps' bytes; its recorded minified lowered to 42,968). No cap
raised.
Pins — S1's seven ported through the general seam (the hold re-armed per
test by dropping the tier's load through `tierLoads`, the runtime's test
seam): `frames-container-lazy-codec` (announced form: the `data` chunk's
`tiers` is the trigger), `frames-container-lazy-document` (the production
loader), `hydration/welcome-status-lazy` (the artifact's `sc:tiers` record
starts the import at install), `container-trace-hold-{id-determinism,
interruption, record-retention, snapshot}`; `container-trace-hold-hydration-
end` RE-PINNED under 3.1 — hydration waits for the load, the mount claims
before done (S1 asserted the opposite order). New `consistency/tier-trace-
hold.spec` (§1's pin: the un-announced hold with the held-record mount, the
announced start-at-install, the codec wait). C3 (b) asserts the hold and
the 3.1 order (it was never `.fails` on `next`). The id-drift `.fails` pin
stays red under rulings 3.4 (unfixed; a keyed sibling after the frame).
The resident cells (`container-args`, C11, C19, the harness) warm the tier
with `prepareTier("trace")`. Solid's `container-trace.spec` pins the keyed
park. Artifacts: 0 of 150 changed (no wire change). Harness 500 cases,
seeds 3289 / 91501: SC arm 0 / 0; generic arm with CONSISTENCY_IGNORE=
C1,C9,C19,E 0 / 0.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ured −83 / −6,612 / −6,726 br; glue +467 min vs the ≈ 150 estimate), §5 S1 re-based; rulings 3.6 the park keyed on the claim again, 3.4 the id-drift pin on the tree Co-authored-by: Claude via Cursor <noreply@cursor.com>
Nested server-content regions — server content passed as a PROP to a client
fill (`{$frame}` in the occurrence's record), rendered as a `<solid-frame>`
region element with a frame bound over it that the host routes the region's
chunks to — are now the frames client's REGIONS TIER (frames savings pass §3
row C4), loaded through `prepareTier("regions")`:
- `@solidjs/web/frames/regions` — a NEW `@solidjs/web` export path, the tier
module (`frames/src/regions-tier.ts`): the per-frame region cache (a
`WeakMap` the tier keeps, no frame field), element discovery in an adopted
interior (`collectRegionElements`, the range walk), the `{$frame}` arm of
arg resolution (`resolve`: mint / reuse / rename), the bind of a frame over
each region element (`bind`: `createFrame` from the eager entry, the parent
linked as `options.parent`), disposal (`unmount`), and the staged preview's
two region reads (`changed`, `frames`). The module's exports ARE its
appliers: `prepareTier` records the installed module in a dispatch table
(`tierModules`) and the frame calls through it; no `install()` — nothing of
this tier lives outside the runtime's dispatch. In the dist the tier's
import of the client entry is externalized to `@solidjs/web/frames`
(instance identity: `createFrame` must be the runtime the app's host
routes to); frame-client's pure DOM helpers (`eachInRange`,
`makeFrameElement`, `isFrameElement`, exported `@internal`) bundle as the
chunk's own copy.
- The eager client keeps: `needsRegions` — one test on the host's
`record.regions` note (no arg walk) at TWO sites: a fresh mount waits in
the held set (adopt path: the hold registers under frames-rulings 3.1, the
server interior stays on screen), and a MOUNTED occurrence's new record
naming a region stays pending in the store (the live binding keeps the args
it shows) until the install's flush — either way the check starts the load
(the un-announced fallback); the document face's `sc:region:` drain arm in
client.ts (an occluded region's html lands in the store regardless; the
frame the tier binds on install seeds from it); the loader entry; an
`options` getter on the frame (`@internal`, not on `Frame`) the tier reads
the parent's host / claim scope / owner scope from. The slot-resolution
thread-up (`#resolveSlot` / `#resolveSlotRecord` / `#removeSlotRecord`)
walks a `parent` link (`#outer`) by private access across instances —
the three per-region option closures are gone.
- The rename machinery (`renameRegion`, the rename arm) is MOVED into the
tier, not deleted — flagged: it is live, not dead. A single-flight response
renders a shown boundary's regions under the call's address while a direct
response and the document render them under the function id, so a flight
refresh renames every region of the boundary (`preview`'s region check
anticipates exactly this; `lifecycle-matrix/call-driven-args` › regions and
`frames-optimistic-hold` pin the rebind). It deletes with S7's store-boundary
normalization (principles §5.3), not here. Eager cost of keeping it: 0.
Already gone on the base: `isFrameRef` (the host's inline `$frame` note is
the detection), `tableFor`'s prefix walk and `#reconcileRegions` (A1b).
- `InstallOptions.tiers` types a tier's module as the new exported
`TierModule` (`{ install?(): void; [applier: string]: unknown }`): the old
`{ install?(): void }` is a TypeScript weak type a module whose exports are
its appliers cannot satisfy.
Measured before written (edited dist copies through scripts/size's bundler,
re-attribution §7), then the real build, against C3's head 89954fa:
frames eager −1,078 min / −237 br (the deletion alone −1,596 / −423; the glue
+518 / +186 — over the ≈ 150-min line, reported: the installed-module table,
the loader entry, `needsRegions` at both sites, the tier calls at bind /
resolve / unmount / preview, the `options` getter, the `parent` thread-up;
TypeScript's TS18030 — no private name in an optional chain — forced the
longer `#outer && #outer.#x()` form, +33 min over the edited copy's −250 br);
page base −1,083 / −281; page live −1,083 / −261; the four non-SC scenarios
0. Chunk `regions.js` 1,872 min / 805 br (reported, not counted). Vs `next`
9d89df7: −1,524 / −158, −24,812 / −6,565, −24,904 / −6,709. Caps lowered
(the ratchet, measured + 10 B at the 0.01 KB step): frames eager 13.79 →
13.64 KB (under the cap for the first time since Phase A), page base 38.61 →
38.33 KB, page live 42.16 → 41.90 KB; no cap raised.
Pins: new `consistency/tier-regions-hold.spec` (4 — un-announced `{$frame}`
in an adopted record → held under 3.1, interior and region content on screen,
mounts with the held record, the adopted region element discovered and bound,
no TypeError, hydration-done after; announced → the import starts at install;
an occluded `sc:region:` record drained before the tier seeds the bound
frame; stream face: a mounted occurrence's region-naming record waits and
applies at the install into the live binding). `tier-prepare`'s regions test
releases the real module. Resident cells warm with `prepareTier("regions")`:
`lifecycle-matrix/call-driven-args`, `frames-hn-client`,
`frames-occlusion-client`, `frames-optimistic-hold`,
`frames-used-region-client`; `test/server/frame-hn` (the runtime without the
entry) registers the loader itself. Harness 500 × seeds 3289 / 91501: SC arm
0 / 0; generic arm (`C1,C9,C19,E` ignored) 0 / 0. Wire unchanged; 0 of 150
artifacts changed. Suites: web client (128 files), server (159), hydrate +
consistency (85), solid (42) green; `test-types` green on web and solid.
Co-authored-by: Cursor <cursoragent@cursor.com>
…ured −237 / −281 / −261 br; deletion −423, glue +186 br vs the ≈ 150-min line; chunk 1,872 min / 805 br); principles §4 row 19 re-dispositioned — the region rename is live (the flight path renames under the call's address), moved into the regions tier, deletes with S7's normalization Co-authored-by: Cursor <cursoragent@cursor.com>
…nsureStylesheet, applyInlineStyles) The SC layer audit's one 0-coverage gap (§2.4): no client test reached frame-client's stylesheet gate or inline-style application. Pinned before the assets tier moves them (frames savings pass §3 row C5), as the code behaves today: - a stylesheet named by a segment's assets record is inserted once content and reveal are in the store (the placeholder may still be missing) and the segment reveals when the link settles — load or error; a link already in the document counts as settled; one link per href; one waiter per frame on a pending link however many flushes re-check; a disposed frame's settle is inert - the gate is the assets record, not the reveal chunk's waitForStyles flag - the link is requested at the reveal record, not at the assets record - inline styles never gate; they land in the head in entry order, deduped by data-asset id (a document-emitted element is adopted), before the segment's content, after its stylesheet gate
…/ 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`.
A slot record's `{$ref}` to a key its response has not delivered yet is
answered by that response's `createJSONDataTable` with a PENDING READ the
table owns: one promise per undelivered key, marked `s = 0` (so the host
tells it from a delivered value that is itself a promise — an async arg
passed whole), carrying `c`, the callbacks the table runs in the `apply` /
`close` that settles it. The key's `data` chunk settles it through the
table's own `apply`, stamped `s = 1` / `v` (the hydration serializer's
marks, which a fill's prop read adopts synchronously); the response's end
closes the table and every read it never answered rejects, stamped
`s = 2` (L1 — a value that never comes is an error where it is read, not
a silence; the error names the key). A version bump drops the superseded
table with its reads unanswered, as before.
The frames host keeps the COUNT and nothing else of the machinery:
`settleArgs` counts a record's pending reads (`record.pending` — a fresh
mount waits for its record, the covering `<Loading>` pends on the landing
alone; a mounted occurrence takes the read as it is) and pushes one
`settle` on each read's `c`, which re-applies the record to the frames
when the last settles and the record is still the store's (a bump, a
re-sent record or a staged refetch's preview make it not). `pendingRef`,
`settleWait`, the per-store `waits` (and `write`'s reset of them), the
data arm's wait lookup and the end arm's rejection loop are gone; the
end arm tells the integration the response ended (`closeData`), and the
shared host closes that response's table — never minted for a response
that carried no data, never a superseded version's. The callbacks, not
the promise's `then`: a delivered promise value is ADOPTED by the read,
so its `then` would fire at the inner value while the mount must run at
the delivery (the DR-2 value-tier and container pins). `instanceof
Promise` before the mark: a decoded value may be a live container whose
property reads throw not-ready.
The transport awaits `prepareData` before a `slot` chunk whose args carry
a `{$ref}`, as it does before a `data` chunk: the pending read is the
table's, so the table must exist when the record resolves. The producer
emits the data ahead of the record that names it, so on its wire the
await is already answered.
Pin added: `consistency/sref-table-pending-read` — the table's read
lifecycle (mark, stamp, callbacks at delivery even for a promise-valued
key, close with and without the response's error, idempotence) and L1
through the production shared host (a never-delivered key rejects at
`complete`; the fill's read throws to the nearest `<Errored>`, naming the
key; the control delivers and nothing rejects).
frames eager 34,272 / 11,367 → 33,767 / 11,193 (−505 min / −174 br);
page base −505 / −200; page live −505 / −157; the lazy decode chunk
+420 / +161; non-SC scenarios 0 / 0.
Co-authored-by: Cursor <cursoragent@cursor.com>
…/ live 39.76 KB); residue doc §3.2 as built + §7d; plan §3 row R4 Caps lowered to measured + 10 B at the 0.01 KB step: frames eager 11.38 → 11.21 KB (33,767 min / 11,193 br), page base 36.29 → 36.09 (112,938 / 36,071), page live 39.92 → 39.76 (124,810 / 39,747); no cap raised. The lazy `decode.js` chunk of the page scenarios grows +420 / +161 (22,990 / 6,076 → 23,410 / 6,237) — reported, not counted. Residue doc: status line; §2 row 12 / §4 row 7 / §5 row 5 marked landed; §3.2 "As built" (the measured table — ceiling −879 / −280, the carrier +394 / +118 over it, the transport's await 61 / 7, the fan-out fold, built −505 / −174; what the table owns, what frames kept, the two things the build found — the settle as the table's callback at the delivery and the `instanceof` guard — the soundness item the design did not name, why the glue is +110 and not +40); §7d the landed table, the pins, the public surface, the running number. Plan §3 row R4. Co-authored-by: Cursor <cursoragent@cursor.com>
…ume in a lazy chunk; `live` coverage closure
The live wire moves out of the eager frames client into the sixth lazy
tier, `@solidjs/web/frames/wire` (frames/src/wire-tier.ts → wire.js,
1,922 min / 934 br): `connect` — the per-address connection a live()
loop's response runs under (the open-frame count its end is judged by:
a death with frames open vs a completion; the join of a second loop onto
an address already live; `connection.ended` / `cancel`; the SSE reader
selection, proxied so a drained body applies nothing further), `cancel`
(supersession from `bump`), `have` / `haveOf` (the have-list ledger per
mount, RFC 11 §9.5, fed by the client's `#recordHave` at its three
sites) with `encodeHaveList` and the FRAME_HAVE_* literals bundled, and
`resume` (the ordinal + the have-list header). The eager client keeps:
`applyFrames` reading `options[LIVE_WIRE]` as its reader; `handle`'s
live arm awaiting `prepareTier("wire")` before the body is read; `onLive`
→ `prepareTier("wire")`; `resume` and `bump`'s cancel delegating through
`tierLoads.wire?.r`.
Preload-at-call: `live()`'s makeIterable fires the new optional
`responseHandler.onLive()` before the first fetch, so the import races
only the request; a non-live call never loads the tier. Accepted degraded
case: content applied before the chunk was resident kept no ledger, and
`resume` answers nothing while the tier is absent — that first connect
sends neither X-Frame-Have nor Last-Event-ID and gets a full snapshot
(the server never read the ordinal for a frame render); pinned.
Public surface: `Frame.have?()` removed from the Frame interface (the
ledger is the tier's `haveOf(frame)`); `encodeHaveList` moved from
frame-transport to the tier (@internal); `@solidjs/web/frames/wire`
export path added; `responseHandler.onLive?()` and
`ServerComponentHandler.onLive()` added. `ServerFunctionsClientConfig`
gains no key.
Pins: consistency/tier-wire-preload.spec (3) — the hook starts the import
at the call before any fetch, the arm awaits residency, X-Frame-Tiers:
wire joins the same load, a non-live call never loads the tier,
reconnect through the tier with Last-Event-ID + the have-list, the
degraded first connect. frames-live-resume re-pointed to the tier's
haveOf / encodeHaveList; frames-live / frames-live-showing warm the tier.
The coverage closure: server-functions-live-loop.spec (19, client suite)
drives live()'s loop over hand-framed chunk / SSE bodies — first-connect
failure, the fail-fast 4xx set and 408 / 425 / 429 / Retry-After, the
cursor as Last-Event-ID, the online wake, return() mid-connect /
mid-backoff / mid-seed, invoke's signal mid-stream / mid-backoff, no
global EventTarget, live(GET(fn)), the handler's local answers — `live`
per-function branch coverage (audit §2.4 method, client + hydrate) 26/59
sites (91/129 paths) on the base → 57/60 (129/132).
Size (scripts/size, this head vs the base 55eb4c3): frames eager
33,767 / 11,193 → 32,802 / 10,888 (−965 min / −305 br; cap 11.21 → 10.90
KB); page base 112,938 / 36,071 → 112,003 / 35,722 (−349 br; cap 36.09 →
35.74); page live 124,810 / 39,747 → 123,903 / 39,412 (−335 br eager; cap
39.76 → 39.43; the page loads wire.js at its first live() call — total
transfer 40,345 br, +598 against the base); non-SC scenarios 0 / 0; no
cap raised. Measured before writing on edited dist copies through the
harness's bundler: ceiling −1,432 / −472, lean −965 / −310, the carrier
≈ +467 / +162.
Suites: web client 130 / 1,218, hydrate + consistency 89 / 452, server
159 / 1,511, solid 42 / 833, types + test-types clean; harness 500 ×
{3289, 91501} SC arm 0, generic arm (CONSISTENCY_IGNORE=C1,C9,C19,E) 0.
Docs: savings plan §1 wire row built, §3 row C2 landed, §4 chunk table;
residue doc status + §7e.
Co-authored-by: Cursor <cursoragent@cursor.com>
This was referenced Oct 7, 2026
Ruling (2026-10-07): `dynamic` stays the combo (component or tag name);
`dynamicComponent` is the component-only sibling; the server-component
docs mount with `dynamicComponent(() => getStory())`. Option (a) of
documentation/plans/frames-b3-sync.md §5.
`dynamic` is factored into a shared core and the tag arm: `dynamicCore(
source, options, tagArm?)` is the whole implementation — the hoisted lazy
factory memo (FLIGHT box, never re-runs for a reset), the per-instance
value memo (async-memo adoption under hydration, the `latest` token, the
`untrack(cached)` warm-up), the three-memo owner shape matching
index.server.ts, the render memo's per-site `createSignal(address, {
ownedWrite })` + `sites` + `untrack(() => binding.component(props,
address))`, kept-resolution delivery (`resolveBinding` / `sameInstance` /
`deliveredAddress`), `static: true`. The only thing the arm decides is
what a string value renders as: `dynamic` passes `staticElement`,
`dynamicComponent` passes nothing. The core never names the element
runtime, so a bundle whose only consumer is `dynamicComponent` sheds
`staticElement`, `createElement`, `spread`, the prop-collection helpers,
the SVG/MathML tables and `getNextElement`.
Server twin in index.server.ts, same factoring (the arm is one
`ssrElement()`), identical hydration-id consumption: the parity spec
renders one page — a client component and a non-live server component
reference — through both entry points and asserts the documents are
byte-identical (same `_hk` keys, same frame markup, same `_fr` record),
inline and streamed; four hydration arms replay the `dynamicComponent`
document through each client entry point (adopted, no request, same
nodes, both slots live).
Type: `dynamicComponent<C extends Component<any>>(source: () => C |
Promise<C> | AsyncIterable<C> | null | undefined | false, options?)` —
no `string` in the union. A server-component reference has no type-level
brand: it is typed as the server function's declared answer, a
`Component<P>` (`LiveSource<Component<P>>` for `live`, an intersection
that is still one), so `Component<any>` covers it. The type test pins a
tag-name source as a compile error here and fine on `dynamic`.
Pins: A7 reset/re-ask (9 arms), C16, C17 parametrized over both entry
points; every other `dynamic` pin unchanged (shared code).
Size (built, vs base d9395e3): page base −7,417 min / −2,152 br →
33,570; page live −7,418 / −2,171 → 37,241; every other scenario 0 / 0.
Floors ratcheted to 33.58 / 37.26 KB (measured + 10 B at the 0.01 KB
step). The edited-dist model said −2,158 / −2,237 before the source was
written.
Fixtures (sc-base-app, sc-live-app) and the server-component docs' mount
examples switched to `dynamicComponent`; `dynamic`'s docs carry a
one-line pointer.
Public API: new export `dynamicComponent` on `@solidjs/web` (client and
server entries). `dynamic` unchanged.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Two scenarios on the gate: the base and live server-component pages as compiled JSX (fixtures/compiled/sc-base.jsx, sc-live.jsx, the shared sc-shell.jsx and a lazy sc-comments.jsx), compiled hydratable by the measured checkout's @solidjs/compiler like the compiled app scenarios. The hand-written sc-base-app.js / sc-live-app.js keep the runtime alive as values and never compile a template, so the attribute runtime a compiled template imports (className, style, setAttribute, addEvent, delegateEvents, spread/assign) is reached on those pages only through the bind tier's `assign`, and a cut that drops it there drops nothing from a real page. The compiled pages mount the same server component through dynamicComponent() over the same server-function reference (live/GET, action, isPending/latest on the live one) inside a shell with the templates a real page has: links with href / dynamic class / dynamic style / a handler passed through as a prop, one element spread, For, Show, Errored/Loading, a lazy child. The hand-written pages stay (the frames-only floor). Caps inline at local measured + 10 B at the 0.01 KB step (35.93 KB / 112,967 and 41.42 KB / 126,294), to be confirmed against CI. bundle.mjs: the eager graph is the entry plus every chunk it imports statically. With isPending on the page, the frames tiers and the lazy comments route all reaching the core, Rolldown hoists the shared runtime (70,576 B minified) into a web.js the compiled live entry imports at its top; it ships eagerly, so it is counted (each file brotli'd on its own) and named in the output. No other scenario splits; every existing number is unchanged. Tooling only; nothing under packages/*/src. No changeset.
Maintainer's ruling (2026-10-07): most server-component apps do not have
client element spreads, so the compiled baseline must not carry one. The
shell's `<input {...props}>` is gone; the search input binds `value` and
`onInput` directly. The ordinary dynamic attribute forms stay (dynamic
class/style values, href from a prop, the handler passed through as a
prop, For/Show, Errored/Loading, the lazy route).
`spread` is absent from both pages' rendered web.js. Still present:
`assign`/`assignProp` (through the bind tier's `assign` edge, exactly as on
the hand-written pages) and 1,071 B of signals' store/utils.js — the
leaf/merge key walkers `readShallow` reaches through `sourceKeys(value,
SOURCE_PROXY)`; `readShallow` is what the compiler wraps dynamic class/style
values in. `merge`/`omit` themselves are absent.
Re-measured: base 108,634 / 34,869 (was 112,967 / 35,914), live 121,963 /
40,266 (was 126,294 / 41,406; still two eager chunks). Caps re-set at local
measured + 10 B at the 0.01 KB step: 34.88 KB / 108,634 and 40.28 KB /
121,963 — CI to confirm, lower only. On `next` @ 11e9fb6 the stack is
−11,031 br (base) / −9,455 br (live) on these pages; `dynamicComponent` vs
`dynamic` on the spread-free compiled base is −1,681 br / −5,812 min.
Tooling only; no changeset.
Conflicts: - packages/web/frames/src/client.ts: the slot-arg memo keeps both sides — this branch's equals: sameArg and #3874's dev-only _supersedes: IS_DEV ? { transparent, equals: sameArg, _supersedes: true } : { transparent, equals: sameArg }. - scripts/size/scenarios.js, floor-caps.json: next's caps and ledger taken; this branch's caps are re-derived from fresh measurements in a follow-up commit. Co-authored-by: Cursor <cursoragent@cursor.com>
CI Size run 37666601776 on the merge measured the three hydrating scenarios over next's caps (+168 / +174 / +174 B minified: A2b's sharedConfig.hydrateWindow and A5′'s _$HY.fa in solid-js). Caps set at CI brotli + 10 B rounded up to 0.01 KB, recorded minified from CI: - app: hydrating (no stores) 17.73 -> 17.85 KB (floor; 52,841 B min) - app: hydrating + every store primitive family 29.02 -> 29.09 KB (92,114 B) - app: compiled hydrating 31.06 -> 31.09 KB (99,477 B) Under the PR body's Size-Exception. Co-authored-by: Cursor <cursoragent@cursor.com>
#3849 landed on next as the squash 3c1d512 (same tree as 9914189); merging its head first brings next @ 3c7631a in with #3849's recorded resolutions, so the following merge of next is a no-op on content. Conflicts: - packages/web/frames/src/client.ts: the slot-arg memo comment keeps this branch's container-probe text and #3874's SUPERSEDES paragraph; the options are next's (IS_DEV ? { transparent, equals: sameArg, _supersedes: true } : { transparent, equals: sameArg }). - scripts/size/scenarios.js, floor-caps.json: next's ledger notes then this branch's; caps provisionally this branch's, re-derived from measurement in a follow-up commit. Co-authored-by: Cursor <cursoragent@cursor.com>
next's tree equals 9914189 (#3849's head, squashed as 3c1d512), which the previous merge brought in; every conflict and auto-merge resolves to this branch's side. Notably server-functions/src/client.ts keeps this branch's provideRPC without #3849's createServerReference (residue step 1 replaced the RPC-seam re-ask with ctx.retry). Co-authored-by: Cursor <cursoragent@cursor.com>
Size (brotli, eager entry chunk)
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 |
CI Size run 37671002618 on the merge, caps at CI brotli + 10 B rounded up to 0.01 KB, recorded minified from CI. Against next's caps every one is lower: - frames: eager client 14.02 -> 11.13 KB (33,418 B min) - page: base SC (floor) 45.14 -> 33.92 KB (105,252 B) - page: live SC (floor) 48.91 -> 37.53 KB (117,301 B) The two compiled-page scenarios (new here, not on next) move from this branch's local pre-merge caps to CI's bytes on the merge, which carry next's bytes since the branch's base: - page: compiled base SC 34.88 -> 35.09 KB (109,300 B) - page: compiled live SC 40.28 -> 40.61 KB (122,779 B) Co-authored-by: Cursor <cursoragent@cursor.com>
Coverage Report for CI Build 37671603021Coverage increased (+0.06%) to 76.43%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
ryansolid
marked this pull request as ready for review
October 7, 2026 19:34
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The frames savings pass, Phase B through C2 plus B.3's sync form, in one PR (
documentation/plans/frames-savings-pass.md§2–§3;frames-residue-pass.md;frames-b3-sync.md§5 option (a)): the server-announced tier mechanism, the five tiers cut on it — traces, regions, assets, binding slots, the live wire — the four residue cuts between them, anddynamicComponent, the component-only sibling ofdynamicthat lets a server-component page drop the element runtime. Collapsed per the maintainer ("merging is a bottleneck"): this branch was fast-forwarded to the top of a linear stack of ten drafts, their own commits only, each based on the previous — #3860 as it was (Phase B + C3 + C4 + C5, itself the fold of #3854 / #3856 / #3858 / #3857), then #3861 (the trace update-site wait — its one commit rides the chain as C6's cherry-pick, same patch-id), #3862 (C6, the binding-slot tier + option (a)), #3863 (residue 1: the D list,FRAME_HAVE_*off the client entry, the lean re-ask), #3864 (residue 2: thepreviewpull form measured and stopped — pin + docs only), #3866 (residue 3: every fill throughinsert,createFramemarker-less), #3867 (residue 4: the pending{$ref}read owned by the decode table), #3868 (C2, the live wire tier), #3870 (dynamicComponent— B.3 option (a):dynamicstays the combo, the sibling has no tag arm, the server-component docs and the size fixtures mount through it), #3875 (twoscripts/sizescenarios: the base and live server-component pages as compiled JSX through the native compiler — the number a real server-component page moves by; andbundle.mjscounting the entry's static-import closure as eager). Every step measured its cut on an edited dist copy before writing it; each folded body carries its own table and its step's decisions; this body carries the union. Cumulative vsnext(both built fresh with the native compiler, measured with this head'sscripts/sizeharness; the page numbers are #3875's measurement vsnext@11e9fb653, the frames eager number #3870's vsnext@53ef0e69e): frames eager 32,802 min / 10,888 br (−11,230 / −3,115); page base 104,586 / 33,570 (−41,837 / −11,569 br, −25.6%); page live 116,485 / 37,241 (−42,051 / −11,647, −23.8%); compiled page base 108,634 / 34,869 (−40,186 / −11,031); compiled page live 121,963 / 40,266 (−39,270 / −9,455). The hydrating / +stores / compiled-app / CSR scenarios are 0 / 0 vs #3849's head (they carry Phase A's +110 / +142 / +52 br vsnext, its Size-Exception). Six lazy chunks ride beside the eager one (reported, not counted — below). Frames eager absolute: 10,888 br — 888 B above the ≤ 10 KB target; what remains of the incremental path is C1 (holes, ≈ −440) and the claims chunk (≈ −270), both rulings (Not in this PR).Base and CI. Base is #3849 (
wip/frames-pass-integration-2@0aab93230, Phase A), still open — this PR stays a draft until it squash-merges.main-ci.ymltriggers onpull_request: branches: ['*']andsize.ymlon[main, next]; a*pattern does not match/, so a PR againstwip/frames-pass-integration-2runs neither (the folded drafts had only the Socket checks for the same reason). After #3849 lands:git rebase --onto next 0aab93230 wip/frames-tiers-integration(the chain's own commits are0aab93230..3b70dd4c5), retarget tonext, undraft; if CI's brotli bytes then differ from the local ones, the ratcheted caps (the three tier caps and #3870's two page floors) and #3875's two inline compiled-page caps follow CI's per the ratchet rule. The equivalent ran locally on this head (Pins › Suites, Size).Update 2026-10-07 — on
next, by merge (no rebase, per the maintainer). #3849 squash-merged as3c1d51267; this PR is retargeted tonext(still a draft). The branch merges #3849's final head991418926(same tree as3c1d51267) and thennext, so it isnextplus this PR's own changes: the diff againstnextis the same 130 files as0aab93230..3b70dd4c5, patch-identical outsidescripts/sizebut for context lines. Resolutions: the frames client's slot-arg memo keeps #3874's dev-only_supersedes(IS_DEV ? { transparent, equals: sameArg, _supersedes: true } : { transparent, equals: sameArg }) and both comment paragraphs;server-functions/src/client.tskeeps this PR'sprovideRPCwithout #3849'screateServerReference(git's auto-merge re-added it — residue 1 removes it, Public API › Removed); the size ledger keepsnext's notes, then this branch's, and every ratcheted cap is re-derived from CI on the merge (Size).server.ts: noattrNamecall survives; this PR's spread additions ride #3871'sisAttrNameguard. Cumulative vsnext@3c1d51267, CI: frames eager 33,418 / 11,112 (−10,178 / −2,900); page base 105,252 / 33,908 (−41,032 / −11,383 br, −25.1%); page live 117,301 / 37,512 (−41,096 / −11,450, −23.4%); compiled pages 109,300 / 35,079 and 122,779 / 40,593 (new scenarios;nextcannot bundle their fixtures). The Summary's numbers above are the pre-merge measurement;next's bytes since (#3846's ChunkReader, #3865, #3874) add +616 / +666 / +816 B minified to the frames / base / live rows.Public API changes
All
@experimentalunless marked; deduped from the ten bodies (#3860, #3861, #3862, #3863, #3864, #3866, #3867, #3868, #3870, #3875). #3861, #3864 and #3875 changed no surface.New export —
@solidjs/web(not@experimental; the one non-frames surface item)dynamicComponenton the client entry (src/index.ts) and the server entry (src/index.server.ts), intypes/index.d.tswith JSDoc stating the cost model. Signature:dynamicComponent<C extends Component<any>>(source: () => C | Promise<C> | AsyncIterable<C> | null | undefined | false, options?: DynamicOptions): Component<ComponentProps<C>>— nostringin the union;DynamicOptions(static,deferStream) shared withdynamic. The component-only sibling ofdynamic:dynamicmust be able to render a tag name, so onedynamicanywhere on a page retains the element runtime (staticElement,createElement,spread, the prop-collection helpers, the SVG/MathML tables,getNextElement) for everyone;dynamicComponenthas no tag arm and never references it. Implementation:dynamicis factored into a shareddynamicCore(source, options, tagArm?)and the tag arm — the whole hoisted/per-instance/render memo shape (A7's boxedFLIGHT, [2.0 rc.9] Async dynamic() inside Loading hydrates an absent fallback and replaces SSR DOM #3666's async-memo adoption, C16/C17's kept-resolution delivery) is shared; the arm decides only what a string renders as (dynamic:staticElement;dynamicComponent: nothing, like any other non-component value — the type is the guard). The server twin is factored the same way (the arm is onessrElement()), so hydration-id consumption is identical across the four combinations. A server-component reference is typed as the server function's declared answer, aComponent<P>(LiveSource<Component<P>>for alivedeclaration), soComponent<any>covers it without a brand. (web: dynamicComponent — component-only sibling of dynamic #3870)dynamic: unchanged contract, signature, behaviour. Its JSDoc gains the cost-model note and a pointer todynamicComponent. No new diagnostic codes — the dev-only static-source guard still readsdynamic(): a static source must resolve synchronously…for both entry points (shared code). (web: dynamicComponent — component-only sibling of dynamic #3870)documentation/solid-2.0/11-server-components.md(the mount, the "entire client surface" sentences),server-components/server-components.md,server-components-principles.md,solid-2.0/10-server-functions.md(theFeedlive mount),03-control-flow.md(one note line underdynamic);scripts/size/sc-base-app.js/sc-live-app.jsmount through it. Not touched:examples/{hackernews,notes,room}still mount withdynamic(working code; a follow-up if the examples should be on the documented mount);MIGRATION.md; the scenario names (… + dynamic + …,floor-caps.jsonkeys). Changeset.changeset/web-dynamic-component.md(@solidjs/webpatch). (web: dynamicComponent — component-only sibling of dynamic #3870)Removed
Frame.have?(): Record<string, string> | undefinedfrom the publicFrameinterface — the have-list ledger is the wire tier'shaveOf(frame)(@internal). An approved-class removal (the maintainer's C2 adjustment 3). (frames: C2 — live wire tier #3868)FRAME_HAVE_HEADER/FRAME_HAVE_BUDGETfrom the@solidjs/web/framesclient entry — both stay on@solidjs/web/frames/server, where the header is read. No consumer in the repo or in@solidjs/router's dist imported them from the client entry. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)createServerReference(id)from the client half of the server-function RPC slot (getServerFunctionRPC(),@internal; A7 had added it). The router readsGET/decodeResponseoff the slot; nothing read it. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)materializeContainerTracefromsolid-js's main entry (it was@internal), fromsolid-js/internal, and the server entry's inert stub with it — the new subpath carries it (internal-surface.specpins neither eager entry declares it). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)encodeHaveListmoved out offrame-transport.tsinto the wire tier (@internal;decodeHaveListand the constants stay). (frames: C2 — live wire tier #3868)FrameHostOptions.prepareData(chunk)/prepareArgs/FrameHost.prepareArgs/#argsUnprepared— B'sprepareTier+ A2's held set replace them. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)New export paths
@solidjs/web/frames/trace— the traces tier:install(): void. Distframes/dist/trace.js, typestypes/frames/trace-tier.d.ts. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)@solidjs/web/frames/regions— the regions tier:resolve,bind,unmount,changed,frames(the appliers; frame parameter typedany); noinstall.bind(frame, occurrence)lost itsstartre-scan parameter at residue 3 (the frame never reads a fill's output back). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: residue 3 — every fill through insert; createFrame marker-less #3866)@solidjs/web/frames/assets— the assets tier:gate(entries, frame): boolean,apply(record): void, and the entry typesStylesheetEntry,PreloadEntry,InlineStyleEntry,AssetsRecord; noinstall. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)@solidjs/web/frames/bind— the binding-slot tier:text,positions,sync,rebinder,unmount,owned,apply,bind(the appliers; noinstall). Distbind.js+bind.dev.jsunder thedevelopmentcondition (the dev findings ride the dev chunk only), typestypes/frames/bind-tier.d.ts. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)@solidjs/web/frames/wire— the live wire tier:connect,cancel,have/haveOf,encodeHaveList,resume(every export@internal). Distframes/dist/wire.js, typestypes/frames/wire-tier.d.ts. (frames: C2 — live wire tier #3868)solid-js/internal/container-trace(thesolid-js/internalnamespace — not public API):materializeContainerTrace(marker, claiming?). Its own rollup entry,dist/container-trace.js. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)New
@solidjs/web/frames(client entry)installServerComponents(host?, options?)— new second parameterInstallOptions { tiers?: Record<string, () => Promise<TierModule>> }(new exported interface): the client's tier → loader map. The built-in table carriestrace,regions,assets,bind,wire; an entry here replaces the built-in loader. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862, frames: C2 — live wire tier #3868)TierModule— new exported type:{ install?(): void; [applier: string]: unknown }(C4's index-signature form, taken over C5's{ install?(): void } & objectat the B/C3–C5 integration — what lets a user-supplied loader whose module has noinstalltypecheck). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)FrameChunk.tiers?: string[]on every member (TierAnnouncement, new exported interface). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)FrameHostOptions.revive(value, claiming?)/FrameHost.revive(value, claiming?)— second parameter;FrameHost.reviveis documented as assignable after creation (the traces tier'sinstallsets it on the shared host).getFrameHost()no longer wiresreviveitself. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)FrameHostOptions.closeData?(frameId: string, version: number, error?: unknown): void— the host calls it after a response's end chunk (complete, orerrorwith no key) has applied, so the integration closes that response's table;erroris the response's:errorrecord when it ended by one. The shared host wires it totables.get(id)?.get(version)?.close(error). (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)FrameOptions.hold's doc names the tier wait as one of the reasons it fires; no signature change. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)isAsyncValuere-exported from the client entry (@internal) — the bind chunk's entry edge (the tier's import of the entry is what keeps the chunk attached to the page's graph; without it Rolldown splits the shared runtime out of the live page's entry). (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)prepareTier(name)is exported from the internal runtime module (frames/src/frame-client.ts) for the client and its specs, not from the public entry — 9 B br; five tiers now exist for an integration to warm. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)@solidjs/web/frames/serverFRAME_TIERS_HEADER("X-Frame-Tiers") — new export besideFRAME_STREAM_HEADER. Not re-exported from the client entry (its one use inlines). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)frameTransformDirectResult(value, { id, args, tierUrls? })— new optionaltierUrls: Record<tier, url>; given, the document emits a<link rel="modulepreload">per announced tier through the render's asset registry; absent, the record alone announces. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)createFrameSink's returned sink (@internal) gainsneeds(tier)andannounce(). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)@solidjs/web/server-functions(client config)ServerFunctionsClientConfig.responseHandler.onLive?(): void— optional; fired bylive()at the call, before its first fetch, per call (the preload-at-call hook; the maintainer's C2 adjustment 2 — no newServerFunctionsClientConfigkey). (frames: C2 — live wire tier #3868)ServerComponentHandler.onLive(): void— the frames handler's implementation (prepareTier("wire")). (frames: C2 — live wire tier #3868)responseHandler.handle'sctxgainsretry(): Promise<unknown>; theresponseHandlertype now declares the existingintercept(info)seam, whoseinfocarriesretry()too.createServerComponentHandler's returnedhandle/interceptsignatures carryretry?().ServerComponentHandlerOptionsunchanged. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)@solidjs/web/serializationJSONDataTable.close(error?: unknown): void(integration-facing type): every pending read of a key the table never received rejects — witherror, else anErrornaming the key. Idempotent. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)solid-js@internalruntime exports:withStoreHydration(new),applyPatches,forwardIteratorReturn(the server entry mirrors them as inert stubs); stripped from the declarations; the subpath types them. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)Moved
@internals (module exports, never on a public entry)reviveContainerTraces(value, claiming?),setContainerTraceMaterializer(fn(marker, claiming?))(both withclaimingthreaded),isMaterializedContainer,isContainerTraceMarker— behind the traces tier. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)ensureStylesheet,ensurePreload,ensureModulePreload,applyInlineStyles,qualifierValue,findHeadElement,PRELOAD_QUALIFIERS— behind the assets tier (module-private before; nothing to re-export). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)slotEntry,textPosition,consumersOf,slotPositions,consumersEqual,ownedPositions's body,morphOwnedClass,morphOwnedStyle,applyOwned's body,bindDataOccurrence,ElementState,handlerOwners;FrameImpl's#slotConsumers/#slotRebindersfields (now aWeakMapthe tier keeps) — behind the bind tier.assignleaves the eager frames client. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)frame-client.tsexportseachInRange,makeFrameElement,isFrameElement(for the regions tier),tierLoads(the specs' seam — a test re-arms a tier's hold by deleting its load;tierLoads[name].ris the installed module, truthy = resident),SLOT_TEXT,isAsyncValue,shapeOf,slotShapeFinding(for the bind tier) — all@internal, 0 B in the eager dist. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)FrameImplgains anoptionsgetter (@internal; not on theFrameinterface) — the regions tier reads the parent frame'shost/claimScope ?? id/ownerScopethrough it. The frame-internal optionsresolveSlot/resolveSlotRecord/removeSlotRecord(never onFrameOptions) are replaced byparent. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)createFrameHost'spendingRef/settleWait/ per-storewaitsdeleted;applyTo(id, version, r)added. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)rollup.config.js'sexternalizeFramesClientregex covers(trace|regions|bind)-tier; the frames client builds list the tier paths external. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)Changed
Slot(FrameOptions.slotsofcreateFrame/createFrameElement). Before:(props, ctx) => Node | Node[] | undefined— returned nodes were placed by the frame between the range's markers (#replaceRange),undefinedclaimedctx.existingin place. After:(props, ctx) => void— the callback owns its range: it places or binds its output beforectx.range.endoverctx.existing(the Solid binding does this through oneinsert(end.parentNode, value, end, [...ctx.existing])under the fill's owner); the return value is not read.createFrame(boundary: Element, options?)andcreateFrameElement(options)keep their signatures; the internal constructor isFrameImpl(element, options)(was(element, start, end, options)with the markers always null — the never-used comment-marker range mode is deleted). (frames: residue 3 — every fill through insert; createFrame marker-less #3866)SlotContext.rangeis 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. (frames: residue 3 — every fill through insert; createFrame marker-less #3866)callFor(address)(@internal) returns{ id, meta, args, retry }—retry()makes the same call again (the lean re-ask: the frames re-ask for an errored boundary'sresetiscallFor(address).retry(); the RPC-seamreaskinstaller and its two symbol reads are gone; GET stays GET by construction). (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)FrameOptions.revealomitted → the default seam (revealAtOnce) reveals the segment at once (content inserted before the closing comment, the comment removed), with the content applied as it is revealed exactly as on the boundary path — one reveal path,revealstays optional. A placeholder whose closing comment is missing is not revealed (dev error) — before, the imperative path appended at the parent's end and the boundary path threw on the null anchor. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)handler.showing(address, id)no longer brands the bare per-function component withCOMPONENT_BINDING; it only mints the address's binding. The reference a reader holds at t=0 is the bootstrap's binding for the address (branded there). (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)JSONDataTable.resolve(ref)'s answer for a missing key (@internalbehaviour on the integration-facing type): a promise the table owns, markeds = 0and carryingc(the callbacks run in theapply/closethat settles it), stampeds/vas it settles. Before:undefined. A delivered value that is a promise carries no mark. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)FrameHostOptions.resolve's contract, same signature ((ref, frameId, version, current?)): the integration answers the value or the table's pending read;undefinedno longer means "not delivered" (the host does not mint a read of its own). A custom integration that returnedundefinedfor a missing key would now apply the record withundefinedfor that arg. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)FrameHostOptions.prepareData's awaiting: the transport awaits it before aslotchunk whose args carry a{$ref}as well as before adatachunk, soresolvecan assume the codec is resident. Behaviour only on hand-framed wires (the producer emitsstart → data → slot → html → complete). (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)responseHandler.resumeanswersundefinedwhile the wire tier is not resident (the ledger lives in the tier) — the degraded first connect, under Behaviour. (frames: C2 — live wire tier #3868)FrameHost.preview/Frame.preview/stagedContent/contentAddressKEPT — residue 2 measured thepreviewpull form (approved for removal 2026-10-06) and stopped: the honest carrier is +1,414 min / +483 br over a −546 ceiling, net −63; the sketch was unsound where its budget was built (dynamicdelivers a kept resolution only when.addresschanges, so a same-address refetch would land at body end — frames: A1b + A4 — stage/preview deletion, declared slot records, S-ref #3844's gap restated). The push form stays as frames: A1b + A4 — stage/preview deletion, declared slot records, S-ref #3844 left it. Named so the trade is visible if the form is ever taken. (frames: residue 2 — the preview pull form, measured and stopped (carrier +483 br over a −546 ceiling; net −63); C15 (e) pins #3844's gap #3864)Behaviour
{ $tr }marker anywhere in its literal args (needsTrace), a{$frame}region in its record (needsRegions), a_s:*slot marker met at the walk (hasSlotMarker), an assets record naming stylesheets or inline styles (the reveal-readiness term) — waits exactly like a recordless called occurrence: the server interior stays on screen, the frame's hold registers (hydration-done waits), the install's flush mounts it. Every check that finds a tier absent starts its load (the un-announced fallback). A 3.1 hold does NOT keep the delegated-event replay window open — option (a)'s stamp does (Output-shape). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)needsRegions(record) || needsTrace(record.args)): a mounted occurrence's new record whose literal args carry{ $tr }while the tier is absent stays pending in the store — the live binding keeps the args it shows, the load starts, the install's flush applies the record as an args change into the same mount, the marker materialized. Before, the marker went into the binding raw. (frames: trace tier — update-site wait for a mounted occurrence's new{ $tr }record #3861)#heldRecords) and apply a record that replaced it meanwhile as an args change; data occurrences mount with the CURRENT record — their positions are written whole at the bind, so there is no claim to match the markup to. A data occurrence's fresh mount also waits on the regions / trace tiers when its record names them — one hold predicate for both occurrence classes. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)#invokeSlot'sadopted→#resolveArgs(…, claiming)→revive(value, claiming?)→materializeContainerTrace(marker, claiming). A fresh mount reads the fold of its whole backlog at once; a claim — t = 0, under the tier's hold, at a reveal, after done — parks the backlog beyond the snapshot until hydration ends. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)_$HY.r["sc:tiers"]namingtracestarts the import atinstallServerComponents; the adopt-time sync holds the occurrence; hydration-done follows the mount (container-trace-hold-hydration-endre-pinned under 3.1 — same claim assertions, opposite order). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)loadorerror, the$dfcpolicy). The reveal is at max(tier load, stylesheet load); no segment reveals unstyled. Once resident, inline styles never gate — the assets walk lands them at the record's arrival, before#revealSegments, so a segment's<style>precedes its content. A stream with no asset records never loads the tier. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)tiersrides the very chunk the scan would have found the node in. Consequence: adatachunk from a producer that predates the tier, un-announced, decodes the inert marker (the plugin's hookless fallback) rather than converging — a server/client skew one package never ships. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)regions.js(≈ 140 B min there, 0 eager) and deletes with S7's store-boundary normalization. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)insertExpression); a fill's binding owner is transparent — no ambient id consumed at t=0 (the reactive path used to consume one, so a keyed sibling after the frame keyed by timing). (frames: residue 3 — every fill through insert; createFrame marker-less #3866){$ref}read is the table's (L1): a slot record's{$ref}to a key its response has not delivered is answered by that response'screateJSONDataTablewith a promise it owns, settled by the key'sdatachunk through the table's ownapplyand rejected byclose(error)when the response ends — a value that never comes is an error where it is read, naming the key (Stream ended without delivering {$ref: "k"}; before: one generic error for every key, reached by no assertion). Frames keeps the count (record.pending) and onesettleper read. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)_$SC.r(id)) binds the function id, not an_$SC.aaddress record; every production path carries the address with the binding. The re-ask carries the original call's per-call options (a caller-suppliedsignalstill owns the wire). (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)live()'smakeIterablefiresresponseHandler.onLive()at the call, before its first fetch — the import races only the request; a non-live call never loads the tier.handle's live arm awaits residency before the body is read, so a live connection without the tier cannot happen. (frames: C2 — live wire tier #3868)X-Frame-HavenorLast-Event-ID→ full snapshot; the reconnect after a death carries both from the live root's ledger. The brief described this case as "sends the ordinal with no have-list"; withresumefully delegated (theleanform) the ordinal goes too. The server never read it for a frame render (liveSourcecomparesLast-Event-IDto a value digest a frame answer does not have; onlydecodeHaveList(X-Frame-Have)is read in the frame sink), so the outcome is the same full snapshot. Pinned as built. (frames: C2 — live wire tier #3868)documentAddressand its call; the second dispose map (folded intofillScopes); the zombie heuristic and the#slotNodesmap it read (its three dead writes went first); the text pair's interior is walked as ordinary nodes (the pair's own position is the tier's). (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862, frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)Output-shape
_hkon event-slot consumers, document face. Every element the document face renders with an_s:on:*position carries a bare_hkafter its markers — 4 bytes per element; ref-only and attribute-only consumers unstamped; the stream face unstamped (the bootstrap stops capturing at_$HY.done). The tier completes the element at the bind (completed.add(element); runHydrationEvents()) — option (a), the maintainer's Phase 2 ruling, closing the click-replay window. Bare: nothing gathers or claims it, so the dev completion sweep never reports it. 8 document-face expectations inserver/frame-binding-slots.specupdated; a new pin fixes the scope. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)Wire (additive — decision 3, RFC 11 addendum)
X-Frame-Tiers: <name>[,<name>…]on a frame-stream response, set from what the sync render pass minted; omitted when it minted nothing. The head never carriestrace— a trace is serialized at thedatachunk that carries the plugin's node, so its announcement rides that chunk in-band, once. A live response announceswire. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C2 — live wire tier #3868)FrameChunk.tiers?: string[]— the in-band form, on whichever chunk leaves next after a mint the head could not carry; adatachunk carrying it is awaited on those tiers before decoding (no node scan). An old client ignores it (chunkToRecordsreads named fields only). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)_$HY.r["sc:tiers"]: string[], cumulative, re-written at each new mint (the last assignment wins in the shell's data script);<link rel="modulepreload" href>per tier whose URLtierUrlsgave. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)_$SC, the registered symbols are as before. Artifacts vs the base: 5 of 150 re-recorded (B:frame-live-document-{loaded,streamed,switched}announcewire;welcome-status-{loaded,streamed}announcetrace); C6 re-recordedwelcome-status-streamedonce more (the streamed render's chunk order, not the stamp — no artifact carries an_s:on:element). The server suite is green on this head with no artifact self-re-recording of substance (welcome-status-streamed.json/write-before-resume.jsonflip run-to-run on the untouched base too; reverted). web: dynamicComponent — component-only sibling of dynamic #3870 adds 2 new artifacts (dynamic-component-parity-{inline,streamed}.json, deterministic across runs) and re-records none. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862, web: dynamicComponent — component-only sibling of dynamic #3870)Inherited from the base — #3849 (Phase A), not this PR's diff
Listed because the cumulative vs-
nextnumbers above include them and a reader may look for them here; they landed with #3849 (3c1d51267onnext) and are not in this PR's diff againstnext. RemovedFrame.isRevealed(segment)(A3; no caller — #3848) and_$HY.fr.claim(id)/_$HY.fr.release(id)(_$HY.fris{ pending, subscribe }— #3847). Behaviour:_$HY.fr.pending()readsfalsefor a revealed fragment before its_frstamp (the producer's real order; a page's last reveal reads as exhaustion — #3847); a post-done$dffor a placeholder inside server-component markup no client has adopted yet proceeds (was: held, then replayed at adoption — #3847,adopted-swap-post-done). See #3849's body for the rest of Phase A.Pins
Added (new spec files on the chain;
test/consistencyunless noted):server/tier-announce(14) — stream and document faces of the announcement, both forms, cumulative record,renderToStringannounces nothing.tier-prepare(8) — the 3.1 hold onbind(releases the real module), the stream path's buffer-and-retry onregions,prepareTieridempotence (.ris the module), the record read at install, in-bandchunk.tiers+ thedataawait. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)tier-trace-hold(4 — incl. frames: trace tier — update-site wait for a mounted occurrence's new{ $tr }record #3861's update-site wait: on the base the pin fails at its first assertion),tier-regions-hold(4),tier-assets-ready(9 — the FOUC guard; a mutant without the readiness term fails 5 of 7 of the original pins),frames-assets-client(17). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: trace tier — update-site wait for a mounted occurrence's new{ $tr }record #3861)tier-bind-hold(6) — un-announced hold → the install binds every position, hydration-done after, no orphan_hk, a post-bind click dispatches; replay arm (i) a click queued beforehydrate()against a stamped consumer replays into the bound handler at the install; replay arm (ii) a click and aninputduring the hold replay at the bind, in order; the mutant — no stamp loses both arms; a record replaced during the hold → one mount with the replacement; announced → the import starts at install. The client-half mutant (completed.add+runHydrationEventsdisabled) fails 4 of 6.server/frame-binding-slotsgains the stamp-scope pin. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)c15-staging-atomic(e) — the gap frames: A1b + A4 — stage/preview deletion, declared slot records, S-ref #3844 named, by name: a same-address refetch enters the transaction (the fill HAS derived the new arg while the DOM still shows the old; the release lands the whole in one frame; the fill is not re-read at the commit). Green on the push form; any carrier of the staging must keep both halves. (frames: residue 2 — the preview pull form, measured and stopped (carrier +483 br over a −546 ceiling; net −63); C15 (e) pins #3844's gap #3864)sref-table-pending-read(3) — (a) the table alone: one read per key, thes = 0mark,applysettles it stampeds = 1/vand runscin that apply even when the delivered value is a pending promise the read adopts,close()rejects the rest stampeds = 2naming the key, a second close is a no-op; (b) L1 through the production shared host — the mount waits,completecloses the table, the fill's read throws to the nearest<Errored>naming the key; (c) the control. No earlier pin reached the rejection. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)tier-wire-preload(3) — the hook starts the import synchronously at the call, before any fetch; the response arm awaits residency;X-Frame-Tiers: wirejoins the same load (loader called once); a non-live call never loads the tier; reconnect through the tier carriesLast-Event-IDand the have-list, morphs in place, completes; the degraded first connect pinned as accepted.server-functions-live-loop(19, client suite) —live()'s loop driven from the consumer's side over hand-framed bodies (codec chunks and SSE withid:lines): the non-sf throw, a handler withoutintercept, a first-connect failure as the call's rejection, a death reconnecting ononlinewith the cursor asLast-Event-ID, fail-fast 4xx, 408 / 425 / 429 retrying,Retry-After,return()mid-connect / mid-backoff / mid-seed,invoke's signal mid-stream / mid-backoff, no globalEventTarget,live(GET(fn)), the handler's local answers.live's branch coverage 26 → 57 of 60 sites (91 → 129 of 132 paths). (frames: C2 — live wire tier #3868)dynamicComponentparity, SSR + hydrate —test/harness/dynamic-component-parity.tsx,test/server/dynamic-component-parity.spec.tsx(2),test/hydration/dynamic-component-parity-{inline,streamed}{,-via-dynamic}.spec.tsx(4 arms, +-run.tsx; 2 recorded artifacts, deterministic across runs): one page — a client component (sync source) and a non-live server-component reference (async source, under<Loading>) — rendered through the serverdynamicand the serverdynamicComponentis byte-identical (same_hkkeys, frame markup,_frrecord), inline and streamed; thedynamicComponentdocument hydrated through each client entry point — no hydration key miss, record adopted (computes === 1, no request), same nodes, both client slots live. A7 / C16 / C17 through both entry points —frames-errored-reset-refetch(9 × 2),c16-reference-identity(2 × 2),c17-gate-bound-address(5 × 2) aredescribe.eachoverdynamic/dynamicComponent. Type testtest/dynamic-component.type-tests.tsx— a tag-name source ("input", a"input" | "textarea"accessor, a promise of one, aComponent | "input"union, an arbitrarystring) is a compile error fordynamicComponentand fine fordynamic;Component,Promise<Component>,LiveSource<Component>and the nullish/false forms type-check; props flow from the resolved component. (web: dynamicComponent — component-only sibling of dynamic #3870)Re-pinned / adjusted:
c01-claim-once(b) — a ruling (Decisions 10): "a fill returning fresh nodes replaces its range" → "… is a hydration mismatch: the server node stays, nothing is duplicated, hydration reports it". The contract's C1 second arm reads "left as the server rendered it and reported". (frames: residue 3 — every fill through insert; createFrame marker-less #3866)container-trace-hold-hydration-endre-pinned under 3.1 (same claim assertions, opposite order);c03-hydration-done-counts-holds (b)asserts the hold; solid'scontainer-trace.specre-pins the park as the claim's; S1's seven ported through the general seam (frames-container-lazy-{codec,document},hydration/welcome-status-lazy,container-trace-hold-{id-determinism,interruption,record-retention,snapshot}). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)hydration/adopted-claim-args-addressmounts_$SC.r(FID, ADDRESS)(the binding, the production shape) and adds "the store the frame bound is the ADDRESS's, not the wire id's". The nine arms offrames-errored-reset-refetchandhydration/frames-adopted-error-outwardgreen unchanged through the lean re-ask. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)frames-binding-slots› "a refetch re-sends the empty pairs: the morph keeps the client's text" — the pin that keptreconcileChildren's text-pair arm eager (Decisions 6).lifecycle-matrix/remount› "away/back over a t=0 adopted boundary" — the pin that kept the capture arm (Decisions 11). (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862, frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)frames-live-resumere-pointed to the tier'shaveOf/encodeHaveList;frames-live/frames-live-showingwarm the tier. Resident cells warm withprepareTier(...)across the matrix (container-args,call-driven-args, C11, C19, the harness'sreadyMaterializer,frames-hn-client,frames-occlusion-client,frames-optimistic-hold,frames-used-region-client,frames-binding-slots,hydration/binding-slot-adoption,server/frame-hnregisters the regions loader as the entry does).MATRIX.mdupdated. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862, frames: C2 — live wire tier #3868)lifecycle-matrix/call-driven-slots×2,server/frame-hn×2) place throughctx.range. (frames: residue 3 — every fill through insert; createFrame marker-less #3866)Still
test.fails(unchanged across the chain; no other): #3841's 15 GH pins in the hydrate suite (consistency/generic/replay.spec×12,consistency/generic/preload-hold.spec×3); S1's id-drift pin (hydration/container-trace-hold-id-determinism, rulings 3.4 — the server's root-id consumption for a boundary component; the client now consumes no ambient id per fill, the id-neutral side of that gap); and the pre-existing, unrelated client 1 (optimistic-lazy-show-preview-3706, preview="find") / server 3 (loading-recreated-async-source-3734).Suites on this head (
99915141a, #3870's fresh build —packages/compilerpnpm build, turbo--force; #3875 on top isscripts/sizetooling and fixtures only, nothing underpackages/*/src): web client 130 files / 1,227 passed + 1 expected fail; server 160 / 1,513 + 3 expected fail, 2 skipped; hydrate + consistency 93 / 463 + 16 expected fail, 2 skipped (+ 1 file skipped, a campaign spec gated onCONSISTENCY_FUZZ); solid 42 / 833;test-typeson web and solid green;tsc -p tsconfig.build.jsonclean;scripts/sizegate tests 12/12; dist specs (dist-nested-manifestsincl. the five tier chunks' self-imports,dist-frames-server-instance, solid'sdist-{client,server}-artifact,internal-surface,published-declarations,export-parity,cross-package-fields) green. HarnessCONSISTENCY_FUZZ=1, 500 cases, seeds 3289 / 91501: SC arm 0 / 0; generic arm withCONSISTENCY_IGNORE=C1,C9,C19,E0 / 0. (The chain's earlier headd9395e398ran the same suites green: client 130 / 1,218, server 159 / 1,511, hydrate 89 / 452 — #3870 adds 9 / 2 / 11 tests.)Suites after the merge onto
next(7f5b9e007; filtered turbo build of web + universal and their deps, the native compiler from sources identical tonext's): #3874'sframes-slot-arg-flights-38522 / 2; frames client specs 25 files / 182; frames + server-functions server specs 74 / 664; the server-functions client spec 19 / 19; full web — client 135 / 1263 + 1 expected fail, server 161 / 1524 + 3 expected fail + 2 skipped, hydrate 93 (+1 skipped) / 463 + 16 expected fail + 2 skipped; solid 42 / 833.packages/signalsis byte-identical tonext(this PR touches none of it), so the signals suite was not re-run. The full suite re-wrotewelcome-status-streamed.jsonandwrite-before-resume.json(chunk boundaries / order only); in a lone run of the two generator specswrite-before-resumereproduced the committed form andwelcome-status-streameddid not — its fixture races a 15 msstatstimer against two 5 ms generator steps on real timers, under machine load here. Both reverted, not re-recorded (the flip this body already records on the untouched base); CI generates its own before the hydrate suite reads them.Size
On
next(2026-10-07, after the merge). CI's Size run 37671002618 on the merge7f5b9e007measured the head to the byte the same as local; the caps below are CI brotli + 10 B rounded up to 0.01 KB with CI's minified recorded (commit70fe0c7b4, dated ledger lines citing #3860). Thenextcolumn is #3849's CI head (the tree3c1d51267carries); CI's base job cannot bundle the five frames / page fixtures onnext(nodynamicComponent, no compiled fixtures). Min / br B.nextnext→ headweb.jschunk)Gate on the head: every scenario passes, none over its cap;
check-floor-caps.mjs origin/next: no cap raised (noSize-Exception:needed). No cap is raised abovenext's. The two compiled-page caps sit above this branch's own pre-merge local caps becausenext's bytes since the branch's base land under them (the same +666 / +816 B minified as the hand-written pages); they have no cap onnext.Before the merge (the folded chain's measurement, kept for the per-step record):
scripts/sizeon this head (min / br), vsnext— both built fresh in their own worktrees (native compiler included, turbo--force, 0 cached) and measured with this head's harness; thenextscratch tree removed after. The page rows are #3875's measurement (head3b70dd4c5vsnext@11e9fb653; onnext, wheredynamicComponentdoes not exist, the four server-component fixtures mount withdynamic— what anextapp ships); the frames eager row is #3870's (vsnext@53ef0e69e); the rest are the chain's measurement vsnext@9d89df731(0 / 0 across #3870 / #3875). The vs-base column is from #3860's earlier fresh measurement of0aab93230(not re-measured for the collapse) plus #3870's −7,417 / −2,152 and −7,418 / −2,171 on the two pages.0aab93230nextnext146,423 / 45,139)next158,536 / 48,888)next148,820 / 45,900)next161,233 / 49,721)materializeparameter in the codec plugin the SSR runtime bundles)Per step (frames eager, min / br, each measured on its own base): B + C3 + C4 + C5 −2,979 / −721 (+1 B of integration glue over the four PRs' sum); #3861 +12 / +3; C6 −4,005 / −1,185; residue 1 −757 / −247; residue 2 0 (measured and stopped); residue 3 −978 / −287; residue 4 −505 / −174; C2 −965 / −305;
dynamicComponent0 / 0 on frames eager (it is a page-level cut: −7,417 / −2,152 page base, −7,418 / −2,171 page live, 0 / 0 on the other 15 scenarios — the edited-dist model predicted −7,423 / −2,158 and −7,424 / −2,237; the build differs by +6 B minified of wrapper glue and brotli layout noise). The steps sum to the integrated head exactly — 0 B of collapse glue (a fast-forward). Every tier's glue overran its estimate ≈ 3× while every deletion held; recorded in the plan §3.What
dynamicComponentsheds from the page (rendered exports ofweb.json page base, before → after):staticElement,createElement,spread,SVGElements,MathMLElements,getNextElementgone;assign/assignPropstay — through the bind tier's entry edge (isAsyncValue, #3862), not through any template ordynamic— so B.3's "assignlazy on the page" is still open (Not in this PR). On the compiled pages (#3875) the picture is the same:spreadabsent;className/style/setAttribute/addEvent/delegateEventspresent on both the hand-written and compiled pages (hand-written: viaassign; compiled: viaclass={…}/href={…}/onClick={…}); compiled-onlytemplate/getNextElement/getNextMarker(632),readShallow(238),setProperty(217),scope(35), and 1,071 B of signalsstore/utils.js—readShallow→sourceKeys(value, SOURCE_PROXY)retains the leaf/merge key walkers, so any compiled page withclass={expr}carries it.dynamicComponentvsdynamicon the spread-free compiled base page: −5,812 / −1,681 (live −5,816 / −1,736) — less than the hand-written −7,417 / −2,152 because the compiled page's templates import part of the attribute runtime themselves.Lazy chunks (page scenarios, reported not counted; min / br):
trace.js25,417 / 8,198;regions.js1,858 / 798;assets.js2,040 / 783;bind.js4,781 / 1,834 (+bind.dev.js11,172 raw under thedevelopmentcondition);wire.js1,922 / 934 (933 on the live page);decode.js23,410 / 6,237 (+424 / +163 vsnext's 22,986 / 6,074 — residue 4's table-owned read). Live page, honestly: it LOADSwire.jsat its firstlive()call, so its total transfer is 37,241 + 933 = 38,174 br — the chunk's own weight is the price of the eager −305 on pages that never go live. The compiled live page's eager graph is two chunks (sc-live.js55,609 / 18,354 + a statically-importedweb.js66,354 / 21,912): withisPendingon the page, the tiers and the lazy comments route all reaching the core, Rolldown hoists the shared runtime into a chunk the entry imports statically — fetched before the entry runs, so eager. The two compressions cost ≈ 1,223 B br over one chunk and the export glue ≈ 1.3 KB min, which is in the 40,266 above. The hand-written live page does not split only because its lazy page is empty; onnext(no tiers) neither does. Not traced into Rolldown's chunk assignment — it is Rolldown's default chunking, which is what Vite ships, so an application page of this shape gets the same two files. (#3875)Caps (pre-merge; superseded by the table at the top of this section): the three tier caps ratcheted along the chain and the two page floors ratcheted again by #3870 (35.74 → 33.58 KB / 104,586; 39.43 → 37.26 KB / 116,485) — all to measured + 10 B at the 0.01 KB step, with dated ledger lines in
scenarios.jsand recorded minified infloor-caps.json. #3875's two compiled-page scenarios carry inlinelimit:/capMinified(the gate has no report-only mode): 34.88 KB / 108,634 and 40.28 KB / 121,963, from local bytes — CI must confirm: set to CI's measurement + 10 B if CI reads differently, never higher than that; nofloor-caps.jsonentry (the compiled app scenarios carry inline caps too). The integrated head's bytes equal the folded PRs' recorded bytes exactly, so no cap moves for the collapse.check-floor-caps.mjs 0aab93230: no cap raised (againstorigin/nextit reports the hydrating floor 17.73 → 17.85 KB — #3849's Size-Exception, not this PR's; it clears once #3849 merges). The gate (head vsnext): every scenario passes; the three pre-existing over-cap scenarios (signals core floor +11, isPending +23, CSR +45 br, all 0 min, byte-identical tonext) warn within the minified allowance — the known local-vs-CI brotli difference. The ratchet would also lower two caps this PR does not move (signals: + createStore14.56 → 14.55,app: CSR observe + attribution28.66 → 28.65); left alone. No cap raised.Harness (
scripts/size) — #3875Tooling and fixtures only; nothing under
packages/*/src, no changeset.page: compiled base server components (…; no spread)andpage: compiled live server components (…)—fixtures/compiled/sc-base.jsx/sc-live.jsx+sc-shell.jsx(+ a lazysc-comments.jsxroute, reported not counted), compiled through the native@solidjs/compilerthe way theapp: compiled …scenarios are (size: compiled-template scenarios on the gate (compiled floor, JSX todo app CSR + hydrating) #3810):hydratable: true,dev: false,generate: "dom". The same mount as the hand-written pages (installServerComponents(),createServerReference("story", "getStory"),dynamicComponent(() => getStory()),hydrate()intodocument.body) inside a compiled shell — a<For>of nav links (hreffrom a prop →setAttribute;class/styleas dynamic values →className/styleunderreadShallow; a click handler passed as a prop →addEvent;delegateEvents), a search input with avaluebinding (setProperty) and a delegatedonInput,<Show>with a function child,<Errored>+<Loading>around the server component, alazy()route under its own<Loading>, the client signal + memo the hand-written page keeps. The live page addslive(GET(createServerReference(…))), anactiondispatched from a compiled click handler,isPendingread in a template'sclass,latestin a text hole. No element spread on either — the maintainer's ruling (2026-10-07): most server-component apps do not have client element spreads, so the compiled baseline must not carry one; a separate "+ element spread" line was considered and dropped. Why: the hand-written pages never compile a template, so the attribute runtime reaches them only through the bind tier'sassignimport — a change that removes that one edge "saves" ~4 KB minified on the fixture and saves nothing on a page an application ships, where every compiled template imports the runtime directly. These scenarios are the number a real server-component page moves by; the hand-written pair stays as the frames-only floor.bundle.mjs— flagged. It treated every non-entry chunk as lazy; the compiled live page's statically-imported runtime chunk (above) would have read as 18.35 KB — less than half the page. It now measures the eager graph as the entry plus every chunk it reaches through static imports (each file brotli'd on its own);size.mjs,attribute.mjsand the PR comment name such chunks aseager (… counted); the JSON carries aneagerarray besidelazy. No other scenario splits; every existing number is unchanged (verified against the pristinebundle.mjson the same build).scripts/size/README.mddocuments both.page: compiled base SC + router(needs size: add page base + router scenario #3838's router wiring —@solidjs/router@2.0.0-next.35inscripts/size/package.json, the lockfile,.npmrc— not on this head; a follow-up once both land).Decisions for the maintainer
Carried forward from the folded bodies; each is a standing question, none blocks the merge.
tierUrlstoframeTransformDirectResultfor themodulepreload(SolidStart from its manifest; nothing passes them yet), and the client resolves tiers itself through the built-in loader table (trace,regions,assets,bind,wire) +installServerComponents({ tiers }), starting each import from thesc:tiersrecord at install. Not built: resolving on the server throughctx.resolveAssetswith a frames-owned module key (couples the frames server to one bundler's manifest shape). Accept (i)+(ii), or name a third? (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)prepareTierstays unexported from the public client entry (9 B br). Five tiers now exist to warm. Export it, or leave the warm start to the announcement? (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)renderToStringdocument announces nothing — no shared render slot to dedupe on; addinglive: {}to the sync context is a core change on therenderToStringfloor. The client detects (the un-announced fallback). Leave as is? (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)tierReady(name)returnstruefor a name with no loader, soframe-client.tsdriven directly (noclient.ts, so no built-in table) reachestierLoads.regions.r.resolve(...)/tierReady("assets").gate(...)with nothing there — aTypeError, not a hold. Recommend "no loader ⇒ the load fails loudly in dev" (a_SOLID_DEV_throw naming the tier; prod unchanged) — a separate small change if you agree; not made here since it changes B's pinned semantics. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)regions-tier.tsand two pins flip to GAP (lifecycle-matrix/call-driven-args› regions rebind,frames-optimistic-hold› rename). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)reconcileChildren's text-pair arm stays eager (+39 br) — the "tight-nopair" variant deleted it and broke "a refetch re-sends the empty pairs: the morph keeps the client's text (and its node)": reconciled as ordinary nodes the client's text node is removed, and with the start marker unchanged the consumer set compares equal — no rebind, text lost. A tier dispatch point there costs what the arm costs (+109 min / +39 br either way), so the arm is kept with a comment. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)_hkwhose element never binds holds the queue until hydration-done. Accept as the cost of the stamp, or stamp only consumers the fill set is known to bind (needs the fill set on the server)? (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)<style>ahead of its content inside one flush. Say the word and it folds. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)FrameOptions.revealkept optional via the default seam (−19 vs −30 at the ceiling of making it required; ~10 test files construct frames withoutreveal). The missing-closing-comment case is now "not revealed, dev error". Confirm. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)insert(a claim pass cannot replace) — the item would keepnormalizeSlotContent+settle+#replaceRangefor the mismatch case, ≈ 70 br of the item. Confirm or reverse. (frames: residue 3 — every fill through insert; createFrame marker-less #3866)contentHTML+ the host's last-unmount capture; −272 / −90 if cut): pinned bylifecycle-matrix/remount› "away/back over a t=0 adopted boundary" — without it an away/back over a document-adopted boundary shows the covering<Loading>'s fallback while the refetch is in flight instead of the interior it showed. A holds-latest ruling across a remount of the document face, not a cut. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)signal: the retry thunk carries the caller's options whole, so a caller-aborted call's boundaryresetre-asks on the aborted signal (the sf client's rule — a caller-supplied signal owns the wire). Stripping it is ~20 min on the sf client. Flagged in case the re-ask should run on a fresh signal. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)previewpull form — recommended against at −63 (§3.1 "As measured"). The push form stays. If ever taken, it removesFrameHost.preview/Frame.preview/stagedContent/contentAddressand addsFrameHost.staged(id)/promote(id, version?)/apply(chunk, stage?)/SlotContext.staged()— a customFrameHostnot implementing them would lose atomic staging. (frames: residue 2 — the preview pull form, measured and stopped (carrier +483 br over a −546 ceiling; net −63); C15 (e) pins #3844's gap #3864)Slotcallback that still returns nodes is silently ignored (=> void; the type and the doc carry the contract). A_SOLID_DEV_-only warning is a new diagnostic code — its own surface item; worth it if raw consumers exist outside the test suite. (frames: residue 3 — every fill through insert; createFrame marker-less #3866)closedoes not abort the table's deserializer (createJSONDeserializer.abort): a seroval{p, s, f}promise whose settling chunk never comes stays pending after the response ends, as before this pass (the sf path aborts its decoder at an unexpected end; frames never has). L1 for delivered-but-unsettled async values is a separate ruling — the hole is named, not taken. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)lifecycle-matrix/harness.tsmakeHostand ~18 spec-local ones sharing one table across responses) do not wirecloseData— with one table per test a close at any response's end would reject every response's reads. Their pins do not exercise L1; the production shared host does, and the new pin covers it there. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)leanform drops it with the have-list while the tier is absent; a two-lineresumefallback in the client would sendLast-Event-IDalone. The server never read it for a frame render, so the outcome is the same full snapshot either way; pinned as built. (frames: C2 — live wire tier #3868)slotArgsProxystill lacks 2.0.0-rc.0 | Error Boundary not showing when expected #2997's rejection-observe fors === 2; the export it needs (readHydratedValue, module-private insolid/src/client/hydration.ts) would be a newsolid-js/internalexport, not a 0-B re-export. Skipped per the brief; the export would fix both. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)Not in this PR
size/frames-claims-entry, ≈ −270) — stopped at the gate; three forms for ruling. C1, the holes tier (≈ −440) — ruling. Together they are the remaining path from 10,888 to ≤ 10 KB (≈ 120 B of margin after residue 2's finding)._$SCmirror (−234 / −80) — needs the server to emit the bootstrap unconditionally on SC pages (≈ +200 B of document output); a server-side change (residue §3.7). The s / v stamp (Decisions 18). Phase D (preserveModules— web: preserveModules + client.ts source split (attribute runtime, store adapters as modules) #3872, on hold on a Rolldown second-eager-chunk issue; the store hydration adapters ≈ 1.3 KB br stay eager on the pages). B.3's remaining half —assign/assignProp(283 + 921 min) still ride the eager page through the bind tier's entry edge (isAsyncValue, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862), on the hand-written and the compiled pages alike;dynamicComponent(this PR) took the element-runtime half.examples/{hackernews,notes,room}) still mount withdynamic; moving them todynamicComponentis a follow-up if the maintainer wants them on the documented mount.page: compiled base SC + routerwaits on size: add page base + router scenario #3838.live's last three branch sites (areturn()race and two stopped-while-pending elses) — not reachable deterministically from the consumer's side.sc:region:drain arm stays eager (moving it needs the tier to re-drain_$HY.ron install — more glue than the ≈ 30 br it saves). Thesc:tiersrecord is not re-read after install (a post-install write is covered by the hold).nextand updated by merge on 2026-10-07 (Summary › Base and CI › Update).