diff --git a/.changeset/container-trace-claim-reads-snapshot.md b/.changeset/container-trace-claim-reads-snapshot.md new file mode 100644 index 000000000..483d2be1b --- /dev/null +++ b/.changeset/container-trace-claim-reads-snapshot.md @@ -0,0 +1,8 @@ +--- +"solid-js": patch +--- + +A fill that claims adopted markup reads the state the server rendered it from (frames-rulings 3.6 (iii), "the consumer parks" — S1's third commit ported onto `next` without its `claiming` hint). + +- `materializeContainerTrace` parks a replayed backlog beyond the snapshot until hydration ends (`onHydrationEnd`; the next microtask when no hydration is in progress) and then applies it as one ordinary update. A container trace is materialized at a fill's arg-read; when that fill claims server markup — the document's pass, a frame's deferred claim under its hold, a claim at a fragment's reveal after hydration-done — 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 the markup left the DOM diverged for good. Parked, the claim reads the snapshot and the backlog lands after it, so the DOM catches up outside hydration. The release order is the one 3.2 pins: the claim, the frame's hold release, done, then the backlog. A fresh mount pays one beat for not being told apart: its backlog lands a microtask after its snapshot, before any paint. A failure in the backlog applies in order, after the parked patches. +- The materializer creates its projection under a DETACHED root. Rooted under the reading owner — during hydration an id-carrying one — a trace revived at t=0 consumed one child id per trace while one revived by a late claim consumed none, and a keyed sibling after the frame hydrated under different keys in the two runs. diff --git a/.changeset/frames-a1b-stage-deletion-s-ref.md b/.changeset/frames-a1b-stage-deletion-s-ref.md new file mode 100644 index 000000000..cb3e81bb0 --- /dev/null +++ b/.changeset/frames-a1b-stage-deletion-s-ref.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: A1b + A4 (S-ref) — a slot record's `{$ref}`s settle at the host's write, through the response's own data table (`FrameHostOptions.resolve(ref, frameId, version, current)`); an undelivered key is a pending read the response's `data` chunk settles and its `complete`/`error` rejects (L1 — a value that never comes is an error, not a silence); a fresh mount waits for the record to settle, a mounted occurrence's prop pends and holds its value. Codec data tables are per response (keyed by frame id and version). Deleted: `ServerComponentHandlerOptions.onStream`, `STAGED_DATA` and the staged tables, `FrameHost.resolve`, `FrameHostOptions.isContainer`/`FrameHost.isContainer`, `Frame.rebase`, the frame's record dedupe (`#refArgsUnchanged`, `#slotResolvedRefs`) — a re-sent record's equality is the fill's per-prop memo's — and the frame's error/root value latches (applied state keyed by record identity). `FrameHost.preview`/`Frame.preview` lose their `resolve` parameter and stay: the compute-half preview is what stages a refetch's args with the transaction that read it. diff --git a/.changeset/frames-a3-reveal-applies-its-range.md b/.changeset/frames-a3-reveal-applies-its-range.md new file mode 100644 index 000000000..6594dec1c --- /dev/null +++ b/.changeset/frames-a3-reveal-applies-its-range.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: a reveal applies its range (frames-rulings 2.3, A3). A revealed segment's content is applied against the store as it is revealed — its fills mount and the segments whose placeholders it carries reveal inside it, in the same flush and before a reconstructed boundary commits the content — so the frame's flush no longer retries over its segments, and a segment nested in content a pending fill holds no longer waits for a chunk that never comes. The readiness/retry model's second ledger (`#revealed` / `#fallbackShown`) is gone: a reveal's applied state is the content record it applied, a fallback's the gate it materialized, by identity, in the one applied map; whether a segment is shown is the DOM's to say. `Frame.isRevealed(segment)` is removed. diff --git a/.changeset/frames-a4-declared-slot-records.md b/.changeset/frames-a4-declared-slot-records.md new file mode 100644 index 000000000..8acd67bdd --- /dev/null +++ b/.changeset/frames-a4-declared-slot-records.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: A4 (S-record) — the document face declares a slot record at its marker: `_$HY.r["sc:slot::"]` is a pending promise written with the occurrence's markup and settled with the args by the record's data script (the shape a fragment's `_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 (C2 (a2) flips). Output shape: ≈ +32–38 B per document slot record (the resolver helpers are shared with the page's fragment declarations); a sync render (`renderToString`) writes the settled value as before. Deleted: the #2968 `setTimeout` re-drain poll and `FrameOptions.recordsPending` / `FrameOptions.drainRecords`. diff --git a/.changeset/frames-a5-fragment-ownership-by-rendering.md b/.changeset/frames-a5-fragment-ownership-by-rendering.md new file mode 100644 index 000000000..e0b410a07 --- /dev/null +++ b/.changeset/frames-a5-fragment-ownership-by-rendering.md @@ -0,0 +1,10 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Frames A5′ — a deferred fragment's placeholder inside a server component's element is the frame's content by rendering, not by adoption (frames-rulings 3.3, ruled 2026-10-06). + +**`solid-js`:** the document fragment ledger's `fragmentPolicy` lets a post-done swap proceed when the fragment is owned — its `pl-*` template is in the document and the integration's ownership predicate `_$HY.fa(placeholder)` says so — beside the existing claimant case; no hold, no replay for owned fragments. `_$HY.fr.claim` / `_$HY.fr.release` are removed from the published ledger (`_$HY.fr` is `{ pending, subscribe }`); `_$HY.fa(placeholder): boolean` is the new integration hook. `fragmentPending` now reads a revealed fragment from its swap record (`_$HY.v`) before its `_fr` stamp: the producer emits the swap script and then the `_fr` settle in the same batch, so a `_$HY.fr.pending()` read inside the reveal notification saw the revealing fragment as still pending — a page's last reveal never read as exhaustion and a waiter released on exhaustion waited forever. + +**`@solidjs/web` (frames client):** installs `_$HY.fa` once (`pl.closest("[data-fid]")`, minus elements of a boundary disposed in place — C14); deletes `claimRegionFragments`, the per-adoption claim set, the cascade's claim half and the release loop (the dev-only rejection report over the region's `pl-*` templates stays, 0 prod bytes); `documentBoundary` pends on the intercept's one arrival answer (`awaitBoundary`) and `boundaryWaiters` is deleted (G9). A post-done swap into server-component markup no client has adopted yet now lands at once; the adoption that follows finds it in place and reads its declared records synchronously. diff --git a/.changeset/frames-a7-error-throws-reset-reasks.md b/.changeset/frames-a7-error-throws-reset-reasks.md new file mode 100644 index 000000000..dbd08006d --- /dev/null +++ b/.changeset/frames-a7-error-throws-reset-reasks.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: a frame's error is an errored async value (frames-rulings 3.3, A7). `FrameHost.landing(address)` rejects with the error record at the response's `:error` write, so the mount's content node throws to the nearest client `` — the covering `` no longer releases over an empty ``. An error after the landing (a later yield failing, a cut-off stream, a refetch's response erroring) errors the node the same way; with no client `` the core halts, as for any uncaught async error. The ``'s `reset` re-asks: an errored landing is not a landing for a fresh consumer, and the re-read opens a new flight for the same address — the handler records the call behind every address it handles or the document answered (`callFor`, `@internal`), and the client re-invokes it through the server-function registry's RPC seam, whose client half now carries `createServerReference(id)`. A response for an address whose mounts show an error writes through instead of being staged. `frame.error` still records the error. diff --git a/.changeset/frames-c12-fragment-error-outcome.md b/.changeset/frames-c12-fragment-error-outcome.md new file mode 100644 index 000000000..2d33fcaad --- /dev/null +++ b/.changeset/frames-c12-fragment-error-outcome.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +A server `` inside a server component that fails after the first flush renders the server's outcome into its fragment instead of a blank (C12 (c), frames-rulings 3.3): the nearest server ``'s fallback for the error, at the ``'s position (asked through the boundary error handler's new `outcome` mode; a `` between passes the question up); with no server `` the error escapes the component — the frame's own `:error` on the stream face (an unkeyed `error` chunk), a frame-addressed `{ type: "error", fid, error }` op on the document face's `sc:live` channel (only the owning adopted boundary applies it) — and the position keeps the boundary's own fallback. `_fr` still rejects and the keyed error chunk still rides (the diagnostics). Outside a server component nothing changes (the blank the client twin renders fresh over). `HydrationContext.registerFragment`'s resolver gains a third argument (`escaped?: { frame?: string }`) and the context an internal `frameId`. diff --git a/.changeset/frames-c13-sweep-ops-chunk.md b/.changeset/frames-c13-sweep-ops-chunk.md new file mode 100644 index 000000000..b1a08c0b4 --- /dev/null +++ b/.changeset/frames-c13-sweep-ops-chunk.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: one server sweep lands as one frame (C13). The sink collects the `hole` / `attr` re-emissions one sweep produces and emits them as one `{ type: "ops", 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, as before. The client maps the unit to one record map and applies it as one write — one hole pass, one `frame:applied` (the hole pass now announces once per flush, not once per hole). The document op log flattens a unit into its members (last value per hole). Additive wire (`FrameChunk` gains the `ops` member; RFC addendum in `frame-streams-rfc.md`). diff --git a/.changeset/frames-claim-through-hydrate-window.md b/.changeset/frames-claim-through-hydrate-window.md new file mode 100644 index 000000000..96542663c --- /dev/null +++ b/.changeset/frames-claim-through-hydrate-window.md @@ -0,0 +1,10 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +An adopted frame occurrence claims its server markup by re-entering hydration the way a streamed `` resume does (frames-rulings 3.1 / 3.2, the savings plan's A2 — S-hold's window form). + +- `solid-js`: `hydrateWindow(id, fn, scope?)` is factored out of a streamed boundary's resume and reached 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 for the synchronous window, the current owner the claim owner (a render the window forces elsewhere is a client render, #3504), the owner the window's snapshot and live scope when none is open — so a write during a late claim is held and replays once the claim is over, and a late claim no longer re-marks the root's scope. `sharedConfig.claimRoots` (`@internal`) is typed: the claimant declares a range that may be detached around its window. `holdBoundary` stays the registration; the resume path is unchanged in behaviour. +- `@solidjs/web` (frames): `claimRender` is the window — one `createOwner({ id: prefix })` and the call — instead of a registry of its own gathered by walking the range, a hydrating flag flipped through `sharedConfig`'s setter (which reset hydration-done and re-ran its completion from outside the runtime), and a hand-over of keys from the root registry: `gatherClaims` and `hasPendingFragment` are deleted (the window gathers by the producer prefix and always engages). `adoptBoundary` captures the registry/gather pair it adopts under so a claim made long after — under the frame's hold, at a fragment's reveal — gathers against the root that holds the frame. +- `@solidjs/web`: `gatherHydratable`'s prefix-scoped gather selects its keys natively (`[_hk^="…"]`) instead of sweeping every `_hk` and filtering in JS — it now runs once per adopted occurrence, not only per late resume. diff --git a/.changeset/frames-plain-response-bound.md b/.changeset/frames-plain-response-bound.md new file mode 100644 index 000000000..94ad56934 --- /dev/null +++ b/.changeset/frames-plain-response-bound.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: a plain (non-`live`) server component response ends at a streaming bound. A response whose content reads a standing source — a generator memo, a projection — used to stay open until the source settled, which for a source that never returns was never, with none of `live`'s reconnect semantics. The producer now ends it after `maxYields` emitting sweeps past the first flush (default 64) or `maxDurationMs` after the first flush (default 30 000 ms) — and when the request's `signal` aborts after the first flush — emitting `{ type: "complete", bound: "yields" | "time" }` before the body closes, and tears the render down quietly (sources returned, no abandonment finding). Both are new options on `FrameStreamOptions` (`renderServerComponent`, `renderToFrameStream`, `serverComponentResponse`); a `live` response is never bounded. The client stores `:bound` beside `:complete` and, in dev, warns once per cut-off naming `live()` as the declared way past the bound. `createFrameSink` gains an optional fourth `hooks` argument (`onYield`) and its `end(bound?)` takes the bound. Additive wire; RFC addendum in `frame-streams-rfc.md`. diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index 0c27bd60b..7dc23231e 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -277,24 +277,54 @@ gate, and only with A6's drafts attached and their server PRs opened; the harness clean on **two seeds** with every law un-ignored; frames eager **smaller than today** (13,770 → ≈ 12,300). -| # | step | Δ br (frames eager / page base / page live / compiled hydrating) | chunks created | gates (pins flip; size) | depends on | surface | -| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **A0** | **C18 — classification waits for the drain** (rulings step 1, 3d / 3.5; **in flight**). The predicate is one term in `adoptBoundary.recordsPending`: a `prop#n` occurrence is classified only after every delivered record has drained. The only page-halting red; lands as the rulings specified it. A1 then makes the mechanism moot (one write per drain; `#` decides the class — C18 becomes unrepresentable) and the pins stay as the assertion of the pending read. | **≈ +15 / +15 / +15 / 0** (the rulings' ≈ +50 min / +15 br; ≈ +25 br with the batched drain, which A1 supersedes) | none | **flip:** C18 ×3. Size: ±0.02 KB on every scenario (frames eager ≤ 13.8). | #3813 landed | none | -| **A1** | **S-flush + the R deletions it unlocks.** `content = createMemo(() => host.landing(binding()))` — one reactive node per bound address resolved at the version's first root / error write (the host's `landing(address)`: a promise for a cold store, the value for a warm one); the enclosing `` pends on it, a switch is a new flight (`_inFlight` supersession, 1.6 (i) by construction), a refetch's landing is staged by the Transaction that read it (G7 closes). Deletes: **R.gate** (`arm`/`release`/`settle`/`setGate`/`mountGate`, the adopted twin), **R.stage** (`stage` / `stageTables` / `stagedContent` / `CONTENT_TOKEN` / `STAGED_DATA` / `FrameImpl#preview` / `#regionsChange` / `host.preview`; the chunk buffer-until-`complete` stays, one write), **R.version** (2a: one applied record keyed by identity; `#appliedRoot`), **R.dedupe** (per-prop memos in `slotArgsProxy`; `argsEquivalent` / `#refArgsUnchanged` / `#slotResolvedRefs` go), **R.error**'s latch. Files: `frames/src/client.ts` (`boundaryComponent`, `adoptBoundary`, `followAddress`), `frame-transport.ts` (`stage*`, `handle`), `frame-client.ts` (`#apply`, `#flush`, `preview`, `#syncSlots`' dedupe arms). | **≈ −1,150 / ≈ −1,150 / ≈ −1,150 / 0** (`est.` from the measured R total −1,863 with every feature kept, scaled to the 4,170 of 6,655 R-min these groups are; S-flush's own glue ≈ +110–190 min / +40 br is inside this) | none | **flip:** C5 (a, b, e) with the per-response data cell (1.2), C6 (b2), C7 (c), C17 (a); **C17 (c) re-pins** to 1.6 (i) `waiting → B`; C6 (b1) inverts (asserts the opposite of A0). Size: frames eager ≤ 12.7 KB (from 13.78), page base ≤ 43.7, live ≤ 47.5. | A0 | **removed / changed:** `ServerComponentHandlerOptions.onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost.preview` / `Frame.preview`, `STAGED_DATA` — the rulings' step-4 list. **New:** `FrameHost.landing(address)` (internal). | -| **A2** | **C3 via `initBoundaryResume` — S-hold** (the rulings' 3a, pulled forward: **this is what lets any later hold register**). `hydrateWindow(id, fn, roots?)` factored out of `resumeBoundaryHydration`; `initBoundaryResume`'s registration reachable from the adopter (`sharedConfig.resumeBoundary`); `adoptBoundary` registers the adopted frame's owner while `#syncSlots` leaves any adopt-time occurrence deferred (the held set — one registration per frame, 3.2) and releases when a sync leaves none or the frame disposes. The #2968 `setTimeout` poll becomes the registration with the drain's end as its bound (3.5); the resumed fill re-enters hydration through the window and claims under the producer's keys (C1 / C9 stay green). **With it, 3e ported onto `next`** (the detached root + the parked backlog beyond the snapshot, 3.6 (iii), without S1's `claiming` plumbing — the rulings' "else ≈ +90 / +25" arm, because S1 no longer lands first and the Phase A gate counts C19), and 3.2's release order (claim → hold release → done → backlog) pinned. Deletes **R.claim** (the range-scoped registry beside `gatherHydratable(el, root)`) and the counter half of **R.drain**. | **≈ −130 / ≈ −130 / ≈ −130 / ≈ +40** (`est.`: R.claim ≈ 472 min + R.drain's defer ≈ 270 min ≈ −215 br of cuts; the registration ≈ +100 min frames ≈ +60 br incl. the `hold` option; the 3e port ≈ +90 min / +25 br; solid `hydrateWindow` + the reach ≈ +40–65 min ≈ +12–20 br, the detached root ≈ +20 br) | none | **flip:** C3 (a) + the harness's C3 replay; C19 ×2 (3e); S1's C3 (b) flips at C3 (the traces tier). Size: frames eager ≤ 12.55; **app hydrating / compiled hydrating +≈ 40 br — the first cap raise, the maintainer's** (compiled hydrating is at its cap on this head: 30,943 vs 30.93 KB). | A1 (the deferred set is right only once the drain is one write and the landing node exists) | **solid:** `sharedConfig.resumeBoundary` or an `internal` export of the registration — no new counter, no new done path (3.1 ruled; the draft's `holdHydration` withdrawn); the detached projection root (3e). **frames:** `FrameOptions.hold(): () => void` (internal, wired by `adoptBoundary`). | -| **A3** | **C2 / C4 — a reveal is an apply (S-reveal, interim 2b).** `fr.subscribe((_, parent) => el.contains(parent) && frame.sync(parent))` — a document `$df` into adopted content syncs the frame (2.3, 2.4); a bare `children` mounts at the revealed range (C2 b); a `#`-named occurrence found recordless is a pending read (C2 a2, through A2's hold). Deletes **R.reveal**'s readiness / retry model (`#segmentReady`'s retry loop, the `#revealed` / `#fallbackShown` second set) — the segment swap's DOM half stays (T.morph). DR-4's structural form (2c, the document fragment as a store write) is its own plan and not this step. | **≈ −115 / ≈ −115 / ≈ −115 / 0** (`est.`: R.reveal 467 min ≈ −140 br; the one-liner +58 min / +23 br, measured as `Tglue-reveal` − `L8`) | none | **flip:** C2 (a2, b) + the harness's C2 replay; C4 (d) (the ledger is the store; the drain is one write). Size: frames eager ≤ 12.45. | A1, A2 | a `Frame` sync hook for the document reveal — internal, through the spread-cast options seam `adoptBoundary` already uses (rulings' list) | -| **A4** | **C5 / C6 / C17 residue — S-record, S-ref.** **S-ref:** the codec table answers an undelivered `{$ref}` with a pending promise rejected at `complete` / `:error` (L1 — closes the silent-ref hole, re-attribution §5.3 item 1); a record's refs resolve through the table current at its apply (1.3) — the per-response data cell (1.2, ≈ 140 min, replacing `stageTables`) that A1 left as the C5 condition. **S-record:** the server half — the document sink writes `sc:slot::` as a **declared** pending ref at the marker (as `registerFragment` writes `_fr`) and settles it with the args, so `readHydratedValue`'s `.then` path carries the wait — or the solid write hook on `_$HY.r` (+40 B); either removes the poll's last reason. Deletes **R.refwait** (`#refsUnresolved`, the threaded `resolve`) and the poll half of **R.drain**. | **≈ −15 / ≈ −15 / ≈ −15 / 0** (`est.`: R.refwait 145 min + the poll ≈ 120 min ≈ −75 br; the cell ≈ +45 br + reject-at-complete ≈ +15; decode chunk +≈ 60 B min for pending-on-missing — lazy, not counted) | `decode.js` +≈ 60 B (S-ref) | **flip:** C6 (a1); C5 (a, b, e) if A1 shipped them conditional; C17 (c) confirmed under 1.6 (i). Size: frames eager ≤ 12.45 (±). | A1 (the landing node), A2 (a pending read is a hold) | **server:** the declared slot record (output shape, +≈ 30 B/record) **or solid:** the `_$HY.r` write hook (+40 B) — one of the two, the maintainer's pick (the declared record is recommended: it is A5's shape). | -| **A5** | **C12 (c) client half + `claimRegionFragments` — S-adopted, S-key.** **S-adopted:** `_adoptedRoots: Set` in `hydration.ts`; `fragmentPolicy` swaps an unclaimed fragment after `_hydrationDone` when its `pl-` placeholder is inside an adopted root (`_$HY.fr.adopt(el)` / `unadopt(el)` from `adoptBoundary`, ≈ 30 B frames) — G4 closes and **`claimRegionFragments`** (R.claimant) deletes. **S-key:** `whenRevealed` published on `_$HY.fr` (+≈ 15 B solid) and the SC reference carries its covering fragment key (+≈ 30 B server); `installRevealHook`'s rescan and `boundaryWaiters` (D) collapse into `whenRevealed(key).then(...)`. **C12 (c) client half:** the adopted face shows what the server rendered (A0 withdraws the pin's expectation, 3.3); post-done the swap goes through S-adopted rather than freezing the fallback — the pin's **server half** (the sink's error markup) is A6's draft. | **≈ −65 / ≈ −65 / ≈ −65 / ≈ +5** (`est.`: R.claimant 153 min + `boundaryWaiters` ≈ 110 min + the rescan's rebind ≈ 80 min ≈ −95 br; frames glue ≈ +30 br; solid `_adoptedRoots` + `whenRevealed` ≈ +20 min net of the detached root already paid in A2) | none | **flip:** C12 (c) client arm (shows the server's outcome; swaps post-done); the G4 and G9 timing pins (new: `adopted-swap-post-done.spec`, `boundary-arrival.spec`). Size: frames eager ≤ 12.35; hydrating scenarios +≈ 5 (inside A2's raise). | A2 (the adopted frame's registration is what `fr.adopt` keys off), A3 | **solid:** `_$HY.fr.adopt/unadopt`, `whenRevealed` on `_$HY.fr`. **server:** the fragment key on the SC reference (+30 B of output). | -| **A6** | **Server-half drafts — design, no wire change in this step.** (i) **C13's sweep delimiter** (R7): a multi-record `FrameChunk` member `{ type: "ops", ops: [...] }` the sink emits per sweep and the client applies as one write — the only wire item in the rulings' list, drafted as an RFC 11 addendum with the client's one-write apply (A1's shape already applies a write atomically). (ii) **The plain-response streaming bound** (§6 decision 4): `complete` gains `bound: "yields" \| "time"`; the sink ends a plain response at the bound. (iii) **C12 (c)'s error template**: the document face renders a rejected server ``'s error outcome into the fragment (3.3's server half) instead of the blank. Each is a design note + a `test.fails` pin written against the draft; the server PRs follow the drafts and flip C13 (a, b) and C12 (c)'s server arm — Phase A is taken as done when the drafts are reviewed and those PRs are open. | 0 (design) | — | the three drafts reviewed; pins written (`.fails`). **Phase A gate taken here:** 22 reds green / unrepresentable except the three server-half arms, drafts attached; harness clean on two seeds; frames eager ≈ 12.3 < 13.77. | A1–A5 | **wire (drafted, not shipped):** the `ops` chunk member; `complete.bound`. Decision 4. | -| **B** | **The tier mechanism** (§2): `sink.needs(tier)` at the five mint sites; `X-Frame-Tiers` at first flush; `sc:tiers` record + `modulepreload` links on the document face; `prepareTier(name)` + `installTier`; the **held set is A2's registered set** — a tier's adopt-path hold is one more reason an occurrence is deferred, so it registers under 3.1 from day one. No tier is cut yet — this step is the seam alone, measured. S1's `prepareData` / `prepareArgs` are not in the tree (S1 has not merged); the general seam is built directly and S1 re-bases onto it at C3. | **≈ +100 / ≈ +100 / ≈ +100 / 0** gross (`est.`); server ≈ +300–450 min. S1's two faces (+543 min / +134 br) are never shipped — the ≈ −35 net the earlier draft credited here appears at C3 instead, as "S1 re-based costs less than S1 as built". | none new | `tier-announce.spec`, `tier-prepare.spec` (new); artifacts re-recorded once. Size: frames eager ≤ 12.45. | **the Phase A gate**; A2 (the holds register), A1 (the `landing` node is what an installed tier's `flush()` wakes) | **wire (additive):** `X-Frame-Tiers`, `_$HY.r["sc:tiers"]`, the links. Decision 3. | -| **C1** | **Holes tier** (E.a1; cheapest, buffer-only). `tier-holes.js` = `#applyHole`, `#applyAttrs` (less its owned-position arms, which are bind's), `findLiveTarget`, the hole pass, `pumpLiveChannel` + the op log + `applyLiveOp`. The eager client keeps `chunkToRecords`' `hole` / `attr` cases (records must land in the store before the tier is resident) and a one-line dispatch in `#flush`. Under the **8.0 reading** this step is skipped and holes stay eager (§6 decision 1). | **−546 / −508 / −508 / 0** (measured: `T+holes` → `L8`; page `T+holes` → `L8`; live page the same cut) | `tier-holes.js` ≈ 1,900 min / **≈ 620 br** (`est.`: the 2,116-min cut as its own module + the install glue) | `tier-holes-buffer.spec` (new, §1); C13 control + C18 catch-up arms unchanged; `frames-live-holes-*`, `document-live-*` green through the tier. Size: frames eager ≤ 11.9. | B | none (the record shapes and `sc:live` are unchanged; the hole appliers were never exported) | -| **C2** | **Live wire tier** (E.a2; preload-at-call). `tier-wire.js` = `connections` / `hold` / the join-or-hold arm of `handle`, `resume` + `encodeHaveList` / `FRAME_HAVE_*`, the have-list ledger (`#have` / `have()` / `#recordHave` and the record fields that feed it), `applyFrames`' connection wiring + `connection.cancel`, `isEventStream` + the SSE reader selection, `deserializeStream`'s live arm. The eager client keeps a one-line `LIVE_WIRE` dispatch in `handle` and `bump`'s cancel hook (a no-op without the tier). `live()`'s decorator fires the `onLive` hook (set by frames through `configureServerFunctionsClient`) that calls `prepareTier("wire")` before its first fetch; the arm awaits it. | **−433 / −355 / −355 / 0** (measured: `T+wire` → `L8`; the live page keeps the chunk lazy — its eager measurement drops the same bytes) | `tier-wire.js` ≈ 1,300 min / **≈ 470 br** (`est.`) | `tier-wire-preload.spec` (new); the live suite green; the audit's `live` branch gap (22/61) closed to ≥ 45/61 in the same PR (the tier's own tests). Size: frames eager ≤ 11.5; live page unchanged ±50 (the chunk is reported, not counted). | B; independent of C1 | `ServerFunctionsClientConfig.onLive` (new, internal hook on `configureServerFunctionsClient`); `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` are exported constants today and move to the tier's module — **re-export from the eager entry** to keep the surface, or flag the move | -| **C3** | **Traces tier = S1 re-based** (§5; S1 merges **here**, not first). The materializer entry (`solid-js/internal/container-trace`) and `loadContainers` as S1 built them; S1's `prepareData` / `prepareArgs` / `#argsUnprepared` become B's `prepareTier("trace")` + A2's registered held set; the codec-face node scan stays as the un-announced fallback behind the header flag; the eager half of F.trace (`reviveContainerTraces` / `materialize` / `isContainerTraceMarker` / `isMaterializedContainer` / `setContainerTraceMaterializer` / `getFrameHost.revive`) moves into `container-trace.js`'s `installTier`, leaving a ≈ 150-min trigger. S1's commit 3 re-bases onto A2's park: the `claiming` hint (`revive(value, claiming?)`) and the held-record mount land here, the detached root and the backlog are already on `next`. **`container-trace-hold-hydration-end` re-pins under 3.1 at merge** (A2 is in). The +134 B frames exception S1 as built would have needed **never needs granting**: B's seam is already paid and the tier cut is a saving. | **≈ −250 / ≈ −6,640 / ≈ −6,600 / 0** (S1's measured page savings −6,390 / −6,352 plus F.trace's eager half: 843 attributed, −289 measured as `T+trace` → `L8`, less the trigger ≈ −250; S1's +134 on frames does not recur — its two faces are B's seam) | `container-trace.js` 24.3 KB / **7.86 KB br measured** (S1) + the eager half (≈ +700 min / +200 br → ≈ 8.1 KB br) | S1's seven surviving pins green through the general seam (`frames-container-lazy-{codec,document}`, `hydration/welcome-status-lazy`, `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`); **re-pin** `container-trace-hold-hydration-end` (_hydration waits for the load; the mount claims before done_); **flip** S1's C3 (b); the `.fails` id-drift pin → 3.4 (3c). Size: frames eager ≤ 11.25; page base ≤ 36.0, live ≤ 39.8 (S1's caps 38.45 / 42.12 are superseded by these at landing). | B, A2 (the hold registers; the park is on `next`), A3 | S1's: `revive(value, claiming?)`, `setContainerTraceMaterializer(…, claiming?)`, the entry, `withStoreHydration` / `applyPatches` / `forwardIteratorReturn` `@internal` on the main entry; **not shipped:** S1's `prepareData` / `prepareArgs` (replaced by `prepareTier` before they exist on `next`). `reviveContainerTraces` / `setContainerTraceMaterializer` move behind the tier — **flag**: re-export lazily-resolving wrappers or accept the move | -| **C4** | **Regions tier.** `tier-regions.js` = `#bindRegions` / `#regionsFor` / `#discoverRegions` / `collectRegionElements` / `disposeRegions` / `makeFrameElement` / `isFrameRef`, the `{$frame}` arm of `#resolveArgs`, the `resolveSlot` / `resolveSlotRecord` / `removeSlotRecord` thread-up, `tableFor`'s prefix walk, `drainRecords`' `sc:region:` arm. The eager client keeps the `{$frame}` detection in `#resolveArgs` (one `isFrameRef` test → hold, registered). The rename machinery (`renameRegion` / `#reconcileRegions`, D) deletes outright — it is not moved. | **−489 / ≈ −480 / ≈ −480 / 0** (measured on frames: `T+regions` → `L8`; pages `est.` at the same cut) | `tier-regions.js` ≈ 1,900 min / **≈ 540 br** (`est.`) | `tier-regions-hold.spec` (new); `frames-regions-*`, lifecycle matrix region rows green; principles §4 row 19 (the rename compensations) deleted with D. Size: frames eager ≤ 10.75. | B, A2 (the adopt-path hold registers — no 3.1 gap to flag) | none (`createFrameElement` stays eager — it is `@experimental` public API, re-attribution §5.3 item 5) | -| **C5** | **Assets tier.** `tier-assets.js` = `ensureStylesheet` / `ensurePreload` / `ensureModulePreload` / `applyInlineStyles` / `qualifierValue` / `findHeadElement` / `PRELOAD_QUALIFIERS` / `#processedAssets` / `#styleFlush` / the assets pass; the eager client keeps `chunkToRecords`' `assets` case, `host.write`'s `seg::assets` accumulate, and the `#segmentReady` term. **Pin the two untested functions first** (`ensureStylesheet`, `applyInlineStyles` — the audit's 0-coverage gap) in the same PR. Alternative under decision 2: S10's route-through-`web` instead of a tier. | **−684 / ≈ −665 / ≈ −665 / 0** (measured on frames: `T+assets` → `L8`; the `noassets` full-client cut −665) | `tier-assets.js` ≈ 2,400 min / **≈ 760 br** (`est.`) | `tier-assets-ready.spec` (new, the FOUC guard); `frames-assets-*` green; the two new coverage pins. Size: frames eager ≤ 10.05. | B | none | -| **C6** | **Binding-slot tier** (E.c; largest, last of the tiers — its fallback needs the 3.1 hold for the event-replay window, which A2 provides). `tier-bind.js` = `bindDataOccurrence` (+ `valuesFor` / `write` / `release` / `writeText`; its second diff layer above `assign` — ≈ 300 B, D — deletes rather than moves), `slotPositions` / `slotEntry` / `textPosition` / `consumersOf` / `consumersEqual` / `ownedPositions` / `morphOwnedClass` / `morphOwnedStyle` / `applyOwned`, the `_s:` branch of `collectSlots`, the consumer-rebind arm of `#syncSlots`, the owned-position arms of `morphAttributes` / `reconcileChildren` / `#applyAttrs`, the `ctx.positions` branch of `slotsFor`; **`assign` leaves the eager frames client with it** (the page then keeps `assign` only through `dynamic`'s string tag — B.3, D). | **−1,546 / −2,504 / −2,542 / 0** (frames measured `T+bind` → `L8`; pages: the audit's E.c measurement — `assign` leaves on the page too) | `tier-bind.js` ≈ 5,000 min / **≈ 1,650 br** on frames (`est.`); on a page it carries `assign` as well (≈ +3,000 min / +900 br) unless B.3 has already made it lazy | `tier-bind-hold.spec` (new, incl. the click-replay arm); `frames-binding-slot-*`, `slot-positions-*`, #3704 / #3714 suites green. Size: frames eager ≤ 8.5 (both readings), page base ≤ 32.35, live ≤ 36.1. | B, **A2** (the hold registers; the replay window stays open); C3 (the `installTier` shape proven on the biggest chunk first) | none public (the `_s:` marker grammar is unchanged; `bindDataOccurrence` was never exported) | -| **D** | **Packaging remnants from the SC audit, if still relevant after tiering.** **S2 / C** `preserveModules` for `solid-js` / `@solidjs/web` (0 on single-entry scenarios; the enabler): lets the store **hydration adapters** (≈ 2.6 KB min, the ≈ 1.3 KB br S1 fell short of B.2's floor by) follow the engine into `container-trace.js`, and lets **B.3** (`dynamic`'s string-tag branch lazy, `staticElement` behind the seam) take `assign` off the page. **B.3:** page −2,372 / −2,391 br (audit measured), frames 0. **E.b** (sf natural-encoding bodies, codec-args message, `Retry-After` / trailer parsing lazy): −65 frames / −476 base / −519 live (audit floor). **E.c's other half** is C6. **Lazy codec:** already a chunk (22,986 / 6,074) — nothing to do. **Claims + event** (F.claims, F.event, 331 br): not a frames tier — they ride the router's chunk (the router installs `CLAIM_SEAM`); the frames client keeps the ≈ 60-B seam. | **≈ −400 / ≈ −4,100 / ≈ −4,200 / 0** (`est.`: claims+event −331 frames; B.3 −2,372, the adapters ≈ −1,300, E.b −476 on page base) | `dynamic-static.js` ≈ 8,000 min / ≈ 2.4 KB br; the sf natural-body chunk ≈ 1,600 min / ≈ 480 br; the router's claims chunk ≈ 900 min / ≈ 330 br | the audit's S2 band (single-entry scenarios ≤ ±50 B); B.3's hydration specs; `CLAIM_SEAM` tests with the router. Size: frames eager ≤ 8.1, page base ≤ 28.2, live ≤ 31.8. | C3, C6 (so what leaves with the engine and with `assign` is known) | B.3: `dynamic`'s string-tag branch becomes async-loading on first use (behaviour change accepted in audit §7 Q5 / B.3); the `CLAIM_SEAM` install moves to the router | -| **E** | **Budget restatement — principles §6 as per-tier lines** (§4's table is the draft). One line per eager default (both readings written, one picked), one per tier chunk, the page lines, the ratchet rule unchanged ("a ceiling increase requires a new mechanism row citing its axiom"), `floor-caps.json` gains the tier chunks as reported-not-counted lines with their own caps. | 0 | — | `check-floor-caps` clean on `next` | all | — | +**Phase A gate — taken 2026-10-06 with the second integration PR (#3849, on +`next` after #3837).** _Reds:_ every one of the twenty-two is `test` (green) +or recorded unrepresentable (C18 ×3: under S-flush the occurrence name +decides the class) — the three server-half arms included, since A6 shipped +the server half, not drafts (C13 (a, b), C12 (c2) flipped). The only +`test.fails` left under `test/consistency/` are #3841's fifteen generic +hydration pins (GH1–GH6: C19 ×7, C1 ×2, C9 ×2, E ×1, C3 ×2, C14 ×1 — plain +`hydrate()`, not frames). _Harness:_ SC arm, `CONSISTENCY_FUZZ=1`, 500 +cases, seeds 3289 / 91501, every law un-ignored: **0 / 0**; generic arm +101 / 99 cases with findings, all in GH's four classes, **0 / 0** with +`CONSISTENCY_IGNORE=C1,C9,C19,E`. _Frames eager:_ `next` @ `9d89df731` +13,787 br / 43,414 min → **13,804 br / 42,979 min** (**+17 br / −435 min**) +— the "smaller than today" clause is met on minified and missed by 17 B on +brotli; the ≈ 12,300 target is not approached. The gap, by step as +measured on `next`: A2b **−109** br, A1b + A4 **−134** (the #3844 body's +−653 was a mismeasure; `d9d217959` re-measured at 13,544 br), A7 **+272** +(the outward error face and the `reset` re-ask — 3.3's ruling, a step the +plan had no row for), A3 **−45 min / ±0 br** against ≈ −140 br estimated +(the fallback pass and the style gate stay), A5′ **−51**, A6's client half +**+46** (≈ +99 min). The large item the estimate counted and the pass did +not take: `preview` / `CONTENT_TOKEN` / `stagedContent` (≈ 1,300 B min), +kept as the Transaction's carrier for a same-address refetch (#3844 "Not +deleted"); its pull form is not built. _Hydrating cost:_ +110 br (no +stores) / +142 (stores) / +52 (compiled) over `next` — `hydrateWindow` +(A2b) and `_$HY.fa` (A5′) in solid-js, accepted by the maintainer; the +three caps raised under his Size-Exception in #3849. _Pages:_ base +151 br / +live +120 br with −138 min (over their caps by 143 / 115 B brotli, held by +the gate's minified rule). Frames eager measures 14 B over its 13.79 KB +cap on the same terms; no frames / page cap moved. + +| # | step | Δ br (frames eager / page base / page live / compiled hydrating) | chunks created | gates (pins flip; size) | depends on | surface | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A0** | **C18 — classification waits for the drain** (rulings step 1, 3d / 3.5; **in flight**). The predicate is one term in `adoptBoundary.recordsPending`: a `prop#n` occurrence is classified only after every delivered record has drained. The only page-halting red; lands as the rulings specified it. A1 then makes the mechanism moot (one write per drain; `#` decides the class — C18 becomes unrepresentable) and the pins stay as the assertion of the pending read. | **≈ +15 / +15 / +15 / 0** (the rulings' ≈ +50 min / +15 br; ≈ +25 br with the batched drain, which A1 supersedes) | none | **flip:** C18 ×3. Size: ±0.02 KB on every scenario (frames eager ≤ 13.8). | #3813 landed | none | +| **A1** | **S-flush + the R deletions it unlocks.** `content = createMemo(() => host.landing(binding()))` — one reactive node per bound address resolved at the version's first root / error write (the host's `landing(address)`: a promise for a cold store, the value for a warm one); the enclosing `` pends on it, a switch is a new flight (`_inFlight` supersession, 1.6 (i) by construction), a refetch's landing is staged by the Transaction that read it (G7 closes). Deletes: **R.gate** (`arm`/`release`/`settle`/`setGate`/`mountGate`, the adopted twin), **R.stage** (`stage` / `stageTables` / `stagedContent` / `CONTENT_TOKEN` / `STAGED_DATA` / `FrameImpl#preview` / `#regionsChange` / `host.preview`; the chunk buffer-until-`complete` stays, one write), **R.version** (2a: one applied record keyed by identity; `#appliedRoot`), **R.dedupe** (per-prop memos in `slotArgsProxy`; `argsEquivalent` / `#refArgsUnchanged` / `#slotResolvedRefs` go), **R.error**'s latch. Files: `frames/src/client.ts` (`boundaryComponent`, `adoptBoundary`, `followAddress`), `frame-transport.ts` (`stage*`, `handle`), `frame-client.ts` (`#apply`, `#flush`, `preview`, `#syncSlots`' dedupe arms). | **≈ −1,150 / ≈ −1,150 / ≈ −1,150 / 0** (`est.` from the measured R total −1,863 with every feature kept, scaled to the 4,170 of 6,655 R-min these groups are; S-flush's own glue ≈ +110–190 min / +40 br is inside this) | none | **flip:** C5 (a, b, e) with the per-response data cell (1.2), C6 (b2), C7 (c), C17 (a); **C17 (c) re-pins** to 1.6 (i) `waiting → B`; C6 (b1) inverts (asserts the opposite of A0). Size: frames eager ≤ 12.7 KB (from 13.78), page base ≤ 43.7, live ≤ 47.5. | A0 | **removed / changed:** `ServerComponentHandlerOptions.onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost.preview` / `Frame.preview`, `STAGED_DATA` — the rulings' step-4 list. **New:** `FrameHost.landing(address)` (internal). | +| **A2** | **C3 via `initBoundaryResume` — S-hold** (the rulings' 3a, pulled forward: **this is what lets any later hold register**). `hydrateWindow(id, fn, roots?)` factored out of `resumeBoundaryHydration`; `initBoundaryResume`'s registration reachable from the adopter (`sharedConfig.resumeBoundary`); `adoptBoundary` registers the adopted frame's owner while `#syncSlots` leaves any adopt-time occurrence deferred (the held set — one registration per frame, 3.2) and releases when a sync leaves none or the frame disposes. The #2968 `setTimeout` poll becomes the registration with the drain's end as its bound (3.5); the resumed fill re-enters hydration through the window and claims under the producer's keys (C1 / C9 stay green). **With it, 3e ported onto `next`** (the detached root + the parked backlog beyond the snapshot, 3.6 (iii), without S1's `claiming` plumbing — the rulings' "else ≈ +90 / +25" arm, because S1 no longer lands first and the Phase A gate counts C19), and 3.2's release order (claim → hold release → done → backlog) pinned. Deletes **R.claim** (the range-scoped registry beside `gatherHydratable(el, root)`) and the counter half of **R.drain**. **Landed in two parts** — #3837 (`holdBoundary`) and #3840 (`hydrateWindow` + the R.claim deletion; the park unconditional, rulings 3.6 "Landed") — measured **frames −109 br / hydrating +105 br** against the −130 / +40 estimate; the maintainer accepted the hydrating cost (2026-10-06; caps raised under a Size-Exception at the next integration PR), and **every further solid-side seam (S-adopted next) is to be measured on an edited dist copy before it is written.** | **≈ −130 / ≈ −130 / ≈ −130 / ≈ +40** (`est.`: R.claim ≈ 472 min + R.drain's defer ≈ 270 min ≈ −215 br of cuts; the registration ≈ +100 min frames ≈ +60 br incl. the `hold` option; the 3e port ≈ +90 min / +25 br; solid `hydrateWindow` + the reach ≈ +40–65 min ≈ +12–20 br, the detached root ≈ +20 br) | none | **flip:** C3 (a) + the harness's C3 replay; C19 ×2 (3e); S1's C3 (b) flips at C3 (the traces tier). Size: frames eager ≤ 12.55; **app hydrating / compiled hydrating +≈ 40 br — the first cap raise, the maintainer's** (compiled hydrating is at its cap on this head: 30,943 vs 30.93 KB). | A1 (the deferred set is right only once the drain is one write and the landing node exists) | **solid:** `sharedConfig.resumeBoundary` or an `internal` export of the registration — no new counter, no new done path (3.1 ruled; the draft's `holdHydration` withdrawn); the detached projection root (3e). **frames:** `FrameOptions.hold(): () => void` (internal, wired by `adoptBoundary`). | +| **A3** | **C2 / C4 — a reveal is an apply (S-reveal, interim 2b).** `fr.subscribe((_, parent) => el.contains(parent) && frame.sync(parent))` — a document `$df` into adopted content syncs the frame (2.3, 2.4); a bare `children` mounts at the revealed range (C2 b); a `#`-named occurrence found recordless is a pending read (C2 a2, through A2's hold). Deletes **R.reveal**'s readiness / retry model (`#segmentReady`'s retry loop, the `#revealed` / `#fallbackShown` second set) — the segment swap's DOM half stays (T.morph). DR-4's structural form (2c, the document fragment as a store write) is its own plan and not this step. **Landed** (2026-10-06, `fix/frames-a7-a3-error-throws-reveal-deletion`, with A7): the document-face half was #3837's (the empty write from the reveal cascade); the stream face's own half here — a revealed segment's content is applied as it is revealed, nested segments included (`#revealSegments(root)`), so `#flush` makes one pass and the second ledger (`#revealed` / `#fallbackShown` / `isRevealed`) deletes; the applied state is the content record / fallback gate by identity in the one applied map. **Measured −45 B min / ±0 br** on the frames eager client against A7's head: the fallback pass and the style gate — half the estimate's bytes — stay (F.assets keeps the gate; the pass keeps the reveal-before-fallback order). Pinned: two nested arms in the lifecycle matrix, one inside a pending boundary's detached content (which never revealed before). | **≈ −115 / ≈ −115 / ≈ −115 / 0** (`est.`: R.reveal 467 min ≈ −140 br; the one-liner +58 min / +23 br, measured as `Tglue-reveal` − `L8`) | none | **flip:** C2 (a2, b) + the harness's C2 replay; C4 (d) (the ledger is the store; the drain is one write). Size: frames eager ≤ 12.45. | A1, A2 | a `Frame` sync hook for the document reveal — internal, through the spread-cast options seam `adoptBoundary` already uses (rulings' list) | +| **A4** | **C5 / C6 / C17 residue — S-record, S-ref.** **S-ref:** the codec table answers an undelivered `{$ref}` with a pending promise rejected at `complete` / `:error` (L1 — closes the silent-ref hole, re-attribution §5.3 item 1); a record's refs resolve through the table current at its apply (1.3) — the per-response data cell (1.2, ≈ 140 min, replacing `stageTables`) that A1 left as the C5 condition. **S-record:** the server half — the document sink writes `sc:slot::` as a **declared** pending ref at the marker (as `registerFragment` writes `_fr`) and settles it with the args, so `readHydratedValue`'s `.then` path carries the wait — or the solid write hook on `_$HY.r` (+40 B); either removes the poll's last reason. Deletes **R.refwait** (`#refsUnresolved`, the threaded `resolve`) and the poll half of **R.drain**. | **≈ −15 / ≈ −15 / ≈ −15 / 0** (`est.`: R.refwait 145 min + the poll ≈ 120 min ≈ −75 br; the cell ≈ +45 br + reject-at-complete ≈ +15; decode chunk +≈ 60 B min for pending-on-missing — lazy, not counted) | `decode.js` +≈ 60 B (S-ref) | **flip:** C6 (a1); C5 (a, b, e) if A1 shipped them conditional; C17 (c) confirmed under 1.6 (i). Size: frames eager ≤ 12.45 (±). | A1 (the landing node), A2 (a pending read is a hold) | **server:** the declared slot record (output shape, +≈ 30 B/record) **or solid:** the `_$HY.r` write hook (+40 B) — one of the two, the maintainer's pick (the declared record is recommended: it is A5's shape). | +| **A5** | **C12 (c) client half + `claimRegionFragments` — S-adopted, S-key.** **S-adopted:** `_adoptedRoots: Set` in `hydration.ts`; `fragmentPolicy` swaps an unclaimed fragment after `_hydrationDone` when its `pl-` placeholder is inside an adopted root (`_$HY.fr.adopt(el)` / `unadopt(el)` from `adoptBoundary`, ≈ 30 B frames) — G4 closes and **`claimRegionFragments`** (R.claimant) deletes. **S-key:** `whenRevealed` published on `_$HY.fr` (+≈ 15 B solid) and the SC reference carries its covering fragment key (+≈ 30 B server); `installRevealHook`'s rescan and `boundaryWaiters` (D) collapse into `whenRevealed(key).then(...)`. **C12 (c) client half:** the adopted face shows what the server rendered (A0 withdraws the pin's expectation, 3.3); post-done the swap goes through S-adopted rather than freezing the fallback — the pin's **server half** (the sink's error markup) is A6's draft. **Landed as A5′ (2026-10-06, ruled 12:55: _a placeholder inside a server component's element is the frame's content by rendering, not by adoption_).** Measured before written: A5 as specified came in at **+504 min** solid on hydrating (no stores) (S-adopted +221 / S-key +283), so the shape changed — **(h)** an ownership predicate `_$HY.fa(placeholder)` the ledger's `fragmentPolicy` asks (geometry: the `pl-*` inside a live `data-fid` element), no `_adoptedRoots`, no claim, no replay, `_$HY.fr.claim`/`release` removed; **G9** by collapsing `documentBoundary`'s wait onto the intercept's `awaitBoundary` (`boundaryWaiters` deleted); the exhaustion fix (`fragmentPending` reads a revealed fragment from `_$HY.v` before its `_fr` stamp — the real producer order); a C14 guard (`disposedFrames`, a boundary disposed in place disowns its placeholders). **Measured:** hydrating (no stores) **+50 min / +5 br** (est. ≈ +20 min solid), frames eager **−204 / −51** (est. −135 min / ≈ −65 br; the pre-guard (h)+G9 edit measured −320 — the C14 guard is the ≈ +116 between), page base **−156 / −81**, live **−156 / +3**. **S-key not built** (reachability: the hydrating flow never reaches the late-boundary wait — a client `` twin's resume gates it; the intercept has no key to collapse onto; nested splices need covering-chain semantics; measured frames half −18 min for +283 solid). | **≈ −65 / ≈ −65 / ≈ −65 / ≈ +5** (`est.`: R.claimant 153 min + `boundaryWaiters` ≈ 110 min + the rescan's rebind ≈ 80 min ≈ −95 br; frames glue ≈ +30 br; solid `_adoptedRoots` + `whenRevealed` ≈ +20 min net of the detached root already paid in A2) | none | **flip:** C12 (c) client arm (shows the server's outcome; swaps post-done); the G4 and G9 timing pins (new: `adopted-swap-post-done.spec`, `boundary-arrival.spec`). Size: frames eager ≤ 12.35; hydrating scenarios +≈ 5 (inside A2's raise). | A2 (the adopted frame's registration is what `fr.adopt` keys off), A3 | **solid:** `_$HY.fr.adopt/unadopt`, `whenRevealed` on `_$HY.fr`. **server:** the fragment key on the SC reference (+30 B of output). | +| **A6** | **Server-half drafts — design, no wire change in this step.** (i) **C13's sweep delimiter** (R7): a multi-record `FrameChunk` member `{ type: "ops", ops: [...] }` the sink emits per sweep and the client applies as one write — the only wire item in the rulings' list, drafted as an RFC 11 addendum with the client's one-write apply (A1's shape already applies a write atomically). (ii) **The plain-response streaming bound** (§6 decision 4): `complete` gains `bound: "yields" \| "time"`; the sink ends a plain response at the bound. (iii) **C12 (c)'s error template**: the document face renders a rejected server ``'s error outcome into the fragment (3.3's server half) instead of the blank. Each is a design note + a `test.fails` pin written against the draft; the server PRs follow the drafts and flip C13 (a, b) and C12 (c)'s server arm — Phase A is taken as done when the drafts are reviewed and those PRs are open. | 0 (design) | — | the three drafts reviewed; pins written (`.fails`). **Phase A gate taken here:** 22 reds green / unrepresentable except the three server-half arms, drafts attached; harness clean on two seeds; frames eager ≈ 12.3 < 13.77. | A1–A5 | **wire (drafted, not shipped):** the `ops` chunk member; `complete.bound`. Decision 4. | +| **B** | **The tier mechanism** (§2): `sink.needs(tier)` at the five mint sites; `X-Frame-Tiers` at first flush; `sc:tiers` record + `modulepreload` links on the document face; `prepareTier(name)` + `installTier`; the **held set is A2's registered set** — a tier's adopt-path hold is one more reason an occurrence is deferred, so it registers under 3.1 from day one. No tier is cut yet — this step is the seam alone, measured. S1's `prepareData` / `prepareArgs` are not in the tree (S1 has not merged); the general seam is built directly and S1 re-bases onto it at C3. | **≈ +100 / ≈ +100 / ≈ +100 / 0** gross (`est.`); server ≈ +300–450 min. S1's two faces (+543 min / +134 br) are never shipped — the ≈ −35 net the earlier draft credited here appears at C3 instead, as "S1 re-based costs less than S1 as built". | none new | `tier-announce.spec`, `tier-prepare.spec` (new); artifacts re-recorded once. Size: frames eager ≤ 12.45. | **the Phase A gate**; A2 (the holds register), A1 (the `landing` node is what an installed tier's `flush()` wakes) | **wire (additive):** `X-Frame-Tiers`, `_$HY.r["sc:tiers"]`, the links. Decision 3. | +| **C1** | **Holes tier** (E.a1; cheapest, buffer-only). `tier-holes.js` = `#applyHole`, `#applyAttrs` (less its owned-position arms, which are bind's), `findLiveTarget`, the hole pass, `pumpLiveChannel` + the op log + `applyLiveOp`. The eager client keeps `chunkToRecords`' `hole` / `attr` cases (records must land in the store before the tier is resident) and a one-line dispatch in `#flush`. Under the **8.0 reading** this step is skipped and holes stay eager (§6 decision 1). | **−546 / −508 / −508 / 0** (measured: `T+holes` → `L8`; page `T+holes` → `L8`; live page the same cut) | `tier-holes.js` ≈ 1,900 min / **≈ 620 br** (`est.`: the 2,116-min cut as its own module + the install glue) | `tier-holes-buffer.spec` (new, §1); C13 control + C18 catch-up arms unchanged; `frames-live-holes-*`, `document-live-*` green through the tier. Size: frames eager ≤ 11.9. | B | none (the record shapes and `sc:live` are unchanged; the hole appliers were never exported) | +| **C2** | **Live wire tier** (E.a2; preload-at-call). `tier-wire.js` = `connections` / `hold` / the join-or-hold arm of `handle`, `resume` + `encodeHaveList` / `FRAME_HAVE_*`, the have-list ledger (`#have` / `have()` / `#recordHave` and the record fields that feed it), `applyFrames`' connection wiring + `connection.cancel`, `isEventStream` + the SSE reader selection, `deserializeStream`'s live arm. The eager client keeps a one-line `LIVE_WIRE` dispatch in `handle` and `bump`'s cancel hook (a no-op without the tier). `live()`'s decorator fires the `onLive` hook (set by frames through `configureServerFunctionsClient`) that calls `prepareTier("wire")` before its first fetch; the arm awaits it. | **−433 / −355 / −355 / 0** (measured: `T+wire` → `L8`; the live page keeps the chunk lazy — its eager measurement drops the same bytes) | `tier-wire.js` ≈ 1,300 min / **≈ 470 br** (`est.`) | `tier-wire-preload.spec` (new); the live suite green; the audit's `live` branch gap (22/61) closed to ≥ 45/61 in the same PR (the tier's own tests). Size: frames eager ≤ 11.5; live page unchanged ±50 (the chunk is reported, not counted). | B; independent of C1 | `ServerFunctionsClientConfig.onLive` (new, internal hook on `configureServerFunctionsClient`); `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` are exported constants today and move to the tier's module — **re-export from the eager entry** to keep the surface, or flag the move | +| **C3** | **Traces tier = S1 re-based** (§5; S1 merges **here**, not first). The materializer entry (`solid-js/internal/container-trace`) and `loadContainers` as S1 built them; S1's `prepareData` / `prepareArgs` / `#argsUnprepared` become B's `prepareTier("trace")` + A2's registered held set; the codec-face node scan stays as the un-announced fallback behind the header flag; the eager half of F.trace (`reviveContainerTraces` / `materialize` / `isContainerTraceMarker` / `isMaterializedContainer` / `setContainerTraceMaterializer` / `getFrameHost.revive`) moves into `container-trace.js`'s `installTier`, leaving a ≈ 150-min trigger. S1's commit 3 re-bases onto A2's park: the `claiming` hint (`revive(value, claiming?)`) and the held-record mount land here, the detached root and the backlog are already on `next`. **`container-trace-hold-hydration-end` re-pins under 3.1 at merge** (A2 is in). The +134 B frames exception S1 as built would have needed **never needs granting**: B's seam is already paid and the tier cut is a saving. | **≈ −250 / ≈ −6,640 / ≈ −6,600 / 0** (S1's measured page savings −6,390 / −6,352 plus F.trace's eager half: 843 attributed, −289 measured as `T+trace` → `L8`, less the trigger ≈ −250; S1's +134 on frames does not recur — its two faces are B's seam) | `container-trace.js` 24.3 KB / **7.86 KB br measured** (S1) + the eager half (≈ +700 min / +200 br → ≈ 8.1 KB br) | S1's seven surviving pins green through the general seam (`frames-container-lazy-{codec,document}`, `hydration/welcome-status-lazy`, `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`); **re-pin** `container-trace-hold-hydration-end` (_hydration waits for the load; the mount claims before done_); **flip** S1's C3 (b); the `.fails` id-drift pin → 3.4 (3c). Size: frames eager ≤ 11.25; page base ≤ 36.0, live ≤ 39.8 (S1's caps 38.45 / 42.12 are superseded by these at landing). | B, A2 (the hold registers; the park is on `next`), A3 | S1's: `revive(value, claiming?)`, `setContainerTraceMaterializer(…, claiming?)`, the entry, `withStoreHydration` / `applyPatches` / `forwardIteratorReturn` `@internal` on the main entry; **not shipped:** S1's `prepareData` / `prepareArgs` (replaced by `prepareTier` before they exist on `next`). `reviveContainerTraces` / `setContainerTraceMaterializer` move behind the tier — **flag**: re-export lazily-resolving wrappers or accept the move | +| **C4** | **Regions tier.** `tier-regions.js` = `#bindRegions` / `#regionsFor` / `#discoverRegions` / `collectRegionElements` / `disposeRegions` / `makeFrameElement` / `isFrameRef`, the `{$frame}` arm of `#resolveArgs`, the `resolveSlot` / `resolveSlotRecord` / `removeSlotRecord` thread-up, `tableFor`'s prefix walk, `drainRecords`' `sc:region:` arm. The eager client keeps the `{$frame}` detection in `#resolveArgs` (one `isFrameRef` test → hold, registered). The rename machinery (`renameRegion` / `#reconcileRegions`, D) deletes outright — it is not moved. | **−489 / ≈ −480 / ≈ −480 / 0** (measured on frames: `T+regions` → `L8`; pages `est.` at the same cut) | `tier-regions.js` ≈ 1,900 min / **≈ 540 br** (`est.`) | `tier-regions-hold.spec` (new); `frames-regions-*`, lifecycle matrix region rows green; principles §4 row 19 (the rename compensations) deleted with D. Size: frames eager ≤ 10.75. | B, A2 (the adopt-path hold registers — no 3.1 gap to flag) | none (`createFrameElement` stays eager — it is `@experimental` public API, re-attribution §5.3 item 5) | +| **C5** | **Assets tier.** `tier-assets.js` = `ensureStylesheet` / `ensurePreload` / `ensureModulePreload` / `applyInlineStyles` / `qualifierValue` / `findHeadElement` / `PRELOAD_QUALIFIERS` / `#processedAssets` / `#styleFlush` / the assets pass; the eager client keeps `chunkToRecords`' `assets` case, `host.write`'s `seg::assets` accumulate, and the `#segmentReady` term. **Pin the two untested functions first** (`ensureStylesheet`, `applyInlineStyles` — the audit's 0-coverage gap) in the same PR. Alternative under decision 2: S10's route-through-`web` instead of a tier. | **−684 / ≈ −665 / ≈ −665 / 0** (measured on frames: `T+assets` → `L8`; the `noassets` full-client cut −665) | `tier-assets.js` ≈ 2,400 min / **≈ 760 br** (`est.`) | `tier-assets-ready.spec` (new, the FOUC guard); `frames-assets-*` green; the two new coverage pins. Size: frames eager ≤ 10.05. | B | none | +| **C6** | **Binding-slot tier** (E.c; largest, last of the tiers — its fallback needs the 3.1 hold for the event-replay window, which A2 provides). `tier-bind.js` = `bindDataOccurrence` (+ `valuesFor` / `write` / `release` / `writeText`; its second diff layer above `assign` — ≈ 300 B, D — deletes rather than moves), `slotPositions` / `slotEntry` / `textPosition` / `consumersOf` / `consumersEqual` / `ownedPositions` / `morphOwnedClass` / `morphOwnedStyle` / `applyOwned`, the `_s:` branch of `collectSlots`, the consumer-rebind arm of `#syncSlots`, the owned-position arms of `morphAttributes` / `reconcileChildren` / `#applyAttrs`, the `ctx.positions` branch of `slotsFor`; **`assign` leaves the eager frames client with it** (the page then keeps `assign` only through `dynamic`'s string tag — B.3, D). | **−1,546 / −2,504 / −2,542 / 0** (frames measured `T+bind` → `L8`; pages: the audit's E.c measurement — `assign` leaves on the page too) | `tier-bind.js` ≈ 5,000 min / **≈ 1,650 br** on frames (`est.`); on a page it carries `assign` as well (≈ +3,000 min / +900 br) unless B.3 has already made it lazy | `tier-bind-hold.spec` (new, incl. the click-replay arm); `frames-binding-slot-*`, `slot-positions-*`, #3704 / #3714 suites green. Size: frames eager ≤ 8.5 (both readings), page base ≤ 32.35, live ≤ 36.1. | B, **A2** (the hold registers; the replay window stays open); C3 (the `installTier` shape proven on the biggest chunk first) | none public (the `_s:` marker grammar is unchanged; `bindDataOccurrence` was never exported) | +| **D** | **Packaging remnants from the SC audit, if still relevant after tiering.** **S2 / C** `preserveModules` for `solid-js` / `@solidjs/web` (0 on single-entry scenarios; the enabler): lets the store **hydration adapters** (≈ 2.6 KB min, the ≈ 1.3 KB br S1 fell short of B.2's floor by) follow the engine into `container-trace.js`, and lets **B.3** (`dynamic`'s string-tag branch lazy, `staticElement` behind the seam) take `assign` off the page. **B.3:** page −2,372 / −2,391 br (audit measured), frames 0. **E.b** (sf natural-encoding bodies, codec-args message, `Retry-After` / trailer parsing lazy): −65 frames / −476 base / −519 live (audit floor). **E.c's other half** is C6. **Lazy codec:** already a chunk (22,986 / 6,074) — nothing to do. **Claims + event** (F.claims, F.event, 331 br): not a frames tier — they ride the router's chunk (the router installs `CLAIM_SEAM`); the frames client keeps the ≈ 60-B seam. | **≈ −400 / ≈ −4,100 / ≈ −4,200 / 0** (`est.`: claims+event −331 frames; B.3 −2,372, the adapters ≈ −1,300, E.b −476 on page base) | `dynamic-static.js` ≈ 8,000 min / ≈ 2.4 KB br; the sf natural-body chunk ≈ 1,600 min / ≈ 480 br; the router's claims chunk ≈ 900 min / ≈ 330 br | the audit's S2 band (single-entry scenarios ≤ ±50 B); B.3's hydration specs; `CLAIM_SEAM` tests with the router. Size: frames eager ≤ 8.1, page base ≤ 28.2, live ≤ 31.8. | C3, C6 (so what leaves with the engine and with `assign` is known) | B.3: `dynamic`'s string-tag branch becomes async-loading on first use (behaviour change accepted in audit §7 Q5 / B.3); the `CLAIM_SEAM` install moves to the router | +| **E** | **Budget restatement — principles §6 as per-tier lines** (§4's table is the draft). One line per eager default (both readings written, one picked), one per tier chunk, the page lines, the ratchet rule unchanged ("a ceiling increase requires a new mechanism row citing its axiom"), `floor-caps.json` gains the tier chunks as reported-not-counted lines with their own caps. | 0 | — | `check-floor-caps` clean on `next` | all | — | **Dependency check under the new order.** S-hold (A2) now precedes everything that holds: B's held set is A2's registered set; C3's, C4's and @@ -404,7 +434,7 @@ record. with the router at the Phase D end state is ≈ **39.7 KB (8.0)**. The 30 KB target is stated against page base WITHOUT the router; with it the gap is the router's. Scenarios `page: base + router` / `page: live + - router` are on draft #3838 (57,001 / 58,243 measured today). +router` are on draft #3838 (57,001 / 58,243 measured today). - **Under 30 KB?** Page base: **yes on both readings**; the margin rests on B.3 — without it 8.0 is ≈ 31.1 KB, over. This is the measurement §6 decision 1's ruling (8.0, holes eager) was conditioned on. @@ -518,7 +548,8 @@ Each a yes/no with a recommendation. needed. The cost is deferring the −6.4 KB page saving until Phases A and B are in. **Ruled 2026-10-06: yes** — S1 merges re-based at C3, after Phases A - and B. + and B. **Accepted for now; review after the first size pass** + (maintainer, 2026-10-06). 6. **The adopt-path holds (traces, regions, bind) register as pending boundaries through `initBoundaryResume` (3.1 participants), with the solid-side reach (`sharedConfig.resumeBoundary`, `hydrateWindow`) paid by diff --git a/documentation/server-components/frame-streams-rfc.md b/documentation/server-components/frame-streams-rfc.md index e64eaba83..a512a3960 100644 --- a/documentation/server-components/frame-streams-rfc.md +++ b/documentation/server-components/frame-streams-rfc.md @@ -449,6 +449,93 @@ frame, and that slot/slot chunks follow the same rule. > currently only set flag keys — the readiness model re-derives everything from > the store each flush, so there is nothing to "replay." +### Addenda (2026-10-06 — the frames correctness pass, server half) + +Additive members; a producer may omit them and a consumer that predates them +reads the stream it always read (what an old consumer does with each is +stated). + +**`ops` — one sweep, one unit (C13, frames-rulings §"The server half").** + +```ts +| { + type: "ops"; + id: string; + version: number; + ops: ( + | { type: "hole"; key: string; html: string; digest?: string } + | { type: "attr"; key: string; attrs: string; removed?: string[]; digest?: string } + )[]; + } +``` + +The server's commit unit is the sweep: one pass over every open binding, +coalesced per microtask. Before this member the wire carried a sweep's +re-emissions as N independent `hole` / `attr` chunks with no edge between +them, and the consumer — whose unit of application is the chunk — landed +them one flush apart, so a listener (a `frame:applied` handler, a +`MutationObserver`) could observe one hole of a sweep moved while a sibling +of the same sweep still showed the previous value (contract R7). The `ops` +member is the sweep's edge on the wire: the producer collects the pass's +`hole` / `attr` emissions and ships them as ONE chunk (stream face — one +wire line) or ONE `sc:live` op of the same shape (document face — the op +carries no `id` / `version`, as no document op does). Members ride +unaddressed; the envelope addresses them. A pass that changed one binding +emits that member alone, exactly as before. The consumer maps the unit to +one record map (`chunkToRecords` merges the members') and applies it as one +write — one hole pass, one `frame:applied`. Nothing is buffered, nothing is +correlated, nothing times out: a connection that dies mid-sweep dies before +the unit was written, and the unit is never half-delivered. + +_Old consumer:_ `chunkToRecords` answers an unknown `type` with an empty +record map (its `default` arm), so the write lands nothing and the frame's +flush is a no-op — the sweep's values are **lost on that consumer** until a +later sweep that changes one binding at a time re-ships them (each as a +plain member), or a reconnect / refetch re-ships the root. The old consumer +does not crash and does not tear; it under-updates. The frames surface is an +experimental preview (RFC 11's status note): the member is taken as +additive on the producer and the consumer ships with it in the same +release. + +**`complete.bound` — the plain response's streaming bound (savings pass §6 +decision 4, ruled 2026-10-06).** + +```ts +| { type: "complete"; id: string; version: number; bound?: "yields" | "time" } +``` + +A plain (non-`live`) server component whose content reads a standing source +— a generator memo, a projection over an async iterable — keeps its +response open and ships each later commit as holes, with no declaration of +liveness anywhere; its only end was "the source settles", which for a +source that never returns is never. The producer now ends such a response +at a bound and says so: `bound: "yields"` after `maxYields` emitting sweeps +past the first flush (default 64; a sweep that emits nothing — the source +repeating a value — is not a yield), `bound: "time"` `maxDurationMs` after +the first flush (default 30 000) **or when the request's `signal` aborts +after the first flush** (a platform deadline is a time bound the client can +tell from a death). The sink's end-of-response latch runs as for any +completion (the last sweep's values ship before the `complete`), the body +closes, and the render is torn down quietly (sources returned, holds +released; no abandonment finding — the response chose to end). Both +defaults are options on `FrameStreamOptions` (`maxYields`, +`maxDurationMs`); `0` / `Infinity` disable one. A `live` response is never +bounded: liveness IS the declaration that there is no bound, and `live()` +is the documented way past it. A `complete` with no `bound` means what it +always meant. A body that ends without any `complete` stays what it is: the +open frame's `:error` (undeclared death). + +_Consumer:_ `chunkToRecords` stores `:bound` beside `:complete`; the frame +lands as on any `complete` (the covering boundary releases, `landing` +resolves), and a consumer that cares can tell a cut-off from a settled +value by the key. In dev the host warns once per response, naming `live()`. +Not surfaced as an error: the content shown is the server's last value, +which is what the frame says it is. + +_Old consumer:_ reads `complete` as before (the extra field is ignored by +its `chunkToRecords`); it sees a completed frame and never learns it was a +cut-off. Degrades to today's behaviour minus the (new) distinction. + ### Two identity schemes The format uses two deliberately distinct identity schemes: diff --git a/documentation/server-components/frames-consistency-contract.md b/documentation/server-components/frames-consistency-contract.md index 49de0dfdf..a31510091 100644 --- a/documentation/server-components/frames-consistency-contract.md +++ b/documentation/server-components/frames-consistency-contract.md @@ -69,16 +69,22 @@ range) or removed by a deliberate replacement — never claimed by two passes, never left in the document beside a fresh clone of itself; a boundary element is adopted by at most one frame. -- **Mechanism:** `frames/src/client.ts:claimRender` (a range-scoped registry - handed over from the root registry), `client.ts:slotsFor.settle` (the - in-place check that turns a render into a claim), `frame-client.ts:FrameImpl.#replaceRange`, - `client.ts:adoptBoundary` + `claimedBoundaries` (one adopter per element), - `client.ts:documentBoundary` (a second mount goes fresh). +- **Mechanism:** `frames/src/client.ts:claimRender` (the claim window — + `sharedConfig.hydrateWindow`, the re-entry a streamed boundary's resume + takes: the range's keys gathered by the producer prefix into the registry + of the root the frame adopted under; A2b replaced the range-scoped + registry handed over from the root registry), `client.ts:slotsFor.settle` + (the in-place check that turns a render into a claim), + `frame-client.ts:FrameImpl.#replaceRange`, `client.ts:adoptBoundary` + + `claimedBoundaries` (one adopter per element), `client.ts:documentBoundary` + (a second mount goes fresh). - **Pin:** `c01-claim-once.spec.tsx` — arms: (a) two occurrences claim once each with no key miss and node identity preserved; (b) a fill that returns fresh nodes replaces, leaving no server node of the range behind; (c) a second mount of the same function while the first adopted mounts fresh and - the adopted element is untouched. + the adopted element is untouched. `c01-claim-window-roots.spec.tsx` (A2b): + a claim the frame makes after another `hydrate()` root replaced the live + registry/gather pair gathers against the root it adopted under (#2917). - **Verdict:** **holds on `next`** (3/3); the harness's C1 laws (key miss, unclaimed, duplicate, node identity) fired in none of 1000 cases. @@ -92,7 +98,10 @@ live fill behind it. - **Mechanism:** `frame-client.ts:FrameImpl.#syncSlots` (range discovery over the frame's content), `client.ts:adoptBoundary`'s `fr.subscribe` cascade - (`claimRegionFragments` + `drainRecords`, #2978/#2968), `#recordRefresh`. + (`drainRecords` + the reveal-is-an-apply write, #2968 / rulings 2.3; the + swap itself needs no claim from the adoption — a placeholder inside a + `data-fid` element is the frame's content by rendering, `_$HY.fa`, A5′), + `#recordRefresh`. A re-sync after a reveal happens only when the reveal brings a _new_ record (`drainRecords` → `host.apply` → `#flush` → `#syncSlots`); nothing re-syncs on the reveal itself. @@ -254,16 +263,25 @@ or after a fragment reveal. ### C11 — a trace materializes to one value, equal to its oracle -A materialized container trace reads, at every observable point, as the -direct materialization of the same snapshot and patch prefix would — -not-ready before the snapshot, then the snapshot with every patch applied so -far — and its value is independent of how the data was split and timed; one -trace materializes to one store however many readers revive it. +A materialized container trace reads, at every observable point **outside a +claim's park**, as the direct materialization of the same snapshot and patch +prefix would — not-ready before the snapshot, then the snapshot with every +patch applied so far — and its value is independent of how the data was +split and timed; one trace materializes to one store however many readers +revive it. _Outside a claim's park_ (frames-rulings 3.6 (iii), amended with +the 3e port): a backlog replayed at materialization — patches delivered +before the fill that reads the store claimed its markup — is parked beyond +the snapshot until hydration ends (the next microtask when no hydration is +in progress), so while the park holds the store reads the snapshot although +its oracle has the patch; the park releases after the frame's hold (3.2), so +a settle point under another occurrence's hold can fall inside it. Every +settle point after hydration-done is outside it. - **Mechanism:** `solid/hydration.ts:materializeContainerTrace` (sync `.on()` replay into a queue the projection drains; version bump per live - emission), `frame-container-plugin.ts:materialize` (WeakMap memo per - stream), `reviveContainerTraces`, `ContainerTracePlugin.deserialize`. + emission; the backlog beyond the snapshot parked under `limit` until + `onHydrationEnd`), `frame-container-plugin.ts:materialize` (WeakMap memo + per stream), `reviveContainerTraces`, `ContainerTracePlugin.deserialize`. - **Pin:** `c11-trace-equals-oracle.spec.tsx` — arms: (a) snapshot before revival, patches after; (b) revival before the snapshot (not-ready, then equal); (c) 1 batch vs N batches vs random partitions give equal prefixes @@ -281,8 +299,11 @@ one — and changes only when the document (or a stream) delivers. - **Mechanism:** `solid/hydration.ts:hydratedCreateLoadingBoundary` (`_fr` states: pending / settled / parked / superseded / rejected), `fragmentPolicy` - (held swaps), `client.ts:adoptBoundary.claimRegionFragments` (#2978: the - adoption claims server-produced placeholders so a late swap lands). + (held swaps) with its ownership-by-rendering term (`_$HY.fa`, installed by + `client.ts:installRevealHook` — #2978: a server-produced placeholder inside + a live `data-fid` element is the frame's content, so a late swap lands with + or without an adoption on record; rulings 3.3, A5′), `adoptBoundary`'s + dev-only rejection report over the region's `pl-*` templates. - **Pin:** `c12-boundary-parity.spec.tsx` — a server `` inside the adopted frame: (a) pending at adopt (fallback shows, no fetch, ledger pending); (b) revealed after adopt (content replaces the fallback in one @@ -321,9 +342,11 @@ reveal touches the DOM or invokes a fill. - **Mechanism:** `frame-client.ts:FrameImpl.dispose` (unregister first, cleanups, record hygiene, `#recordRefresh` cleared), `createFrameHost.unregister`, - `client.ts:adoptBoundary`'s `onCleanup` (applier, `fr` unsubscribe, fragment - claims released), `client.ts:documentBoundary`'s `boundaryWaiters` cleanup, - `client.ts:followAddress.drop`. + `client.ts:adoptBoundary`'s `onCleanup` (applier, `fr` unsubscribe, the + element and its region elements entered in `disposedFrames` so `_$HY.fa` + disowns their placeholders — a boundary disposed _in place_ keeps its + element in the document), `client.ts:documentBoundary`'s `live` latch over + the shared arrival wait, `client.ts:followAddress.drop`. - **Pin:** `c14-dispose-clean.spec.tsx` — arms: (a) dispose during the record defer (`readyState` "loading"), the record lands after; (b) during a `{$ref}` wait on a stream, the data lands after; (c) during a late-boundary @@ -414,7 +437,7 @@ address's late chunks never release it. | C9 | no phantom | `claimRender`, `slotArgsProxy`, settled-branch hydration | `c09-no-phantom` | holds | | C10 | ids timing-independent | `claimRender` owner id, `#invokeSlot` ctx | `c10-ids-timing-independent` | holds | | C11 | trace equals oracle | `materializeContainerTrace`, `materialize` memo | `c11-trace-equals-oracle` | holds | -| C12 | boundary parity at claim | `hydratedCreateLoadingBoundary`, `claimRegionFragments` | `c12-boundary-parity` | **red** (c) | +| C12 | boundary parity at claim | `hydratedCreateLoadingBoundary`, `fragmentPolicy` + `_$HY.fa` | `c12-boundary-parity` | **red** (c2) | | C13 | one sweep, one frame | `applyFrames.drain`, `#flush` hole pass | `c13-sweep-atomic` | **red** (a, b) | | C14 | disposal leaves nothing | `dispose`, `unregister`, adopt cleanups | `c14-dispose-clean` | holds | | C15 | staged refetch lands whole | `stage`/`stagedContent`, `followAddress` | `c15-staging-atomic` | holds | @@ -450,13 +473,26 @@ read — may evaluate a render prop as a zero-arg accessor. A fill claiming server-rendered text shows, after the claim, the value its first read produced: when a container trace's patches landed before the claim, the DOM shows the patched value, not the snapshot the server rendered. +Under frames-rulings 3.6 (iii) the sentence is carried the other way round — +the first read IS the snapshot (what the markup was rendered from), the claim +keeps it, and the patches land after the claim as the update they are — so +what the settled DOM shows is still the value the fill read, patched. - **Mechanism:** `web/src/client.ts:insertExpression` (a hydrating render is a claim pass, not a mutation pass — by design), `materializeContainerTrace` - (replays snapshot + patches synchronously at revive, so the first read is - already the patched value), `claimRender`. -- **Pin:** `harness/replay.spec.tsx` C19 ×2 (`test.fails`) + control. -- **Verdict:** **red on `next`**, **green on S1** (§S1 delta). See §Red R10. + (replays snapshot + patches synchronously at revive and parks the patches + beyond the snapshot until hydration ends — 3.6 (iii), the 3e port; the + park is unconditional until S1's `claiming` hint lands at C3, 3.6 + "Landed"), `claimRender`. +- **Pin:** `harness/replay.spec.tsx` C19 ×2 + control; + `c19-claim-reads-snapshot.spec.tsx` — arms: (a) the t=0 claim with a trace + past the markup, (b) the deferred claim under the frame's hold, (c) the + release order (claim → hold release → done → backlog, rulings 3.2), + (d) a claim after hydration-done (a fragment's reveal), (e) the C11 + consequence (the store reads the snapshot inside the park), (f) id + determinism (the materializer's detached root). +- **Verdict:** was **red on `next`** (§Red R10); **green with the 3e port** + (`wip/frames-pass-integration`, A2b) — the harness clean on both seeds. ## Red on `next` @@ -728,13 +764,27 @@ the claimed text equals the value read. **Where it goes wrong.** A hydrating `insertExpression` is a claim pass — "not a mutation pass" — by design; the trace model assumes the server text IS the store's first value, which holds only if no patch precedes the claim. On S1 (`9927ddddd`, "a held -container-trace fill hydrates like a resident one") the shape is green: the -held fill's claim runs under a path that reconciles the text with the live -value (the same path that produces C3(b)'s red there). **Severity:** stale -value shown after hydration with no diagnostic; self-heals on the next -distinct patch (medium). **Should have been caught by:** `c11-trace-equals- -oracle` (d) — it patches only after the claim; no hydration test lets a -container trace move between SSR and claim. +container-trace fill hydrates like a resident one") the shape is green — not +because the claim reconciles the text (it never does; `9927ddddd`'s own +comment: "a text hole is never rewritten during a claim") but because the +materializer, told it is read for a claim, serves the snapshot and PARKS the +backlog until hydration ends; the DOM catches up after the claim. S1 is +evidence for frames-rulings 3.6 (iii), the consumer parks — not for (i), the +claim pass reconciling. **Fixed** by the 3e port (A2b on +`wip/frames-pass-integration`, #3840): `materializeContainerTrace` parks every +replayed backlog beyond the snapshot until `onHydrationEnd` (a microtask +when none is in progress), and roots its projection detached. The park is +**unconditional** (maintainer, 2026-10-06 — frames-rulings 3.6 "Landed"): +keying it on hydration being in progress at materialize time left post-done +claims (a fragment revealed after done, a record owed past done — corollary +4) red, because no hydration state says "claim" at that moment; the port +carries no `claiming` hint, so a fresh mount pays one beat instead. S1's +`revive(value, claiming?)` hint arrives at plan step C3 and keys the park on +the claim again then. +**Severity:** stale value shown after hydration with no diagnostic; +self-heals on the next distinct patch (medium). **Should have been caught +by:** `c11-trace-equals-oracle` (d) — it patches only after the claim; no +hydration test lets a container trace move between SSR and claim. ## Harness @@ -763,6 +813,17 @@ Campaigns on `next` (`1f8b2caf4`): | 91501 | 500 | — | 327 | C3 268, C19 68, C18 55, C2 26+25 | | 91501 | 500 | C3, C18, C19, C2 | **0** | nothing else surfaces | +On `wip/frames-pass-integration` with the A2b port (S-flush, the C3 hold, +C5, C12 (c), the 3e park): seeds 3289 and 91501, 500 cases, **0 with +findings**, every law un-ignored. The oracle's one amendment for it: the +settled trace law (C11 / C19) exempts a settle point INSIDE a claim's park — +a fill that claimed with patches already delivered, hydration still in +progress (another occurrence's hold), the text at the snapshot — per C11's +"outside a claim's park"; the end is always outside it (hydration done) and +strict. Checked against the branch WITHOUT the park: the amendment hides 2 +(3289) / 3 (91501) of the 83 / 79 C19 cases — those a later distinct patch +heals before the end — and leaves the rest (81 / 76) red. + Shrink mode (seed 3289, ignore C3) reduces to `[item#0 item#1 children] :: H R1 R0` → C18 on the first failing case. Replay pins (`harness/replay.spec.tsx`): C18 ×3 (two records drained after @@ -797,9 +858,10 @@ campaign). Versus `next` (61 passed, 22 expected-fail, 1 skipped): R1 (a hold hydration does not count). - **Newly green:** C19 ×2 — the `test.fails` pins pass on S1: a trace patch before the claim IS shown (`R0 T H` runs with no finding at all, node - identity included; `H T R0` shows the oracle and only C3 fires). The held - container-trace fill of `9927ddddd` claims through a path that reconciles - the text with the live value. + identity included; `H T R0` shows the oracle and only C3 fires). The + mechanism is `9927ddddd`'s park (the materializer serves the snapshot to + the claim and applies the backlog at hydration end), not a reconciling + claim — see R10's correction. - Everything else identical to `next` (every other pin and expected-fail agrees; the codec warm-up probe chunk keeps C5/C6 portable). @@ -812,10 +874,13 @@ Hydration-core (`packages/solid/src/client/hydration.ts`, `web/src/client.ts`): newly-red C3(b). 2. **R6/C12** — give a rejected server `` fragment a consumer (error fallback + surfaced rejection) instead of the blank swap. -3. **R10/C19** — decide: either the claim pass reconciles a text hole whose - value already differs (narrow, trace-only), or the trace model forbids - patches before the claim (the producer holds them until the record's - claim) — S1's held-fill path shows the former is reachable. +3. **R10/C19** — decided (frames-rulings 3.6 (iii), the consumer parks): the + materializer serves the snapshot to the claim and parks the backlog until + hydration ends; the claim pass stays non-mutating. (The alternatives were + (i) the claim pass reconciling a text hole whose value already differs, + and (ii) the producer holding patches until the record's claim; S1's + held-fill path is the park, (iii), not evidence for (i).) Landed as the + 3e port on `wip/frames-pass-integration`. Frames-client (`packages/web/frames/src/`): @@ -832,3 +897,283 @@ Frames-client (`packages/web/frames/src/`): gate. 7. **R7/C13** — needs a wire sweep delimiter (producer + transport), per the rulings draft; client-only work cannot carry it. + +## Generic hydration — classification and pins (2026-10-06) + +Branch `test/hydration-consistency-generic` off `wip/frames-pass-integration` +@ `00dbc8663` (#3837). The question: are the reds above frames-only, or are +some of them plain Solid 2 hydration holes that a page with no server +component would hit? Method: classify each invariant and red, then drive +the generic twin of every candidate through a **frames-free** page — +`hydrate()` over a `renderToStream` document with two sibling streamed +`` boundaries (`packages/web/test/harness/generic-hydration.tsx`, +artifacts rendered by `test/server/generic-hydration.gen.spec.tsx`), by +hand (`test/consistency/generic/*.spec.tsx`) and under a property harness +(`test/consistency/generic/{scenario,run,campaign}`, opt-in behind the same +`CONSISTENCY_FUZZ` knobs as §Harness). **Nothing here changes an engine.** + +**Answer: yes — six generic reds, four of them one class.** GH1–GH3 are the +plain-Solid form of R10/C19 (a claim pass that reads a value the markup was +not rendered from and does not reconcile the text); GH4 is the plain form +of the frames pass's "unrevealed boundary with `STATUS_PENDING` shows +fallback"; GH5/GH6 are the plain form of R1/C3 and C14 for a hold the +hydration runtime does not count — the root module preload. Everything else +in C1–C19 is either frames-only or holds on plain pages (1000 harness cases, +two seeds, no finding outside the six). + +### Classification + +Key: **SC-only** — needs frames/slots/records to express; **generic-restated** +— the SC case is an instance of a plain hydration rule (§3 _n_ cites +`documentation/plans/solid-web-size-audit.md` §3) that could break without +frames; **generic-suspect** — shared mechanism, nothing in the plain suite +pinned it before this pass. "Plain verdict" is what the generic pins and +harness found. + +| # | class | plain rule (§3) / mechanism | plain verdict | +| --- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| C1 | generic-restated | §3 1–2, 8 — `getNextElement` claim-by-key, the claim pass never mutates (`client.ts:insertExpression`) | holds; key misses only as consequences of GH3/GH4 | +| C2 | generic-restated | §3 70, 76 — `resumeBoundaryHydration` is driven by the `_fr` settle + `whenRevealed`, not by the DOM reveal | holds (every range live and reactive, 1000 cases) | +| C3 | generic-restated | §3 35–37, 69 — `_pendingBoundaries` / `checkHydrationComplete`; the root preload wait (`client.ts:hydrate` `rootMapping`) | holds for ``; **red GH5** (preload hold not counted) | +| C4 | SC-only | document records (`drainRecords`) have no plain twin; the plain "applies once" is C1's no-duplicate | — | +| C5 | SC-only | per-response data tables | — | +| C6 | SC-only | held `slot:*` records across rebind | — | +| C7 | SC-only | frame store / `#flush` | — | +| C8 | SC-only | frame fan-out | — | +| C9 | generic-restated | §3 8, 68, 70 — settled fragment hydrates straight through (`hydratedCreateLoadingBoundary`) into an UNREVEALED core boundary | **red GH4** (fallback committed over settled content) | +| C10 | generic-restated | §3 18, 22, 70 — ids from the owner's counter; a resume's `gather(id)` | holds (both orders claim the server nodes, no miss) | +| C11 | SC-only | container traces | — | +| C12 | generic-restated | §3 68 — `_fr` states pending / settled / parked / superseded / rejected (the rejected arm has a client twin on plain pages) | holds at settle points; the transient violation is GH4 | +| C13 | SC-only | live holes / sweeps | — | +| C14 | generic-restated | §3 35, 69 — `initBoundaryResume` disposal release + `cleanupFragment`; the preload path's deferred disposer | holds for boundaries; **red GH6** (dispose during preload) | +| C15 | SC-only | staging | — | +| C16 | SC-only | component identity | — | +| C17 | SC-only | shell gate / address | — | +| C18 | SC-only | occurrence classification | — | +| C19 | generic-suspect | §3 6, 38, 71 — `normalize` adopts the text node without a write; the snapshot scope (#3504) is what makes the read match | **red GH1, GH2, GH3** (three sources the snapshot does not cover) | +| R1 | generic-restated | a hold registered with nothing hydration counts | **GH5** is its plain twin | +| R2 | SC-only | `#appliedRootValue` | — | +| R3 | SC-only | a plain reveal IS a trigger (C2 row) | holds | +| R4 | SC-only | data path version | — | +| R5 | SC-only | rebind | — | +| R6 | SC-only | a server `` with no client twin; the plain `s === 2` arm resumes fresh (`hydration/diagnostics`) | holds | +| R7 | SC-only | sweep delimiter | — | +| R8 | SC-only | gate / address | — | +| R9 | SC-only | classification vs drain | — | +| R10 | generic-suspect | the claim pass is not a mutation pass; the trace is one source without a snapshot — GH1–GH3 are the others | **GH1–GH3** | + +The frames pass's six "smelled generic" findings, placed: (1) `initBoundaryResume` +ids vs the fragment ledger — SC-only (a plain boundary's id IS its fragment +key by design; the `sc:` prefix is the frames fix); (2) a hold on a page that +never ran `hydrate()` — SC-only in that shape (`initBoundaryResume` is +reached only under `hydrating`), but its generic twin — a hold the runtime +does not count — is GH5; (3) unrevealed boundary + `STATUS_PENDING` → +fallback — **generic, GH4** (the `flatten` memo-of-a-promise arm was not +reproduced on a plain page); (4) `$df` not a sync trigger — plain `` +resumes on the `_fr` settle, holds; (5) R10's class for the plain adapters — +**generic, GH1–GH3** (the hybrid async-iterable signal path is protected by +its creation-time snapshot of the first yield; the store path parks its +backlog past hydration end — both by reasoning, not pinned); (6) events vs a +hold — **generic**: GH5's second arm (the bootstrap stops capturing once the +wrong done drained the queue) and GH4's lost click. + +### Generic reds + +Pins: `packages/web/test/consistency/generic/replay.spec.tsx` (GH1–GH4, over +the harness's laws) and `preload-hold.spec.tsx` (GH5, GH6). Schedules read +as `describeScenario` prints them: `H` hydrate, `Cn` the stream's n-th chunk, +`W` a client write to the module-level signal, `P` a push to the module-level +store list, `Ea`/`Eb` a click on a boundary's button, `t` a 20ms settle, `m` a +microtask, `X` dispose. + +#### GH1 — C19: a memo created before capture is read live by a resume's claim pass + +**Shape.** `const label = createRoot(() => createMemo(() => "label:" + path()))` +at module level (a global store module), read in the shell and in both +boundaries; `setPath("/b")` after `hydrate()` and before the fragments land +(`[ab] :: H W t C0 C1 C2 t`). **Observed:** the shell shows `label:/b`; each +boundary's resume claims the server text `label:/a` while the memo it read +says `label:/b` — the `.raw` hole beside it (the plain signal) reads the +snapshot `/a`, claims, and catches up to `/b`; `.label` never does until the +memo changes again. **Expected** (the write-before-resume contract, #3504): +the boundary resumes against the server snapshot, then catches up. **Where +it goes wrong.** `captureWriteSnapshot` records the pre-write value of a +plain SIGNAL written during capture (`core.ts:setSignal`), and a computed +created during capture gets its creation value as snapshot (`core.ts:computed`); +a computed created BEFORE capture has neither, recomputes live when its +dependency is written, and the in-scope reader finds no `_snapshotValue` to +serve. `normalize` then adopts the text node without a write (§3 6) and +`insertExpression`'s claim arm returns the value (§3 8). **Severity:** stale +DOM, no diagnostic, until the next distinct change (medium). **Fix direction +(not applied):** the computed analog of `captureWriteSnapshot` — when a +computed without a snapshot recomputes while capture is active and it is not +itself in a snapshot scope, record its pre-recompute value; or make the claim +pass reconcile a text hole whose read differs from the node (which also +covers R10). + +#### GH2 — C19: a shell async memo adopted pending re-runs before a later boundary resumes + +**Shape.** `shared = createMemo(async () => "shared:" + path())` in the shell, +read only inside the boundaries (pending when the shell flushes, so the +client adopts it pending: no creation snapshot — `computed()` skips +`STATUS_PENDING`, and an async landing "reveals" by design). It lands +`shared:/a` with the first fragment; a write re-runs it (`H C0 C1 t W t C2 t`); +the second boundary resumes reading `shared:/b` and claims `shared:/a`. +**Observed:** `b.shared shared:/a ≠ shared:/b` beside `b.raw /b` — one +boundary internally inconsistent. **Expected:** `shared:/b` once settled. +**Severity:** stale DOM, no diagnostic (medium). **Fix direction:** same +as GH1 (the first landed value of a pending-adopted computed is the server's +value and could seed its snapshot), or reconcile at claim. + +#### GH3 — C19 / C1: a store write to a leaf no reader has materialized is not snapshotted + +**Shape.** `const [store, setStore] = createStore({ items: ["i0", "i1"] })` at +module level; `` inside each boundary; a push +(`setStore(s => s.items.push("i2"))`) after `hydrate()` and before the +fragments land (`H P C0 C1 C2 t`). **Observed:** at each resume `` reads +three items against two server rows — `Hydration key miss for "…620"` (a +detached `
  • ` the warning blames on id namespaces) and a list one row +short until the next structural change. **Expected:** two rows claimed, the +third inserted at release. **Where it goes wrong.** No shell reader had read +`items.length`, so the write mutates the raw target with no leaf signal to +capture; the leaf is created at the resume's first read with the post-write +value and a snapshot OF that value. Materializing the leaf before the write +(a shell reader of `items.length`) makes the same schedule green — the hole +is exactly "unmaterialized leaf". **Severity:** stale DOM + misleading dev +diagnostic (medium). **Fix direction:** under capture, a store write to an +unmaterialized leaf materializes it (so `captureWriteSnapshot` sees the +pre-write value) or records a per-target pre-write snapshot. + +#### GH4 — C9 / C12 / events: a boundary resuming while a shell async source is in flight commits its fallback over the settled content + +**Shape.** The GH2 page; the write lands BEFORE the first fragment +(`H W C0 C1 m t`): `shared` is superseded by a client flight (15ms); the +boundary's fragment reveals and it resumes while the flight is open. +**Observed:** the resume's content reads `shared` pending; the core boundary +has never revealed on the client, so it falls back — the fallback is +rendered in the claim window (`Hydration key miss for "410"`, `

    `, +a phantom the claim arm keeps out of the DOM), then `releaseSnapshotScope` +re-runs the insert OUTSIDE the window and commits it: the server `

    ` +is detached and a fresh client `

    a-loading

    ` stands in +its place until the flight lands, when the same server nodes are +re-attached (node identity holds, parity holds at the settle point). A +click queued on the server section at its reveal (`H W m C0 C1 C2 Eb`) +replays while the section is detached — the walk from the detached button +never reaches the delegated container — and is consumed: `b: 1 clicks, 0 handled`. +**Expected:** the settled server content is the boundary's revealed value +(async-holds-latest), no fallback, no detach, the click replays. **Where it +goes wrong.** `hydratedCreateLoadingBoundary`'s settled paths hand the +server content to `coreLoadingBoundary` as a fresh, UNREVEALED boundary; +"revealed" is a client-render fact the hydration path never asserts. The +frames pass's finding (3) is this, with frames. **Severity:** visible +fallback flash over settled content, focus/selection loss, lost pre-hydration +input (medium-high). **Fix direction:** a boundary hydrating straight +through / resuming from a settled fragment starts revealed (the claimed +content is its value), so a pending read holds. + +#### GH5 — C3: hydration-done does not count a root's module preload + +**Shape.** Two roots; A's `hydrate()` finds `a_assets` and defers its render +behind `loadModuleAssets`; B's `hydrate()` runs synchronously meanwhile +(islands entry-clients start several roots in one tick — the code comment +in `hydrate` names the shape). **Observed:** B's pass ends → `checkHydrationComplete` +→ `drainHydrationCallbacks`: `onHydrationEnd` fires, `isHydrationInProgress()` +reads false, `_$HY.done = true` a macrotask later — while A has claimed +nothing and cannot until its module lands. Second arm: with a queued click +to drain, the replay nulls `_$HY.events` at done and the bootstrap stops +capturing; a click on A's server markup during A's wait is neither queued +nor handled (`a: 0` where 1 was sent). A third root starting after the +timeout would degrade to `render()` (§3 33; reasoned from the `_$HY.done` +guard, not pinned). **Expected:** done waits for the preload. **Where it +goes wrong.** The wait registers with nothing the completion check counts — +`_hydratingValue` is a per-root flag the next root's `finally` clears, and +`_pendingBoundaries` knows only `` registrations. R1 with the +record defer swapped for the preload. **Severity:** wrong done + lost input +(medium-high in islands setups). **Fix direction:** count the preload wait +as a pending registration — the same `_pendingBoundaries++` / release pair +`initBoundaryResume` keeps (what `sharedConfig.holdBoundary` wraps, minus +its owner requirement: `hydrate`'s preload branch has no owner yet) around +the `p.then`. + +#### GH6 — C14: disposing a root during its module preload does not cancel the deferred render + +**Shape.** `const dispose = hydrate(App, el)` with a pending `_assets` +preload; `dispose()` before the module lands. **Observed:** `hydrate` returns +`() => disposer && disposer()` with `disposer` unset until the preload's +`.then`; the call is a no-op, the render runs when the module lands, and +the root stays live (a write re-renders it) with no handle left — a second +call to the same function reaches the late disposer. **Expected:** nothing +renders after dispose. **Severity:** leaked live root (low-medium; HMR and +test teardown are the realistic callers). **Fix direction:** a `disposed` +flag in `hydrate`'s preload branch, checked before the deferred `render` +(and clearing `hydrating` / checking completion when set). + +### Generic holds confirmed + +On the plain page (hand pins in `replay.spec.tsx` "generic holds", and the +harness's 1000 cases with `CONSISTENCY_IGNORE=C1,C9,C19,E` → 0 findings): + +- **C2 / C10** — both fragment orders, hydrate before / between / after the + chunks: every boundary claims its server nodes (node identity, no + duplicate, no key miss absent a client write), is invoked once, and + reacts after a post-done write. +- **C3** — hydration-done waits for both streamed `` boundaries in + either order; `isHydrationInProgress()` stays true until then. +- **C12** — pending → the server fallback shows; revealed → content, no + fallback (at every settle point). +- **C14** — dispose while both are pending, or between the reveals: the late + chunks touch nothing, nothing runs, no error (the placeholder range is + removed at disposal; a late `$df` queues a retry that never lands). +- **Events** — a click queued before `hydrate()` on a settled fragment, or + after a reveal before the resume, replays exactly once at the claim + (absent GH4's detach). +- **#3504 snapshot** — the plain signal written during hydration resumes on + the snapshot and catches up in every schedule (the control beside GH1). +- Reasoned, not pinned: the hybrid async-iterable SIGNAL adapter is covered + by its creation snapshot (the first yield is delivered synchronously, so + the memo is not pending at creation); the STORE adapter parks its backlog + past hydration end (§3 60); `lazy()` without `moduleUrl` under a settled + boundary takes the async path and cannot claim — the documented + degradation of §3 81, not a hole. + +### Harness arm + +`packages/web/test/consistency/generic/` — same knobs as §Harness +(`CONSISTENCY_FUZZ=1 CONSISTENCY_SEED=… CONSISTENCY_CASES=… CONSISTENCY_IGNORE=… +CONSISTENCY_MODE=survey|shrink`, run against `test/consistency/generic`). +Scenario: a fragment order (`ab` / `ba`, two server renders) and a shuffled +schedule of `hydrate`, the stream's chunks (wire order kept), an optional +client write and store push (after `hydrate` — before it they are an app +mismatch, outside the contract), clicks on either boundary, a dispose, +0–3 settles, 0–2 microtasks. Laws: G no-runtime-error; C1 no-key-miss / +no-unclaimed / node-identity / no-duplicate; C9 no-fallback-over-settled; +C3 in-progress-until-done / done-counts-holds; C12 fragment-parity; C19 +claim-shows-signal / -memo / -async-memo / -store-list; C14 dispose-no-invoke +/ dispose-no-dom; C2 every-range-live / -reactive; E queued-click-replays-once. + +| seed | cases | ignore | cases with findings | findings by law | +| ----- | ----- | -------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| 3289 | 500 | — | 101 | C1 no-key-miss 95, C19 store-list 60 (GH3), C19 memo 55 (GH1), C9 fallback-over-settled 26 (GH4), C19 async-memo 16 (GH2), E 8 (GH4) | +| 91501 | 500 | — | 99 | C1 84, C19 memo 60, C19 store-list 48, C9 26, C19 async-memo 15, E 4 | +| 91501 | 500 | C1, C9, C19, E | **0** | C2, C3, C12, C14, G: nothing surfaces | + +Limitations: one page shape (two sibling boundaries; no nested boundaries, +no `lazy()`, no ``); the server's chunking is fixed per order +(three chunks: the first boundary's data, `shared` + its fragment, the +second fragment); `_hydrationDone` is a worker latch so every case after +the first runs post-done (a reveal before `hydrate()` is held and replayed +at registration — both regimes are legal pages); `readyState` is not +mocked (plain hydration consults it only for truncation). + +### Caveats + +- The verdicts are "a pin could not break it", as above. The harness covers + one page; nested boundaries resolving out of order, `lazy()` inside a + boundary and two `hydrate()` roots are covered only by the existing suite + (`parity-harness`, `loading-lazy-resume-3749`, `multi-root-registry`). +- GH4 self-heals for the DOM (the server nodes return); its lasting damage + is the lost input and the focus/selection loss, which the pin observes + through the click only. +- Severity of GH5 depends on the islands setup: a single deferred root is + fine (control pinned); the red needs a second root finishing while the + first waits. diff --git a/documentation/server-components/frames-rulings.md b/documentation/server-components/frames-rulings.md index 96d05dd72..5600b993a 100644 --- a/documentation/server-components/frames-rulings.md +++ b/documentation/server-components/frames-rulings.md @@ -3,9 +3,9 @@ **Status: ruled.** **3.1, ruled 2026-10-05** (hydration-done follows non-SC Solid 2) and the **Principle** below, which is the maintainer's (three statements, 2026-10-05); every other ruling was re-derived from it and -marked *recommended-by-principle* where the principle decides a reading — -and on **2026-10-06** the maintainer nodded the lot (*"other than that lets -do your recommendations"*): **1.3, 1.4 full, 1.6 (i), 2.3, 3.3** are ruled +marked _recommended-by-principle_ where the principle decides a reading — +and on **2026-10-06** the maintainer nodded the lot (_"other than that lets +do your recommendations"_): **1.3, 1.4 full, 1.6 (i), 2.3, 3.3** are ruled as recommended; **3.6 is ruled (iii)** (the consumer parks); the one wire fact (whether `slot` may trail `html`) is ruled by leaving the order unspecified and pinning the client's tolerance; **3.5 is closed**, @@ -18,7 +18,13 @@ and nothing else moves. **2026-10-06:** the correctness pass ran overnight as draft PRs #3830–#3833 (see "As landed" under the order of work, and the defaults it took under "What the rulings do NOT decide"); the maintainer folded the stack into **one PR against `next`, #3837** ("merge as one given -all the checks"), which carries this document with the code. +all the checks"), which carries this document with the code. **Phase A +landed** (2026-10-06): #3837 (A0, A1's S-flush, A2's hold, C5's data scope, +C12's report) and the second integration PR **#3849** (A2b's park + +`hydrateWindow`, A1b + A4, A7 + A3, A5′, A6's server half, the generic +hydration pins) — every ruling marked "Landed" below is on `next`; what +stays open is that PR's "Not in this PR" (S-key, DR-4 structural, the +`preview`/token pull form, the 30 s bound's arming point, GH1–GH6). The frames/hydration consistency contract (`frames-consistency-contract.md`, branch `spec/frames-consistency-contract` @@ -74,11 +80,11 @@ is a response too — version 0, the t = 0 frame (DR-4). **A server component's output — its frame markup, its records, its traces — is rendered data like any other async data in Solid 2, and follows the rules Solid 2 already has for async data; the frames layer adds a transport, never -a second model.** The maintainer's three statements, verbatim: *"SCs are no -different than other rendered data."* *"Hydration ending should follow our -Solid 2 non-SC."* *"SCs participate in `` until their first flush +a second model.** The maintainer's three statements, verbatim: _"SCs are no +different than other rendered data."_ _"Hydration ending should follow our +Solid 2 non-SC."_ _"SCs participate in `` until their first flush the same way [as any async data], and can have their own internal loading -states that the client doesn't care about."* Every ruling below is therefore +states that the client doesn't care about."_ Every ruling below is therefore one of two things — the frames **form of a rule the core already has** (`Restates:` names it: the L2 rulings 1–9 and A-rules of `packages/signals/docs/SPEC-ASYNC-SEMANTICS.md`; the `` rules of @@ -97,29 +103,29 @@ Four corollaries, one per seam and one for the boundary the seams meet at: 1. **Response identity IS async supersession.** An address is a source; a response is a flight answering one question on it; a refetch or a switch - is a **new question** on the same source. L2 ruling 5 (provenance): *"a + is a **new question** on the same source. L2 ruling 5 (provenance): _"a landing asking an older question than the guess it lands beneath is not - its answer … nothing moves on screen."* A18 (supersession, 2026-09-10): - *"a slow source shouldn't leak back in like that"* — only the question's + its answer … nothing moves on screen."_ A18 (supersession, 2026-09-10): + _"a slow source shouldn't leak back in like that"_ — only the question's own answer, a later question's, or mainline supersedes; a store's - *"projection landing still consumes the whole layer (fresh authority - supersedes every tentative write)."* So: a late chunk of a superseded + _"projection landing still consumes the whole layer (fresh authority + supersedes every tentative write)."_ So: a late chunk of a superseded response is dropped, never merged; the store holds the **latest answer**, not a merge of answers; an answer resolves its parts (`{$ref}`) through its own question's context, never the current one's. Seam 1. 2. **Applied state per version IS "a landing replaces the value wholesale".** L2 ruling 1 (one frame concept — a node is committed or staged, a flush - lands or parks), A15 (*"lanes settle as one reveal"*; a stale reader is + lands or parks), A15 (_"lanes settle as one reveal"_; a stale reader is re-derived at the landing), A30 (a frame is replaced by its landing, not by the pass that asked). A landing is applied as a whole and every reader of it re-derives; nothing of the previous frame is consulted. So: a version bump re-applies even a byte-identical root (an equal landing is - still a landing — A18 (a): *"a landing that equals … confirms"*, the frame + still a landing — A18 (a): _"a landing that equals … confirms"_, the frame is the new question's); a **reveal is a landing** (content becoming shown is the moment readers of it re-derive — A15's reveal corollary); "applied" is a cache of the store keyed by the landing. Seam 2. -3. **Hydration-done IS non-SC hydration-done — ruled.** *"Hydration ending - should follow our Solid 2 non-SC."* Done is what +3. **Hydration-done IS non-SC hydration-done — ruled.** _"Hydration ending + should follow our Solid 2 non-SC."_ Done is what `hydration.ts:checkHydrationComplete` says: the root pass over and `_pendingBoundaries === 0`; every hold the frames client takes registers **as a pending boundary**, the way a `` resume does @@ -128,20 +134,20 @@ Four corollaries, one per seam and one for the boundary the seams meet at: mean the same thing with or without SC. Seam 3 (3.1, with 3.2 as its mechanism). 4. **A frame is one async value outward; its inner boundaries are the - server's.** *"SCs participate in `` until their first flush the + server's.** _"SCs participate in `` until their first flush the same way, and can have their own internal loading states that the client - doesn't care about."* **Outward:** to its surroundings a frame is one + doesn't care about."_ **Outward:** to its surroundings a frame is one async source. The enclosing `` — and hydration-done, per 3 — waits for the frame's **first flush** exactly as it waits for any async source's first landing (`05-async-data.md` "`Loading` is the UI boundary": - *"branch readiness … after that branch has produced content, subsequent - revalidation should not kick you back into the fallback"*; A29's boundary + _"branch readiness … after that branch has produced content, subsequent + revalidation should not kick you back into the fallback"_; A29's boundary exemption, #3540: an unrevealed boundary shows its fallback now, a - revealed one holds; A33: *"a `` boundary showing its fallback is - the display of everything under it"*), and for **nothing inside it**. A + revealed one holds; A33: _"a `` boundary showing its fallback is + the display of everything under it"_), and for **nothing inside it**. A refetch or switch is a new question on that source — the boundary's - retain/`on` behaviour applies as for any memo. *This is what the shell - gate is* (1.5, 1.6): the boundary's pending state for the frame's first + retain/`on` behaviour applies as for any memo. _This is what the shell + gate is_ (1.5, 1.6): the boundary's pending state for the frame's first flush under the bound address. **Inward:** a server component's own ``/`` are the server's. Their fallbacks, reveals and error outcomes arrive **as markup and segments** (`seg:`/`reveal`/the @@ -152,13 +158,13 @@ Four corollaries, one per seam and one for the boundary the seams meet at: server rendered nothing for it, that is a server-half gap to report, not a client state to invent. **The inward face exempts nothing of the client's:** a fill waiting for its chunk (S1's `prepareArgs`), a record - defer, a `{$ref}` wait are client-side waits *inside* a frame that has + defer, a `{$ref}` wait are client-side waits _inside_ a frame that has had its first flush — the client's own fills — and register under 3 as pending boundaries (3.2). The principle decides five readings the draft left to the maintainer — 1.3 (yes), 1.4 (full), 1.6 (per-address), 2.3 (yes), 3.3 (yes, re-shaped by -corollary 4) — each marked *recommended-by-principle* below; it supports 3.6 +corollary 4) — each marked _recommended-by-principle_ below; it supports 3.6 (iii) through the hydration adoption rule and asks only that 3.5's sentence be confirmed. It argues **against** two things as drafted: 3.3's client error arm (a client `` for a server boundary's failure) and the @@ -167,12 +173,12 @@ position". Code sites corollary 4 says to change are listed under 3.3. ## The seams, the reds, the duplicates -| seam | question | reds (pins that fail on `next`) | duplicates (audit §4) | -| --- | --- | --- | --- | -| **1. Response identity** | which response owns a record, a `{$ref}` wait, a data table, the shell gate | **C5** (a, b, e) a superseded response's late `data` lands in the current table — R4; **C6** (a1, b2) a held `slot:*` record outlives its response and resolves through the next one's data — R5; **C17** (a, c) the shell gate answers to the frame's registered address, which lags the binding — R8 | two table spaces (`tables` + `stageTables`' `staged`, 183 B); two ref-resolution paths (`host.resolve(ref, frameId)` + the `resolve` parameter threaded through `preview` → `#refsUnresolved`/`#refArgsUnchanged`/`#resolveArgs`/`#resolveRef`); two dedupes (`argsEquivalent` 217 B at apply, `#refArgsUnchanged` 534 B at sync — the first exists because refs are response-scoped and the store is not); two shell gates (`boundaryComponent` + the adopted face in `adoptBoundary`, ≈ 150 B duplicated); `preview`'s data half (the `resolve` it threads). _No red, same question:_ the `_$SC` bootstrap twice — the document's t = 0 address record reaches the mount by `documentAddress` scanning `_$SC.a` (audit S9) | -| **2. Applied state per version** | what a version bump resets; when a reveal is an apply | **C7** (c) a byte-identical v2 root never re-applies, so v2's segment waits for a placeholder v1 removed — R2; **C2** (a2, b; and the harness's C2 replay — R3 rediscovered: record before adoption, fragment revealed after) and **C4** (d) a fragment reveal into adopted content is not a sync trigger — R3 | two version spaces (`FrameImpl.#version` beside `store.version`; `rebase`, ≈ 60–100 B); applied state in seven fields reset at three sites (`#resetStreamState` ×5, `rebind` ×2, the root apply ×1), one of them (`#appliedRootValue`) reset at only one; two reveal engines (`web.js`'s `$df`/`$dfl` and the frame's `#revealSegment`/`#showFallback`, ≈ 1.3 KB on one side — DR-4); the #2978 cascade (`claimRegionFragments` + the `fr.subscribe` body ≈ 250 B) as the document face's half of a sync | -| **3. Hydration-done accounting** | what `done` counts; when a hold lifts; what a claim owes and reads | **C3** (a; and the harness's C3 replay — R1 rediscovered) hydration reports done while an adopted occurrence is still deferred — R1; **C18** (×3: two records drained after the parser finished, a live op before the drain, the live pump's catch-up read) a recordless occurrence is classified while the document still holds its record undrained, its render prop evaluated argless → `TypeError` → `REACTIVITY_HALTED` — R9, **page-halting**; **C19** (×2: patch before claim, two orders) a trace patch delivered before the fill's claim is never shown; heals only on the next distinct patch — R10 (green on S1); **C12** (c) a rejected server `` the adoption claimed swaps to a blank, unsurfaced — R6; S1's **C3b** shape (the `prepareArgs` wait — held by S1's own pin as the *expected* order); S1's `.fails` (a keyed sibling after a document boundary misses its key) | the #2968 deferral (`recordsPending` + `#recordRefresh` arm + the drain hook ≈ 300 B) — whose bound is the parser's state while the records it waits for sit in a ledger (`_$HY.r`) nothing consults; S1's `#argsRefresh` + `#heldRecords`, and the `{$ref}` wait's non-carrier: three hold kinds, two carriers, no shared accounting; `drainRecords` + `appliedRecords` (DR-4 row 20) — one `host.apply` per record, a sync per apply; the trace's claim reading (`materializeContainerTrace`'s synchronous replay) and the claim pass's non-mutation (`insertExpression`) each right alone, wrong together. _No red, same question:_ two late-boundary waiters (`boundaryWaiters` + `arrivals`, ≈ 150 B duplicated, resolved from one subscription — a hold already counted through the covering ``'s `_fr`; audit S9) | -| outside | — | **C13** (a, b) one sweep, two frames — R7, **confirmed** by `c13-sweep-atomic` on both faces (`a0\|b0 → a1\|b0 → a1\|b1`, identical through `frame:applied` and a `MutationObserver`); the delimiter's shape is in "The server half / wire" below | the asset-loader mirror (audit S10), the three region-rename sites (audit S7) | +| seam | question | reds (pins that fail on `next`) | duplicates (audit §4) | +| -------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1. Response identity** | which response owns a record, a `{$ref}` wait, a data table, the shell gate | **C5** (a, b, e) a superseded response's late `data` lands in the current table — R4; **C6** (a1, b2) a held `slot:*` record outlives its response and resolves through the next one's data — R5; **C17** (a, c) the shell gate answers to the frame's registered address, which lags the binding — R8 | two table spaces (`tables` + `stageTables`' `staged`, 183 B); two ref-resolution paths (`host.resolve(ref, frameId)` + the `resolve` parameter threaded through `preview` → `#refsUnresolved`/`#refArgsUnchanged`/`#resolveArgs`/`#resolveRef`); two dedupes (`argsEquivalent` 217 B at apply, `#refArgsUnchanged` 534 B at sync — the first exists because refs are response-scoped and the store is not); two shell gates (`boundaryComponent` + the adopted face in `adoptBoundary`, ≈ 150 B duplicated); `preview`'s data half (the `resolve` it threads). _No red, same question:_ the `_$SC` bootstrap twice — the document's t = 0 address record reaches the mount by `documentAddress` scanning `_$SC.a` (audit S9) | +| **2. Applied state per version** | what a version bump resets; when a reveal is an apply | **C7** (c) a byte-identical v2 root never re-applies, so v2's segment waits for a placeholder v1 removed — R2; **C2** (a2, b; and the harness's C2 replay — R3 rediscovered: record before adoption, fragment revealed after) and **C4** (d) a fragment reveal into adopted content is not a sync trigger — R3 | two version spaces (`FrameImpl.#version` beside `store.version`; `rebase`, ≈ 60–100 B); applied state in seven fields reset at three sites (`#resetStreamState` ×5, `rebind` ×2, the root apply ×1), one of them (`#appliedRootValue`) reset at only one; two reveal engines (`web.js`'s `$df`/`$dfl` and the frame's `#revealSegment`/`#showFallback`, ≈ 1.3 KB on one side — DR-4); the #2978 cascade (`claimRegionFragments` + the `fr.subscribe` body ≈ 250 B) as the document face's half of a sync | +| **3. Hydration-done accounting** | what `done` counts; when a hold lifts; what a claim owes and reads | **C3** (a; and the harness's C3 replay — R1 rediscovered) hydration reports done while an adopted occurrence is still deferred — R1; **C18** (×3: two records drained after the parser finished, a live op before the drain, the live pump's catch-up read) a recordless occurrence is classified while the document still holds its record undrained, its render prop evaluated argless → `TypeError` → `REACTIVITY_HALTED` — R9, **page-halting**; **C19** (×2: patch before claim, two orders) a trace patch delivered before the fill's claim is never shown; heals only on the next distinct patch — R10 (green on S1); **C12** (c) a rejected server `` the adoption claimed swaps to a blank, unsurfaced — R6; S1's **C3b** shape (the `prepareArgs` wait — held by S1's own pin as the _expected_ order); S1's `.fails` (a keyed sibling after a document boundary misses its key) | the #2968 deferral (`recordsPending` + `#recordRefresh` arm + the drain hook ≈ 300 B) — whose bound is the parser's state while the records it waits for sit in a ledger (`_$HY.r`) nothing consults; S1's `#argsRefresh` + `#heldRecords`, and the `{$ref}` wait's non-carrier: three hold kinds, two carriers, no shared accounting; `drainRecords` + `appliedRecords` (DR-4 row 20) — one `host.apply` per record, a sync per apply; the trace's claim reading (`materializeContainerTrace`'s synchronous replay) and the claim pass's non-mutation (`insertExpression`) each right alone, wrong together. _No red, same question:_ two late-boundary waiters (`boundaryWaiters` + `arrivals`, ≈ 150 B duplicated, resolved from one subscription — a hold already counted through the covering ``'s `_fr`; audit S9) | +| outside | — | **C13** (a, b) one sweep, two frames — R7, **confirmed** by `c13-sweep-atomic` on both faces (`a0\|b0 → a1\|b0 → a1\|b1`, identical through `frame:applied` and a `MutationObserver`); the delimiter's shape is in "The server half / wire" below | the asset-loader mirror (audit S10), the three region-rename sites (audit S7) | Byte figures are the audit's (minified, page base, exact per function; brotli ≈ 0.29× at this layer). Estimates below carry a sign per direction: @@ -183,7 +189,7 @@ brotli ≈ 0.29× at this layer). Estimates below carry a sign per direction: ## Seam 1 — Response identity The question every red here asks: a thing arrived — whose is it? Today the -answer is given by *where it landed* (the address's current table, the frame's +answer is given by _where it landed_ (the address's current table, the frame's current id, whatever gate is armed), and the rotation that makes "current" mean "newest" happens at different moments for different things: the table at the header (`beginStream`), the record never (`slot:*` survives `clearStreamRecords`), @@ -200,16 +206,16 @@ superseded response lands in the frame that shows the current one.** - **Mechanism today.** The table: `client.ts:tables` is a `Map`; `beginStream(address)` rotates by `tables.set(address, undefined)` and - `ensureTable` creates lazily at *first use* — which `createFrameHost.apply`'s + `ensureTable` creates lazily at _first use_ — which `createFrameHost.apply`'s `data` arm performs with no version read (the transport restamped `chunk.version`; `applyData` never looks). The record: owned by the frame store and versioned at the store (`store.version`, `#version`), not per record; `clearStreamRecords` keeps every `slot:*`. The wait: a `continue` in `FrameImpl.#syncSlots` with no carrier; its answer is `#resolveRef(ref)` → - `host.resolve(ref, this.#options.id)` → `tableFor(id)` — the frame's *current* + `host.resolve(ref, this.#options.id)` → `tableFor(id)` — the frame's _current_ id, so a `rebind` re-routes every held record to the new address's data. - **Lives twice in.** `tables` + `stageTables()`'s `staged` (the staged - response's table is the one case that already *is* response-owned — kept in a + response's table is the one case that already _is_ response-owned — kept in a second map because the first is address-owned); `host.resolve` + the `resolve` parameter; `argsEquivalent` + `#refArgsUnchanged`. - **Decides.** The frame of reference for 1.2–1.4; by itself it flips nothing. @@ -242,7 +248,7 @@ becomes the current one.** ruling §3 11 — so the cell's lazy fill is the codec's, not the response's); installed as the address's current at the header (unstaged) or at `commit` (staged). The response's chunks reach the host through a per-response target - whose `apply` routes `data` into *its* cell — the shape `stage`'s entry + whose `apply` routes `data` into _its_ cell — the shape `stage`'s entry already has. Nothing stamps or compares versions on the data path: a late chunk fills a cell nothing reads. - **Restates:** corollary 1 — A18 (2026-09-10): a superseded flight's landing @@ -259,7 +265,7 @@ a later response's values, and the later response's own record replaces it.** - **Mechanism today.** `#resolveRef(ref, resolve?)` — the host path by frame id, or the `resolve` the staged preview threads down. R5: after `rebind`, A's held record resolves through `tableFor(B)`; at a staged commit, `commit` installs - v2's tables *before* replaying v2's chunks, and the replayed `start`'s flush + v2's tables _before_ replaying v2's chunks, and the replayed `start`'s flush resolves v1's held record through them. - **Lives twice in.** The two resolution paths (host-by-frame-id and the threaded `resolve`): `createFrameHost.preview(chunk, resolve)`, @@ -286,13 +292,13 @@ a later response's values, and the later response's own record replaces it.** - **Restates:** corollary 1 — L2 ruling 5: an answer is judged by the question it answers; A29/A15: a pass derives from the world it was served. A record is an answer whose parts (`{$ref}`) are resolved in its own question's - context; resolving them through the frame's *current* address is the + context; resolving them through the frame's _current_ address is the "slow source leaking back in" A18 forbids. **Recommended-by-principle: yes.** Not frames-specific. ### 1.4 A version bump drops what the previous version never applied -**Slot records outlive a bump only as the dedupe for *mounted* occurrences; a +**Slot records outlive a bump only as the dedupe for _mounted_ occurrences; a record no mount applied belongs to its superseded response and leaves with it.** Two forms; the maintainer picks. @@ -304,7 +310,7 @@ Two forms; the maintainer picks. record cannot resolve through anything. ≈ +50 B. - **Full — the store is one response's.** Every record of the previous version leaves at the bump (the host's `write` and the frame's `apply` alike); what - preserves occurrence state across versions is the *mount's* applied state + preserves occurrence state across versions is the _mount's_ applied state (`#slotArgs`, `#slotResolvedRefs`), which the sync's value compare (`#refArgsUnchanged`) already consults. Then the apply-time dedupe (`argsEquivalent` 217 B, the `slot:` arm of `FrameImpl.apply` ≈ 80 B) has @@ -325,11 +331,11 @@ Two forms; the maintainer picks. supersedes every tentative write)"). The narrow form keeps a merge of two responses in one store, which has no analogue in a node's value. **Recommended-by-principle: the full form.** What stays frames-specific is - the *precondition* — that every response carries its full record set (the + the _precondition_ — that every response carries its full record set (the sink's A5 rule; "may `slot` trail `html`" below) — a property of the wire the principle cannot supply; confirm it before taking the full form. - **Open under either form:** a called occurrence (`prop#n`) found recordless - on a *non-adopt* sync is invoked argless today (the #2968 defer is adopt-only; + on a _non-adopt_ sync is invoked argless today (the #2968 defer is adopt-only; `#syncSlots`' comment calls the recordless-called case "the protocol's invariant broken"). RFC 11 fixes no order between `slot` and `html`; the sink emits the record "at the call, ahead of the markup". C6 (a1) orders @@ -381,7 +387,7 @@ releases through the frameless waiter.** flush the same way". A switch is a new question on the source (A18 provenance), so the superseded address's apply is an older question's landing and releases nothing (L2 ruling 5: "nothing moves on screen"). The - frames-specific residue is only *where the binding lives* (the `rebind` at + frames-specific residue is only _where the binding lives_ (the `rebind` at the commit, ruled 2026-10-04) — a transport fact, not a second gate. ### 1.6 A switch keeps on screen what was on screen @@ -394,7 +400,7 @@ and the superseded address never reveals after the switch was delivered.** - **Mechanism today.** `createMemo(() => gatePromise())` is one memo across addresses; R8 (c): A's html released the gate legitimately (A was the bound address, no switch delivered yet), so the memo holds the element; B's delivery - re-arms it, and a memo pending *with* a value shows the value under + re-arms it, and a memo pending _with_ a value shows the value under async-holds-latest — the `` drops its fallback for content that was never on screen, `waiting → A → B`. - **Two readings.** @@ -426,38 +432,49 @@ and the superseded address never reveals after the switch was delivered.** is those two rules applied to the frame-as-one-value: unrevealed → fallback until B's first flush; revealed A → A until B's first flush; A's late landing is an older question's (ruling 5) and never reveals. (ii) - holds-latest is a display rule for a *value already shown* — A was never + holds-latest is a display rule for a _value already shown_ — A was never shown, so (ii) misapplies it. **Recommended-by-principle: (i).** Not frames-specific. - **Decides.** **C17 (c)** under (i). ### Fix shape — seam 1 -| step | collapse (−) | carrier (+) | net (min B, est.) | pins that flip | touches | -| --- | --- | --- | --- | --- | --- | -| 1a — 1.2, data owned by response | `stageTables` 183; `beginStream` 32; `tableFor`/`ensureTable`'s lazy-create ≈ 84; `stage`'s `data ? … : streams` fallbacks ≈ 60 | per-response cell at `bump` + the unstaged per-response target (the staged entry's shape, smaller) ≈ 140 | **≈ −220** | C5 (a), (b), (e) | `ServerComponentHandlerOptions.onStream` (public: the rotation it signalled is now the cell's install — delete or redefine); `STAGED_DATA` (@internal) generalizes to every response | -| 1b — 1.3, record carries its resolver | `host.resolve` 53 + `FrameHostOptions.resolve` wiring ≈ 40; the `resolve` parameter across `preview` ×2, `#refsUnresolved`, `#refArgsUnchanged`, `#resolveArgs`, `#resolveRef` ≈ 70 | the stamp at chunk→records ≈ 40 | **≈ −120** | C6 (a1), (b2) | `FrameHost.resolve(ref, frameId)` / `FrameHostOptions.resolve` (public, experimental — no caller); `FrameHost.preview`/`Frame.preview` signatures (@internal) | -| 1c — 1.4 full, the store is one response's | `argsEquivalent` 217; the `slot:` arm of `FrameImpl.apply` ≈ 80; `clearStreamRecords`' filter + `root` ≈ 60 | — | **≈ −350** (narrow form: ≈ +50) | none directly; closes the C6 class | the sink's A5 rule must be confirmed (no wire change if it holds) | -| 1d — 1.5 + 1.6 (i), the gate | the second gate (`adoptBoundary`'s face) ≈ 150 | `shellGate()` helper's waiter check ≈ 20; per-address gate memo ≈ 80 | **≈ −50** (1.5 alone: ≈ −130) | C17 (a); C17 (c) under (i) | none public | -| **seam 1** | | | **≈ −740** (≈ −215 br) | 7 of the 22 | | +| step | collapse (−) | carrier (+) | net (min B, est.) | pins that flip | touches | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1a — 1.2, data owned by response | `stageTables` 183; `beginStream` 32; `tableFor`/`ensureTable`'s lazy-create ≈ 84; `stage`'s `data ? … : streams` fallbacks ≈ 60 | per-response cell at `bump` + the unstaged per-response target (the staged entry's shape, smaller) ≈ 140 | **≈ −220** | C5 (a), (b), (e) | `ServerComponentHandlerOptions.onStream` (public: the rotation it signalled is now the cell's install — delete or redefine); `STAGED_DATA` (@internal) generalizes to every response | +| 1b — 1.3, record carries its resolver | `host.resolve` 53 + `FrameHostOptions.resolve` wiring ≈ 40; the `resolve` parameter across `preview` ×2, `#refsUnresolved`, `#refArgsUnchanged`, `#resolveArgs`, `#resolveRef` ≈ 70 | the stamp at chunk→records ≈ 40 | **≈ −120** | C6 (a1), (b2) | `FrameHost.resolve(ref, frameId)` / `FrameHostOptions.resolve` (public, experimental — no caller); `FrameHost.preview`/`Frame.preview` signatures (@internal) | +| 1c — 1.4 full, the store is one response's | `argsEquivalent` 217; the `slot:` arm of `FrameImpl.apply` ≈ 80; `clearStreamRecords`' filter + `root` ≈ 60 | — | **≈ −350** (narrow form: ≈ +50) | none directly; closes the C6 class | the sink's A5 rule must be confirmed (no wire change if it holds) | +| 1d — 1.5 + 1.6 (i), the gate | the second gate (`adoptBoundary`'s face) ≈ 150 | `shellGate()` helper's waiter check ≈ 20; per-address gate memo ≈ 80 | **≈ −50** (1.5 alone: ≈ −130) | C17 (a); C17 (c) under (i) | none public | +| **seam 1** | | | **≈ −740** (≈ −215 br) | 7 of the 22 | | The audit's **S8** ("one apply path for staged content", ≈ −0.7 KB) splits -here: 1a/1b are its *data* half (`stageTables` folds into response-owned -cells; `preview`'s `resolve` threading goes). Its *markup* half — a staged +here: 1a/1b are its _data_ half (`stageTables` folds into response-owned +cells; `preview`'s `resolve` threading goes). Its _markup_ half — a staged version held in the one store under a not-shown bit, `preview` becoming the ordinary args-update arm — is S8 proper, is a restatement of rulings §3 71–75, and is not decided here (audit §7 Q2). +**Landed (2026-10-06, A1b + A4 on the A2b branch):** 1.2 and 1.3's carriers +as one mechanism — the response's table is keyed by the host's version +(`tableFor(id, version, current)`) and a record's `{$ref}`s settle at the +host's write through it, an undelivered key becoming the response's own +pending read (S-ref; rejected at the stream's end — L1); 1.4 full was +#3830's. 1b's threaded `resolve`, `stageTables`, `STAGED_DATA`, `onStream` +and `FrameHost.resolve` are gone; the `preview` push itself stays (see "As +landed" below for why: the token is the carrier by which a refetch of a +shown address enters the Transaction, and the compute-half push is what +stages its args in that pass). + --- ## Seam 2 — Applied state per version -The question: a frame applied something under version *n*; version *n*+1 +The question: a frame applied something under version _n_; version _n_+1 arrives — what does the frame still believe? Today "applied" is seven fields reset at three sites, and one of them is reset at only one of the two sites that bump the version. And "applied" is also asked of the wrong event: a record -applies when it *arrives* (the flush after a store write), not when the range -it names *appears* — so a reveal that brings no new record applies nothing. +applies when it _arrives_ (the flush after a store write), not when the range +it names _appears_ — so a reveal that brings no new record applies nothing. ### 2.1 The store is the truth; the applied state is a cache of it, keyed by version @@ -504,7 +521,7 @@ version's segments reveal into.** - **Decides.** **C7 (c)** a v2 root byte-identical to v1's resets the segment. (C7 a — all 720 orders — and b hold; d is the differing-root control.) - **Carrier.** One `#applied` record, `{ version, root, revealed, fallbacks, - holes, assets, errorNotified, have }`, created fresh at every bump +holes, assets, errorNotified, have }`, created fresh at every bump (`this.#applied = applied(v)`) and at `rebind` (`applied(undefined)`, plus the root record dropped — the one thing `rebind` does beyond a bump); there is no second site to forget. `rebase()` becomes `#applied.version = undefined` @@ -532,9 +549,9 @@ event seen from two sides, and either one completes the pair.** seam's `content()`). The document face does not: a `$df` into adopted markup notifies `adoptBoundary`'s `fr.subscribe`, which runs `claimRegionFragments` (#2978) and `drainRecords` (#2968) — a sync happens only if the drain finds a - *new* record (`drainRecords` → `host.apply` → `#flush` → `#syncSlots`). R3: a + _new_ record (`drainRecords` → `host.apply` → `#flush` → `#syncSlots`). R3: a reveal with no new record syncs nothing (C2 b, C2 a2 at reveal time); a record - drained *before* the reveal ran its sync while the range was inside + drained _before_ the reveal ran its sync while the range was inside `