Repository navigation
fix(web/frames): hydration-done counts the frame's holds — a waiting adopted occurrence is a pending boundary (C3) - #3831
Closed
ryansolid wants to merge 2 commits into
Conversation
…adopted occurrence is a pending boundary (C3)
Frames-rulings 3.1 (ruled): hydration-done follows non-SC Solid 2 — done is
what `checkHydrationComplete` says (the root pass over, `_pendingBoundaries`
zero), and the frames client participates through that mechanism, with no
accounting of its own. 3.2, the carrier: ONE registration per frame while a
sync leaves an adopted occurrence waiting to mount (for its record, for a
`{$ref}`'s data), released by the first sync that leaves none or by disposal.
solid-js: `sharedConfig.holdBoundary(id)` (internal) — `initBoundaryResume`'s
registration for a holder with nothing to resume: the count, the owner's `_hp`
mark, the disposal release; returns the release, which checks completion.
One assignment in `enableHydration`.
web/frames: `FrameOptions.hold()` (adopt path; wired by `adoptBoundary` under
the component's owner, only while `isHydrationInProgress()`, keyed `sc:<fid>`
so no fragment's bookkeeping is touched); `#syncSlots` tracks whether a full
sync left an unmounted occurrence waiting and registers/releases at its end;
`dispose` releases.
Harness: the C3 law exempts a done that fired at or after the mount's
disposal — a disposed holder owes no claim (its release is what lets done
fire, as a disposed <Loading>'s is). Verified to hide nothing on `next`
(C3 stays 280/500 there).
Pins flipped to `test`: C3 (a), harness C3 ×1. Campaign (500 cases): C3
280 → 0 (seed 3289), 268 → 0 (seed 91501); only C19 remains.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: af64ec1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
This was referenced Oct 6, 2026
…n #syncSlots, the guard reads isHydrationInProgress alone
−48 B min on the frames client (43,411 → 43,363); the zombie check now
precedes the {$ref} wait, so a zombie held on a ref is unmounted at once
rather than when the ref resolves.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Member
Author
|
ryansolid
added a commit
that referenced
this pull request
Oct 7, 2026
…ecords, error propagation, fragment ownership, server half, generic hydration pins (#3849) * fix(solid): a claim reads the trace's snapshot; the backlog lands after hydration ends (frames-rulings 3.6 (iii), the 3e port) S1's third commit (9927ddd) ported onto next, minus its `claiming` hint: - `materializeContainerTrace` parks a replayed backlog beyond the snapshot (`limit = 1`) until hydration ends — `onHydrationEnd`, the next microtask when none is in progress — then releases it with a version bump so the compute drains the backlog as one ordinary update. A trace is materialized at a fill's arg-read; when that fill CLAIMS adopted markup the snapshot is what the markup was rendered from, the claim trusts the markup (a text hole is never rewritten during a claim), and a store already past it left the DOM diverged for good. Release order (3.2): claim → the frame's hold release → done → backlog. A failure applies in order, after the parked patches. The park is unconditional (every replayed backlog), not keyed on hydration being in progress at materialization: the projection's first compute runs at creation, materialization precedes the frame's claim window, and a claim made AFTER hydration-done — an occurrence inside a server <Loading> whose fragment reveals after done (corollary 4: the server's inner loading state registers nothing) — has no hydration state that says "claim". Keyed on `isHydrationInProgress()` the harness kept 7 / 7 such cases red on the two seeds. A fresh mount pays one beat for not being told apart: its backlog lands a microtask after the snapshot, before any paint. - The projection's root is DETACHED (`runWithOwner(null, createRoot)`): rooted under the reading owner, a trace revived at t=0 consumed one child id while one revived by a late claim consumed none, and a keyed sibling after the frame hydrated under different keys in the two runs. Pins: harness C19 ×2 flip to `test`; `c19-claim-reads-snapshot.spec.tsx` (the t=0 claim past the markup, the deferred claim under the hold, the release order, the post-done claim at a reveal, the C11 consequence, id determinism — S1's container-trace-hold-{snapshot, hydration-end, id-determinism} re-cut for next); solid `container-trace.spec.ts` (id neutrality, the park's three timings, the in-order failure). Harness oracle: the settled trace law exempts a settle point inside a claim's park (contract C11, "every observable point" read as "outside a claim's park"); the end stays strict. Campaign, 500 cases: seed 3289 83 → 0, seed 91501 79 → 0, every law un-ignored. Contract: C11 amended, C19 re-read under (iii), R10 corrected (S1's path is the park, evidence for (iii) not (i)), the fix order's item 3 decided. Co-authored-by: Claude via Cursor <noreply@cursor.com> * feat(solid,web/frames): an adopted occurrence claims through the hydration window (hydrateWindow); R.claim deleted The savings plan's A2, S-hold's window form (frames-rulings 3.1 / 3.2): the frame's hold is `initBoundaryResume`'s registration (#3831's `holdBoundary`, kept), and the resumed fill re-enters hydration through the same window a streamed <Loading> resume opens — instead of a registry and a hydrating flag of the frames client's own. solid: `hydrateWindow(id, fn, scope?, o = getOwner())` factored out of `resumeBoundaryHydration` and exposed as `sharedConfig.hydrateWindow` (@internal): the keys under `id` gathered into the registry (the captured `scope` pair when another hydrate() root replaced the live one, #2917), hydrating on, `o` the claim owner (#3504 — a render the window forces elsewhere is a client render), and `o` the snapshot + live scope when none is open: a late claim no longer re-marks the root's scope through `markTopLevelSnapshotScope` and leaves it unreleased (two C11 cases on seed 91501 with a first cut that skipped this), and inside an open scope (the root pass, an enclosing window) the claim joins it and releases with it. A window opened after hydration-done turns snapshot capture on for its span and clears it, as the old setter dance did. `sharedConfig.claimRoots` typed; the claimant sets it. The resume path's behaviour is unchanged (hydration mode off after a resume, whatever the flag read before). web/frames: `claimRender` is one `createOwner({ id: prefix })` and the window call. Deleted: `gatherClaims` (the range walk), `hasPendingFragment` (the window always engages), the root-registry hand-over, and the flip of `sharedConfig.hydrating` through its setter — which reset hydration-done and re-ran `checkHydrationComplete` from outside the runtime. `adoptBoundary` captures the registry/gather pair it adopts under so a claim long after (under the hold, at a fragment's reveal) gathers against the frame's root — pinned by c01-claim-window-roots (fails without the capture). web: `gatherHydratable`'s prefix gather selects natively (`[_hk^="…"]`); it runs once per adopted occurrence now, not only per late resume. Size (local, vs the integration branch): frames eager 43,414 → 42,932 min / 13,787 → 13,678 br (−482 / −109); app hydrating (no stores) 52,626 → 52,744 min (+118) / 17,728 → 17,833 br; compiled hydrating 99,257 → 99,381 (+124) / 31,023 → 31,099; page base 145,757 → 145,501 (−256) / 44,882 → 44,885; page live 157,720 → 157,464 (−256) / 48,595 → 48,631. The hydrating scenarios are over their caps (brotli); no cap raised. Pins: solid hydrate-window.spec (the seam's contract), web c01-claim-window-roots (multi-root). Suites: solid 830; web client 1142 / 1 xfail, server 1474 / 3 xfail, hydrate 367 / 4 xfail; harness 500 cases × 2 seeds, 0 findings. Co-authored-by: Claude via Cursor <noreply@cursor.com> * docs(server-components): consistency contract — C1's mechanism is the claim window (A2b); the multi-root pin listed Co-authored-by: Claude via Cursor <noreply@cursor.com> * docs(server-components): record the 2026-10-06 rulings on A2b — the park is unconditional; hydrateWindow's hydrating cost accepted - frames-rulings 3.6: "Landed" — the park is unconditional (post-done claims under corollary 4 have no hydration state that says "claim"); S1's `claiming` hint at plan step C3 keys it on the claim again. - frames-rulings 3.2: cost as landed — #3837 + #3840, frames −109 br / hydrating +105 br; the hydrating cost accepted, caps raised under a Size-Exception at the next integration PR. - consistency contract R10 / C19: the park's key described as it landed. - savings-pass plan §3 row A2: landed in two parts, measured vs estimate, further solid-side seams measured on an edited dist copy before they are written. * fix(web/frames): A1b + A4 S-ref — refs settle at the write through the response's table; the dedupe, the latches, stageTables/STAGED_DATA/onStream and the threaded resolver deleted S-ref (frames-rulings 1.3, L1). `createFrameHost.apply` settles a `slot` chunk's `{$ref}` args at the write, through the integration's table for the chunk's RESPONSE (`FrameHostOptions.resolve(ref, frameId, version, current)`), so the record the store holds — and every mount reads — carries values, never refs, and a later response's data can answer none of them. A key the response has not delivered yet resolves to a pending read owned by the store (keyed by version and key; stamped `s`/`v` as a serialized promise once it settles): its `data` chunk settles it and re-applies the records it was the last open read of, the stream's `complete` or `error` rejects it (L1 — a value that never comes surfaces where it is read, instead of leaving the record silently unapplied), a bump drops the superseded response's waits unanswered. A FRESH mount waits for its record to settle (`record.pending`), so the frame's shell shows at its landing with the range as the server left it and the mount's covering boundary pends on the landing alone (A0, corollary 4 — a fill whose first read pended into that boundary would hold the frame's own address follow behind the fallback); a MOUNTED occurrence takes the read as it is and its prop holds what it shows (A17, the DR-2 value tier). The frame no longer resolves refs: `#refsUnresolved`, `#resolveRef`, `#slotResolvedRefs`, `#refArgsUnchanged`, `#reconcileRegions` and the `resolve` parameter threaded through `preview` delete; the host notes a record's `{$frame}` args (`record.regions`) and decoded args (`record.decoded`) so the frame never probes a value (a decoded value may be a live container). Codec data tables are per RESPONSE (frames-rulings 1.2): `client.ts` keys them by frame id and version — the shown response's and a staged refetch's coexist — and drops versions below the store's `current` at the next use. `stageTables`, `STAGED_DATA`, `beginStream`, the prefix walk and `ServerComponentHandlerOptions.onStream` delete; a staged response's data chunks write through to the host as they arrive (into their own table). R.dedupe. A re-sent record always updates the live occurrence's props; the fill's props read through one memo per prop whose `equals` (`sameArg`: identity, then structural for decoded plain data; containers, async values and nodes by identity) is the dedupe — a record whose refs decode to equal values churns no reader. A live container is held boxed in its memo (the core probes a memo's result for `.then`; a pending container's trap throws). `FrameHostOptions.isContainer` / `FrameHost.isContainer` have no caller and delete. R.version / R.error. The applied root and the notified error are keyed by record identity (`#appliedRoot`, `#appliedError`; frames-rulings 2.1/2.2); `Frame.rebase()` (no caller since #3830) deletes. NOT deleted — the gap, reported: `stage`'s buffer + `CONTENT_TOKEN` + `stagedContent.preview/commit` + `FrameImpl#preview`/`host.preview` (reduced). The token is how a refetch of the address a mount SHOWS enters the reactive graph at all (`dynamic` delivers a kept resolution only when its address differs), and the compute-half preview is what stages the refetch's slot args with the transaction that read it: without it a fill deriving optimistic intent over an arg re-derives from the OLD arg in the pass that dissolves the intent (`frames-optimistic-hold` ×3, principles §9.2.2) — one flush before the effect-half commit pushes the new one. C15 itself stays green either way (the DOM never shows the tear; a memo does). Pins: C5 (b, c) re-pinned to observe the rotation through the fill (the host's resolver is no longer a surface); `frames-flight-delivery`'s `onStream` probe → a probe registration seeded from the resident store. * fix(web/frames): A4 S-record — the document declares a slot record at its marker; the #2968 poll deletes The document face (`createDocumentSlotProps`) writes `sc:slot:<fid>:<occ>` as a DECLARED pending value under its key — serialized at the marker, ahead of the fill and of any fragment that carries the range — and settles it with the args once they are classified: the shape a fragment's `<key>_fr` takes, so the adopting client awaits a record that trails its range's reveal through the value's own `.then` instead of polling the registry for a plain write. `adoptBoundary.drainRecords` reads a settled declaration's stamp synchronously (an adopt-time claim must not take a pending beat — readHydratedValue's rule), awaits a pending one, observes a rejected one. A sync render (`renderToString`) has no later script and no serializer for a promise: it writes the settled value, as before. A fill that throws settles its declaration empty so the response is not held open. Output shape: ≈ +32–38 B per document slot record (measured over the eight artifacts carrying one; the resolver/settle helpers are the page's `_fr` declarations' already). The 148 artifacts re-recorded: 8 changed. Client: `#recordRefresh` and the `setTimeout` re-drain poll delete, with `FrameOptions.recordsPending` / `FrameOptions.drainRecords` (no caller); a recordless called occurrence waits — the declaration's settle is the write that re-syncs it. The hold (3.2) is unchanged. No new wire TYPE: the declaration is the hydration-value mechanism the page already uses for fragments, applied to slot records (document face only). Pins: C2 (a2) flips to `test` (the record settled after the reveal, parser done, no fragment pending). The late-record pins re-shaped to the declared protocol (declared at boot/with the fragment, settled where the plain write was): c01 (window roots), c03 (a), c04 (b), c09 (c), c10 (b), c14 (a), c19 (b, c, e), hydration/adopted-slot-late-record; the harness declares a render occurrence's record with the markup that carries its range and settles it at the `record` event. Campaign: 500 cases × seeds 3289, 91501 — 0 findings. * docs(server-components): frames rulings — 1.2/1.3's carriers and the A1b removals landed (A1b + A4); the preview kept as the Transaction's carrier; public surface updated * fix(web/frames): A7 — a frame's error is an errored async value: the landing rejects on :error, the content node throws to the client <Errored>, reset re-asks (a new flight for the same address) A0, corollary 4 outward (frames-rulings 3.3): a frame is one async value, and its `:error` is that value rejecting — exactly as any `createAsync` that rejects. `FrameHost.landing` rejects with the error record at the response's error write; the mount's content node throws it to the nearest client `<Errored>`, so the covering `<Loading>` never releases over an empty `<solid-frame>`. An error AFTER the landing (the same response erroring after its root, a cut-off stream, a refetch's response erroring) is the L2 "errored flight after a landing" case and errors the node as an async iterable that yielded and then threw does — the shown value is not kept beside the error. With no client `<Errored>` the core halts (REACTIVITY_HALTED), as for any uncaught async error. `reset` re-asks: the `<Errored>`'s reset recomputes the node that threw; it re-reads `host.landing(address)`, and an errored landing is not a landing for a fresh consumer — the re-read is a promise for the next flight, which the mount opens itself (`dynamic`'s factory is hoisted and never re-runs for a reset): the handler records the call behind every address it handles or the document answered (`callFor`, @internal) and the client re-invokes it through the same declaration shape (a GET read stays GET). A response for an address whose mounts show an error writes through instead of being staged. Pins: frames-errored-reset-refetch.spec (new, A6's .fails pin made test + 8 arms); the lifecycle error arms, the switch-errored arm, C17 (b) and frames-live's error arms re-pinned to the new reading; four switch pins now send `complete` before closing (a close before complete is a death, which now errors the frame). Co-authored-by: Claude <noreply@anthropic.com> * fix(web/frames): A7 — the re-ask mints its callable through the late-bound RPC seam, not by importing the transport The first cut imported `createServerReference`/`GET`/`invoke`/`withMeta` from the server-function client entry, which retained the whole transport in the frames eager graph (+11 KB min on the frames scenario). The registry's RPC slot (`provideServerFunctionRPC`, read by routers for the same reason) now carries `createServerReference(id)` on the client half, and the frames client reads the slot and the declaration-metadata brand through their registered symbols — the pattern the frame runtime already uses for every cross-bundle brand — so neither bundle retains a second registry copy. The error tick shared by both mount faces is one helper (`failing`). Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web/frames): A3 — a reveal applies its range; R.reveal's readiness/retry model deleted frames-rulings 2.3 (a reveal is an apply) applied to the stream face's own segments: a revealed segment's content is applied against the store as it is revealed — its fills mount (as before) and the segments whose placeholders it carries reveal INSIDE it, in the same flush and before a reconstructed boundary commits the content — so `#flush` makes one pass over the frame's segments instead of retrying until no pass progresses (the store-model analogue of `$dfd` the re-attribution classed R.reveal), and a segment nested in content a pending fill holds reveals into the detached fragment instead of waiting for a chunk that never comes (pinned: two nested arms in the lifecycle matrix). The second ledger goes with it: `#revealed` / `#fallbackShown` / `isRevealed` — a reveal's applied state is the content record it applied, a fallback's the gate it materialized, by identity (2.1), in the one applied map the hole appliers already use; whether a segment is SHOWN is the DOM's to say (2.4: its placeholder is gone once it swapped). The segment swap's DOM half, the style gate and the per-flush content + gate + placeholder test stay (T.morph, F.assets, and the test the re-attribution says always stays). `Frame.isRevealed(segment)` (public, @experimental) is removed — no caller. Measured (frames eager, min): −45 against A7's head; the estimate (≈ −140 br) counted the fallback pass and the style gate, both kept. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — 3.3's outward face landed (A7: the landing rejects, a later error errors the value, reset re-asks; the reach gap recorded), 2.3's stream-face half landed (A3); the plan's A3 row measured Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): pin the adopted face of frames-rulings 3.3 — an sc:live error op errors a dynamic mount over the document's element, reset re-asks the intercepted call; the direct-placeholder gap Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: A5′ — fragment ownership by rendering, G9 collapse, claimRegionFragments deleted Ruled 2026-10-06 (12:55): a placeholder inside a server component's element is the frame's content by rendering, not by adoption. solid-js (hydration ledger): - fragmentPolicy swaps post-done when the fragment is claimed OR owned — ownedFragment(id): the pl-<id> template is in the document and _$HY.fa(pl) says the integration owns it. No _adoptedRoots, no replay. - _$HY.fr is { pending, subscribe }; claim/release removed (releaseFragment deleted; claimFragment stays for initBoundaryResume). - fragmentPending reads a revealed fragment from _$HY.v before its _fr stamp (the producer's real order: swap script, fe, then the settle) — the latent exhaustion bug under which a page's last reveal never read as exhaustion. @solidjs/web (frames client): - installRevealHook installs _$HY.fa once: pl.closest("[data-fid]") not in disposedFrames (C14: a boundary disposed in place marks its element and the region elements inside it). - claimRegionFragments / claimedFragments / cascade claim half / release loop deleted; the dev-only (c1) rejection report stays as an IS_DEV pl-* scan (0 prod bytes). - documentBoundary pends on awaitBoundary(id) — the intercept's one deferred answer; boundaryWaiters deleted (G9). Pins: adopted-swap-post-done.spec (G4 — adopted/then post-done; swap before the adoption with synchronous record read; nested region; held-outside control), boundary-arrival.spec (G9 — pend then adopt; exhaustion in the real order through the shipped $df; shared answer; disposed during the wait), C14 (e) dispose in place + control, C12 (c3) post-done client half, C2 (c2) re-pinned (no hold, no replay), frames-late-boundary-client swapIn stamps _fr after fe. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): A5′ landed — rulings 3.3 claimant by rendering (measured table), decided-by-default 8 superseded, contract mechanism refs, plan row A5 Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: one sweep is one chunk — the `ops` member (C13) The sink's `sweep()` collects the hole / attr re-emissions one pass produces and emits them as ONE `{ type: "ops", id, version, ops: [...] }` chunk on the stream face (one wire line) and one `sc:live` op of the same shape on the document face; a sweep that changes one binding emits that member alone, addressed, as before. Members ride unaddressed — the envelope addresses them. Client: `chunkToRecords` merges a unit's members into one record map, so `FrameImpl.apply` writes them in one write and `#flush` lands them in one hole pass; the hole pass now announces `frame:applied` ONCE per flush (it announced per hole, which let a listener read the DOM with one hole of a sweep moved and its sibling not). `applyFrames.drain` and `applyLiveOp` pass the unit through unchanged. The document op log (`liveOps`, last-value-wins per target) flattens a unit into its members. Pins: `c13-sweep-atomic` (a, b) flip to `test`, fed the unit as the sink now emits it, plus a (log) arm for the catch-up replay; `test/server/frame-sweep-ops.spec.tsx` pins the sink on both faces (a two-hole sweep → one unit; a one-hole sweep → the plain member). `frame-live-holes-projection` reads the unit's members. Wire: additive `FrameChunk` member — RFC addendum in documentation/server-components/frame-streams-rfc.md, with what an old consumer does with it (drops the write; under-updates, never tears). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: the plain response's streaming bound — `complete.bound` A plain (non-`live`) server component whose content reads a standing source kept its response open until the source settled — never, for a generator that never returns — with none of `live`'s reconnect semantics. `frameStream` now ends such a response at a bound, detectably: - `bound: "yields"` after `maxYields` emitting sweeps past the first flush (default 64; a sweep that emits nothing is not a yield — the sink's new `onYield` hook counts them); - `bound: "time"` `maxDurationMs` after the first flush (default 30 000), or when the request's `signal` aborts after the first flush (a platform deadline the client can tell from a death); before the first flush, or for a `live` response, the abort stays the death it was. The sink's end-latch runs as for any completion, `complete` carries the bound, the body closes, and the render is torn down quietly — the abort reason carries `quiet: true`, which `renderToStream` reads as "the response chose to end" (no SSR_STREAM_ABANDONED). A body's own cancel (the reader left) stays a death: never dressed as a bound. A `live` response is never bounded. Client: `:bound` is stored beside `:complete`; the frame lands as on any `complete`; in dev the host warns once per cut-off, naming `live()`. Public surface: `FrameStreamOptions.maxYields` / `maxDurationMs` (new); `createFrameSink(emit, frame, have, hooks?)` (fourth parameter, `onYield`) and the sink's `end(bound?)`; `FrameChunk`'s `complete` gains `bound?`. RFC addendum in frame-streams-rfc.md. `frameTransformResult` takes the defaults (it is a policy function with no options object). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: a server <Loading>'s post-flush failure renders its outcome (C12 c) A server `<Loading>` inside a server component that fails after the first flush has no client twin to render over its position; the fragment used to carry a blank (`" "`) and the position emptied silently (R6). The document face now renders what the SERVER rendered for the outcome, as the stream face's `meta.error` path already revealed (frames-rulings 3.3, A0 corollary 4 inward): - the nearest SERVER `<Errored>`'s fallback for the error, at the `<Loading>`'s position — asked through the boundary error handler's new `outcome` mode (`createErrorBoundary` answers with its fallback as finished markup when it is inside the component's scope; a `<Loading>` between passes the question up; an `<Errored>` outside the component — the app's twin at t = 0 — answers nothing). Rule: "a post-flush error inside a server component's <Loading> shows the nearest <Errored>'s fallback at the boundary's position". Ids are the component's own hydration-free scope's; head / asset registrations drop as before; - with no server `<Errored>` the error ESCAPES the component — the frame as one async value errored, the outward face: the stream face's unkeyed `error` chunk (`:error`); the document face's `sc:live` `{ type: "error", fid, error }` op, which only the owning adopted boundary applies (`applyLiveOp`'s `fid` gate now covers unkeyed error ops) — and the position keeps the boundary's own fallback, never a blank. `_fr` still rejects and the keyed error chunk still rides. Outside a server component nothing changes: the blank the client twin renders fresh over. Pins: C12 (c2) flips to `test` (the page carries the Errored's fallback as the server now writes it); (c3) added for the escape arm; `test/server/frame-fragment-error-outcome.spec.tsx` pins the sink on both faces, the nested-Loading pass-up, and the unchanged non-component case. The maintainer's refetch question (a) is pinned `test.fails` in `test/frames-errored-reset-refetch.spec.tsx`: it fails at its first step — the client `<Errored>` never catches a frame's `:error` on this branch (the landing resolves on it) — and the two-part fix is described in the pin and in frames-rulings 3.3. Internal surface: `HydrationContext.registerFragment`'s resolver takes a third `escaped?: { frame?: string }`; `HydrationContext.frameId` (`@internal`, set by `frameTransformDirectResult`); the module-internal `ErrorContext` handler takes `(err, outcome?: true)`. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — the server half built (A6), the 2026-10-06 approvals, the refetch rule under 3.3 - The server-half drafts section gets a status block: the three drafts as built on `fix/frames-a6-server-half`, each with where it differs from the draft (the per-flush `frame:applied`, the signal-as-time-bound, the document face's escape op) and what is not built (the client's outward `:error` face). - The maintainer's approvals of 2026-10-06 recorded: A4 = the declared slot record (not the `_$HY.r` hook); A1b's surface removals approved ("not even beta"); S-adopted to be built; the 30 KB target stated against `page: base` without the router, to be reviewed after the first size pass. - 3.3 gains the refetch rule (a) as a ruling-by-A0 with its pin status (`test.fails`, failing at the client <Errored> never catching) and the two-part fix; (b) what slot content can reach today (nothing frame-specific; full app context) and the smallest addition (`SlotContext.address`). - The as-landed table's A6 row, the still-red list and the public-surface list updated with A6's items. - Savings pass §6 decision 5: "accepted for now; review after the first size pass" (maintainer, 2026-10-06). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: the A6 client half, trimmed (−56 B min) `chunkToRecords`' `ops` arm as one `Object.assign` over the mapped members; `:bound` written beside `:complete` unconditionally (`undefined` for a settled response, as `holes` is for a root without them); `applyLiveOp`'s frame gate as `op.fid && op.fid !== id`. Frames eager: +99 B min / +46 B br over the branch (was +155 / +42). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * test(hydration): generic hydration consistency pins and harness arm Answers "are the frames consistency reds SC-only, or generic hydration holes?" for Solid 2's plain `hydrate()`: classifies C1–C19 / R1–R10, drives the generic twin of every candidate through a frames-free page (two streamed `<Loading>` boundaries over module-level state, rendered by `renderToStream` in both fragment orders), by hand and under a property harness, and pins what it finds. Tests and docs only; no engine change. Six generic reds (`test.fails`, each with a control): - GH1 C19 — a memo created before snapshot capture (module-level) is read live by a boundary resume's claim pass; the server text stays. - GH2 C19 — a shell async memo adopted pending (no creation snapshot), re-run by a client write before a later boundary resumes — same. - GH3 C19/C1 — a store write to a leaf no reader materialized is not snapshotted: `<For>` at the resume renders the post-write length, key miss for the extra row, list short until the next structural change. - GH4 C9/C12/events — a boundary resuming while a shell async source is in flight commits its fallback over the settled server content at the snapshot release (unrevealed core boundary); the server nodes are detached then re-attached, a click queued on them is lost. - GH5 C3 — hydration-done does not count a root's module preload: a second root finishing synchronously drains done while the first still waits; the bootstrap then stops capturing events for it. - GH6 C14 — disposing a root during its module preload does not cancel the deferred render; the root mounts after dispose and stays live. Holds confirmed on plain pages (1000 harness cases, two seeds, nothing outside the six): C2, C3 for `<Loading>` holds, C10, C12 at settle points, C14 for boundary disposal, queued-event replay, the #3504 snapshot for plain signals. packages/web/test/consistency/generic/: scenario.ts, run.tsx (oracle), campaign.spec.tsx (opt-in, `CONSISTENCY_FUZZ=1`, same knobs as test/consistency/harness), replay.spec.tsx (GH1–GH4 + holds), preload-hold.spec.tsx (GH5, GH6), README.md. Shared page test/harness/generic-hydration.tsx; artifacts from test/server/generic-hydration.gen.spec.tsx. Contract doc: new section "Generic hydration — classification and pins (2026-10-06)". Co-authored-by: Claude via Cursor <noreply@cursor.com> * size: raise hydrating caps (maintainer Size-Exception); frames/page caps unchanged The three hydrating scenarios carry A2b's sharedConfig.hydrateWindow and A5′'s _$HY.fa in solid-js — costs the maintainer accepted 2026-10-06 (frames-rulings 3.2 "Cost as landed", 3.3). Caps set at measured + 10 B at the 0.01 KB step, recorded minified updated: app: hydrating (no stores) 17.73 -> 17.85 KB (52,626 -> 52,794 min) app: hydrating + stores 28.93 -> 29.08 KB (91,684 -> 91,858 min) app: compiled hydrating 31.03 -> 31.09 KB (99,257 -> 99,431 min) No ratchet: frames eager / page base / page live measure over their caps by 14 / 143 / 115 B brotli with minified −435 / −138 / −138 vs the recorded sizes (the gate's minified rule passes them with a warning); nothing is below its cap by a 0.01 KB step that this PR moved. * docs(server-components): Phase A gate taken — reds green / unrepresentable, harness clean on two seeds, frames eager measured against the target; rulings status → Phase A landed Co-authored-by: Cursor <cursoragent@cursor.com> * test(server): the generic-hydration artifact renders on a fake clock stepped 1 ms — its three flush points are three chunks on every host CI (#3849) ran the server suite under load: the page's 5 ms data value and its 15 ms fragment fell due in one event-loop turn, deferFlush wrote them as one chunk, and the hydrate suite then read a two-chunk artifact — every generic schedule's chunk indices shifted (C2 with no chunk → the C14 dispose pin threw at 'matchAll'; the GH1/GH2 'write between the reveals' pins became writes after both reveals and passed, failing as test.fails). Locally the same artifact self-re-recorded under the parallel suite. The generator now steps a fake clock (setTimeout + setImmediate faked) one millisecond at a time so each timer fires in its own turn with its flush; the chunks are byte-identical to the committed artifacts and stable under load (10/10 runs beside the full server suite). The assertions pin the shape: exactly three chunks, the first a script with no template, the other two fragment templates. Co-authored-by: Cursor <cursoragent@cursor.com> * size: re-derive the hydrating caps after merging next (#3849) CI Size run 37666601776 on the merge measured the three hydrating scenarios over next's caps (+168 / +174 / +174 B minified: A2b's sharedConfig.hydrateWindow and A5′'s _$HY.fa in solid-js). Caps set at CI brotli + 10 B rounded up to 0.01 KB, recorded minified from CI: - app: hydrating (no stores) 17.73 -> 17.85 KB (floor; 52,841 B min) - app: hydrating + every store primitive family 29.02 -> 29.09 KB (92,114 B) - app: compiled hydrating 31.06 -> 31.09 KB (99,477 B) Under the PR body's Size-Exception. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid
added a commit
that referenced
this pull request
Oct 7, 2026
… B, C2–C6, residue 1–4) (#3860) * fix(solid): a claim reads the trace's snapshot; the backlog lands after hydration ends (frames-rulings 3.6 (iii), the 3e port) S1's third commit (9927ddddd) ported onto next, minus its `claiming` hint: - `materializeContainerTrace` parks a replayed backlog beyond the snapshot (`limit = 1`) until hydration ends — `onHydrationEnd`, the next microtask when none is in progress — then releases it with a version bump so the compute drains the backlog as one ordinary update. A trace is materialized at a fill's arg-read; when that fill CLAIMS adopted markup the snapshot is what the markup was rendered from, the claim trusts the markup (a text hole is never rewritten during a claim), and a store already past it left the DOM diverged for good. Release order (3.2): claim → the frame's hold release → done → backlog. A failure applies in order, after the parked patches. The park is unconditional (every replayed backlog), not keyed on hydration being in progress at materialization: the projection's first compute runs at creation, materialization precedes the frame's claim window, and a claim made AFTER hydration-done — an occurrence inside a server <Loading> whose fragment reveals after done (corollary 4: the server's inner loading state registers nothing) — has no hydration state that says "claim". Keyed on `isHydrationInProgress()` the harness kept 7 / 7 such cases red on the two seeds. A fresh mount pays one beat for not being told apart: its backlog lands a microtask after the snapshot, before any paint. - The projection's root is DETACHED (`runWithOwner(null, createRoot)`): rooted under the reading owner, a trace revived at t=0 consumed one child id while one revived by a late claim consumed none, and a keyed sibling after the frame hydrated under different keys in the two runs. Pins: harness C19 ×2 flip to `test`; `c19-claim-reads-snapshot.spec.tsx` (the t=0 claim past the markup, the deferred claim under the hold, the release order, the post-done claim at a reveal, the C11 consequence, id determinism — S1's container-trace-hold-{snapshot, hydration-end, id-determinism} re-cut for next); solid `container-trace.spec.ts` (id neutrality, the park's three timings, the in-order failure). Harness oracle: the settled trace law exempts a settle point inside a claim's park (contract C11, "every observable point" read as "outside a claim's park"); the end stays strict. Campaign, 500 cases: seed 3289 83 → 0, seed 91501 79 → 0, every law un-ignored. Contract: C11 amended, C19 re-read under (iii), R10 corrected (S1's path is the park, evidence for (iii) not (i)), the fix order's item 3 decided. Co-authored-by: Claude via Cursor <noreply@cursor.com> * feat(solid,web/frames): an adopted occurrence claims through the hydration window (hydrateWindow); R.claim deleted The savings plan's A2, S-hold's window form (frames-rulings 3.1 / 3.2): the frame's hold is `initBoundaryResume`'s registration (#3831's `holdBoundary`, kept), and the resumed fill re-enters hydration through the same window a streamed <Loading> resume opens — instead of a registry and a hydrating flag of the frames client's own. solid: `hydrateWindow(id, fn, scope?, o = getOwner())` factored out of `resumeBoundaryHydration` and exposed as `sharedConfig.hydrateWindow` (@internal): the keys under `id` gathered into the registry (the captured `scope` pair when another hydrate() root replaced the live one, #2917), hydrating on, `o` the claim owner (#3504 — a render the window forces elsewhere is a client render), and `o` the snapshot + live scope when none is open: a late claim no longer re-marks the root's scope through `markTopLevelSnapshotScope` and leaves it unreleased (two C11 cases on seed 91501 with a first cut that skipped this), and inside an open scope (the root pass, an enclosing window) the claim joins it and releases with it. A window opened after hydration-done turns snapshot capture on for its span and clears it, as the old setter dance did. `sharedConfig.claimRoots` typed; the claimant sets it. The resume path's behaviour is unchanged (hydration mode off after a resume, whatever the flag read before). web/frames: `claimRender` is one `createOwner({ id: prefix })` and the window call. Deleted: `gatherClaims` (the range walk), `hasPendingFragment` (the window always engages), the root-registry hand-over, and the flip of `sharedConfig.hydrating` through its setter — which reset hydration-done and re-ran `checkHydrationComplete` from outside the runtime. `adoptBoundary` captures the registry/gather pair it adopts under so a claim long after (under the hold, at a fragment's reveal) gathers against the frame's root — pinned by c01-claim-window-roots (fails without the capture). web: `gatherHydratable`'s prefix gather selects natively (`[_hk^="…"]`); it runs once per adopted occurrence now, not only per late resume. Size (local, vs the integration branch): frames eager 43,414 → 42,932 min / 13,787 → 13,678 br (−482 / −109); app hydrating (no stores) 52,626 → 52,744 min (+118) / 17,728 → 17,833 br; compiled hydrating 99,257 → 99,381 (+124) / 31,023 → 31,099; page base 145,757 → 145,501 (−256) / 44,882 → 44,885; page live 157,720 → 157,464 (−256) / 48,595 → 48,631. The hydrating scenarios are over their caps (brotli); no cap raised. Pins: solid hydrate-window.spec (the seam's contract), web c01-claim-window-roots (multi-root). Suites: solid 830; web client 1142 / 1 xfail, server 1474 / 3 xfail, hydrate 367 / 4 xfail; harness 500 cases × 2 seeds, 0 findings. Co-authored-by: Claude via Cursor <noreply@cursor.com> * docs(server-components): consistency contract — C1's mechanism is the claim window (A2b); the multi-root pin listed Co-authored-by: Claude via Cursor <noreply@cursor.com> * docs(server-components): record the 2026-10-06 rulings on A2b — the park is unconditional; hydrateWindow's hydrating cost accepted - frames-rulings 3.6: "Landed" — the park is unconditional (post-done claims under corollary 4 have no hydration state that says "claim"); S1's `claiming` hint at plan step C3 keys it on the claim again. - frames-rulings 3.2: cost as landed — #3837 + #3840, frames −109 br / hydrating +105 br; the hydrating cost accepted, caps raised under a Size-Exception at the next integration PR. - consistency contract R10 / C19: the park's key described as it landed. - savings-pass plan §3 row A2: landed in two parts, measured vs estimate, further solid-side seams measured on an edited dist copy before they are written. * fix(web/frames): A1b + A4 S-ref — refs settle at the write through the response's table; the dedupe, the latches, stageTables/STAGED_DATA/onStream and the threaded resolver deleted S-ref (frames-rulings 1.3, L1). `createFrameHost.apply` settles a `slot` chunk's `{$ref}` args at the write, through the integration's table for the chunk's RESPONSE (`FrameHostOptions.resolve(ref, frameId, version, current)`), so the record the store holds — and every mount reads — carries values, never refs, and a later response's data can answer none of them. A key the response has not delivered yet resolves to a pending read owned by the store (keyed by version and key; stamped `s`/`v` as a serialized promise once it settles): its `data` chunk settles it and re-applies the records it was the last open read of, the stream's `complete` or `error` rejects it (L1 — a value that never comes surfaces where it is read, instead of leaving the record silently unapplied), a bump drops the superseded response's waits unanswered. A FRESH mount waits for its record to settle (`record.pending`), so the frame's shell shows at its landing with the range as the server left it and the mount's covering boundary pends on the landing alone (A0, corollary 4 — a fill whose first read pended into that boundary would hold the frame's own address follow behind the fallback); a MOUNTED occurrence takes the read as it is and its prop holds what it shows (A17, the DR-2 value tier). The frame no longer resolves refs: `#refsUnresolved`, `#resolveRef`, `#slotResolvedRefs`, `#refArgsUnchanged`, `#reconcileRegions` and the `resolve` parameter threaded through `preview` delete; the host notes a record's `{$frame}` args (`record.regions`) and decoded args (`record.decoded`) so the frame never probes a value (a decoded value may be a live container). Codec data tables are per RESPONSE (frames-rulings 1.2): `client.ts` keys them by frame id and version — the shown response's and a staged refetch's coexist — and drops versions below the store's `current` at the next use. `stageTables`, `STAGED_DATA`, `beginStream`, the prefix walk and `ServerComponentHandlerOptions.onStream` delete; a staged response's data chunks write through to the host as they arrive (into their own table). R.dedupe. A re-sent record always updates the live occurrence's props; the fill's props read through one memo per prop whose `equals` (`sameArg`: identity, then structural for decoded plain data; containers, async values and nodes by identity) is the dedupe — a record whose refs decode to equal values churns no reader. A live container is held boxed in its memo (the core probes a memo's result for `.then`; a pending container's trap throws). `FrameHostOptions.isContainer` / `FrameHost.isContainer` have no caller and delete. R.version / R.error. The applied root and the notified error are keyed by record identity (`#appliedRoot`, `#appliedError`; frames-rulings 2.1/2.2); `Frame.rebase()` (no caller since #3830) deletes. NOT deleted — the gap, reported: `stage`'s buffer + `CONTENT_TOKEN` + `stagedContent.preview/commit` + `FrameImpl#preview`/`host.preview` (reduced). The token is how a refetch of the address a mount SHOWS enters the reactive graph at all (`dynamic` delivers a kept resolution only when its address differs), and the compute-half preview is what stages the refetch's slot args with the transaction that read it: without it a fill deriving optimistic intent over an arg re-derives from the OLD arg in the pass that dissolves the intent (`frames-optimistic-hold` ×3, principles §9.2.2) — one flush before the effect-half commit pushes the new one. C15 itself stays green either way (the DOM never shows the tear; a memo does). Pins: C5 (b, c) re-pinned to observe the rotation through the fill (the host's resolver is no longer a surface); `frames-flight-delivery`'s `onStream` probe → a probe registration seeded from the resident store. * fix(web/frames): A4 S-record — the document declares a slot record at its marker; the #2968 poll deletes The document face (`createDocumentSlotProps`) writes `sc:slot:<fid>:<occ>` as a DECLARED pending value under its key — serialized at the marker, ahead of the fill and of any fragment that carries the range — and settles it with the args once they are classified: the shape a fragment's `<key>_fr` takes, so the adopting client awaits a record that trails its range's reveal through the value's own `.then` instead of polling the registry for a plain write. `adoptBoundary.drainRecords` reads a settled declaration's stamp synchronously (an adopt-time claim must not take a pending beat — readHydratedValue's rule), awaits a pending one, observes a rejected one. A sync render (`renderToString`) has no later script and no serializer for a promise: it writes the settled value, as before. A fill that throws settles its declaration empty so the response is not held open. Output shape: ≈ +32–38 B per document slot record (measured over the eight artifacts carrying one; the resolver/settle helpers are the page's `_fr` declarations' already). The 148 artifacts re-recorded: 8 changed. Client: `#recordRefresh` and the `setTimeout` re-drain poll delete, with `FrameOptions.recordsPending` / `FrameOptions.drainRecords` (no caller); a recordless called occurrence waits — the declaration's settle is the write that re-syncs it. The hold (3.2) is unchanged. No new wire TYPE: the declaration is the hydration-value mechanism the page already uses for fragments, applied to slot records (document face only). Pins: C2 (a2) flips to `test` (the record settled after the reveal, parser done, no fragment pending). The late-record pins re-shaped to the declared protocol (declared at boot/with the fragment, settled where the plain write was): c01 (window roots), c03 (a), c04 (b), c09 (c), c10 (b), c14 (a), c19 (b, c, e), hydration/adopted-slot-late-record; the harness declares a render occurrence's record with the markup that carries its range and settles it at the `record` event. Campaign: 500 cases × seeds 3289, 91501 — 0 findings. * docs(server-components): frames rulings — 1.2/1.3's carriers and the A1b removals landed (A1b + A4); the preview kept as the Transaction's carrier; public surface updated * fix(web/frames): A7 — a frame's error is an errored async value: the landing rejects on :error, the content node throws to the client <Errored>, reset re-asks (a new flight for the same address) A0, corollary 4 outward (frames-rulings 3.3): a frame is one async value, and its `:error` is that value rejecting — exactly as any `createAsync` that rejects. `FrameHost.landing` rejects with the error record at the response's error write; the mount's content node throws it to the nearest client `<Errored>`, so the covering `<Loading>` never releases over an empty `<solid-frame>`. An error AFTER the landing (the same response erroring after its root, a cut-off stream, a refetch's response erroring) is the L2 "errored flight after a landing" case and errors the node as an async iterable that yielded and then threw does — the shown value is not kept beside the error. With no client `<Errored>` the core halts (REACTIVITY_HALTED), as for any uncaught async error. `reset` re-asks: the `<Errored>`'s reset recomputes the node that threw; it re-reads `host.landing(address)`, and an errored landing is not a landing for a fresh consumer — the re-read is a promise for the next flight, which the mount opens itself (`dynamic`'s factory is hoisted and never re-runs for a reset): the handler records the call behind every address it handles or the document answered (`callFor`, @internal) and the client re-invokes it through the same declaration shape (a GET read stays GET). A response for an address whose mounts show an error writes through instead of being staged. Pins: frames-errored-reset-refetch.spec (new, A6's .fails pin made test + 8 arms); the lifecycle error arms, the switch-errored arm, C17 (b) and frames-live's error arms re-pinned to the new reading; four switch pins now send `complete` before closing (a close before complete is a death, which now errors the frame). Co-authored-by: Claude <noreply@anthropic.com> * fix(web/frames): A7 — the re-ask mints its callable through the late-bound RPC seam, not by importing the transport The first cut imported `createServerReference`/`GET`/`invoke`/`withMeta` from the server-function client entry, which retained the whole transport in the frames eager graph (+11 KB min on the frames scenario). The registry's RPC slot (`provideServerFunctionRPC`, read by routers for the same reason) now carries `createServerReference(id)` on the client half, and the frames client reads the slot and the declaration-metadata brand through their registered symbols — the pattern the frame runtime already uses for every cross-bundle brand — so neither bundle retains a second registry copy. The error tick shared by both mount faces is one helper (`failing`). Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web/frames): A3 — a reveal applies its range; R.reveal's readiness/retry model deleted frames-rulings 2.3 (a reveal is an apply) applied to the stream face's own segments: a revealed segment's content is applied against the store as it is revealed — its fills mount (as before) and the segments whose placeholders it carries reveal INSIDE it, in the same flush and before a reconstructed boundary commits the content — so `#flush` makes one pass over the frame's segments instead of retrying until no pass progresses (the store-model analogue of `$dfd` the re-attribution classed R.reveal), and a segment nested in content a pending fill holds reveals into the detached fragment instead of waiting for a chunk that never comes (pinned: two nested arms in the lifecycle matrix). The second ledger goes with it: `#revealed` / `#fallbackShown` / `isRevealed` — a reveal's applied state is the content record it applied, a fallback's the gate it materialized, by identity (2.1), in the one applied map the hole appliers already use; whether a segment is SHOWN is the DOM's to say (2.4: its placeholder is gone once it swapped). The segment swap's DOM half, the style gate and the per-flush content + gate + placeholder test stay (T.morph, F.assets, and the test the re-attribution says always stays). `Frame.isRevealed(segment)` (public, @experimental) is removed — no caller. Measured (frames eager, min): −45 against A7's head; the estimate (≈ −140 br) counted the fallback pass and the style gate, both kept. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — 3.3's outward face landed (A7: the landing rejects, a later error errors the value, reset re-asks; the reach gap recorded), 2.3's stream-face half landed (A3); the plan's A3 row measured Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): pin the adopted face of frames-rulings 3.3 — an sc:live error op errors a dynamic mount over the document's element, reset re-asks the intercepted call; the direct-placeholder gap Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: A5′ — fragment ownership by rendering, G9 collapse, claimRegionFragments deleted Ruled 2026-10-06 (12:55): a placeholder inside a server component's element is the frame's content by rendering, not by adoption. solid-js (hydration ledger): - fragmentPolicy swaps post-done when the fragment is claimed OR owned — ownedFragment(id): the pl-<id> template is in the document and _$HY.fa(pl) says the integration owns it. No _adoptedRoots, no replay. - _$HY.fr is { pending, subscribe }; claim/release removed (releaseFragment deleted; claimFragment stays for initBoundaryResume). - fragmentPending reads a revealed fragment from _$HY.v before its _fr stamp (the producer's real order: swap script, fe, then the settle) — the latent exhaustion bug under which a page's last reveal never read as exhaustion. @solidjs/web (frames client): - installRevealHook installs _$HY.fa once: pl.closest("[data-fid]") not in disposedFrames (C14: a boundary disposed in place marks its element and the region elements inside it). - claimRegionFragments / claimedFragments / cascade claim half / release loop deleted; the dev-only (c1) rejection report stays as an IS_DEV pl-* scan (0 prod bytes). - documentBoundary pends on awaitBoundary(id) — the intercept's one deferred answer; boundaryWaiters deleted (G9). Pins: adopted-swap-post-done.spec (G4 — adopted/then post-done; swap before the adoption with synchronous record read; nested region; held-outside control), boundary-arrival.spec (G9 — pend then adopt; exhaustion in the real order through the shipped $df; shared answer; disposed during the wait), C14 (e) dispose in place + control, C12 (c3) post-done client half, C2 (c2) re-pinned (no hold, no replay), frames-late-boundary-client swapIn stamps _fr after fe. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): A5′ landed — rulings 3.3 claimant by rendering (measured table), decided-by-default 8 superseded, contract mechanism refs, plan row A5 Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: one sweep is one chunk — the `ops` member (C13) The sink's `sweep()` collects the hole / attr re-emissions one pass produces and emits them as ONE `{ type: "ops", id, version, ops: [...] }` chunk on the stream face (one wire line) and one `sc:live` op of the same shape on the document face; a sweep that changes one binding emits that member alone, addressed, as before. Members ride unaddressed — the envelope addresses them. Client: `chunkToRecords` merges a unit's members into one record map, so `FrameImpl.apply` writes them in one write and `#flush` lands them in one hole pass; the hole pass now announces `frame:applied` ONCE per flush (it announced per hole, which let a listener read the DOM with one hole of a sweep moved and its sibling not). `applyFrames.drain` and `applyLiveOp` pass the unit through unchanged. The document op log (`liveOps`, last-value-wins per target) flattens a unit into its members. Pins: `c13-sweep-atomic` (a, b) flip to `test`, fed the unit as the sink now emits it, plus a (log) arm for the catch-up replay; `test/server/frame-sweep-ops.spec.tsx` pins the sink on both faces (a two-hole sweep → one unit; a one-hole sweep → the plain member). `frame-live-holes-projection` reads the unit's members. Wire: additive `FrameChunk` member — RFC addendum in documentation/server-components/frame-streams-rfc.md, with what an old consumer does with it (drops the write; under-updates, never tears). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: the plain response's streaming bound — `complete.bound` A plain (non-`live`) server component whose content reads a standing source kept its response open until the source settled — never, for a generator that never returns — with none of `live`'s reconnect semantics. `frameStream` now ends such a response at a bound, detectably: - `bound: "yields"` after `maxYields` emitting sweeps past the first flush (default 64; a sweep that emits nothing is not a yield — the sink's new `onYield` hook counts them); - `bound: "time"` `maxDurationMs` after the first flush (default 30 000), or when the request's `signal` aborts after the first flush (a platform deadline the client can tell from a death); before the first flush, or for a `live` response, the abort stays the death it was. The sink's end-latch runs as for any completion, `complete` carries the bound, the body closes, and the render is torn down quietly — the abort reason carries `quiet: true`, which `renderToStream` reads as "the response chose to end" (no SSR_STREAM_ABANDONED). A body's own cancel (the reader left) stays a death: never dressed as a bound. A `live` response is never bounded. Client: `:bound` is stored beside `:complete`; the frame lands as on any `complete`; in dev the host warns once per cut-off, naming `live()`. Public surface: `FrameStreamOptions.maxYields` / `maxDurationMs` (new); `createFrameSink(emit, frame, have, hooks?)` (fourth parameter, `onYield`) and the sink's `end(bound?)`; `FrameChunk`'s `complete` gains `bound?`. RFC addendum in frame-streams-rfc.md. `frameTransformResult` takes the defaults (it is a policy function with no options object). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: a server <Loading>'s post-flush failure renders its outcome (C12 c) A server `<Loading>` inside a server component that fails after the first flush has no client twin to render over its position; the fragment used to carry a blank (`" "`) and the position emptied silently (R6). The document face now renders what the SERVER rendered for the outcome, as the stream face's `meta.error` path already revealed (frames-rulings 3.3, A0 corollary 4 inward): - the nearest SERVER `<Errored>`'s fallback for the error, at the `<Loading>`'s position — asked through the boundary error handler's new `outcome` mode (`createErrorBoundary` answers with its fallback as finished markup when it is inside the component's scope; a `<Loading>` between passes the question up; an `<Errored>` outside the component — the app's twin at t = 0 — answers nothing). Rule: "a post-flush error inside a server component's <Loading> shows the nearest <Errored>'s fallback at the boundary's position". Ids are the component's own hydration-free scope's; head / asset registrations drop as before; - with no server `<Errored>` the error ESCAPES the component — the frame as one async value errored, the outward face: the stream face's unkeyed `error` chunk (`:error`); the document face's `sc:live` `{ type: "error", fid, error }` op, which only the owning adopted boundary applies (`applyLiveOp`'s `fid` gate now covers unkeyed error ops) — and the position keeps the boundary's own fallback, never a blank. `_fr` still rejects and the keyed error chunk still rides. Outside a server component nothing changes: the blank the client twin renders fresh over. Pins: C12 (c2) flips to `test` (the page carries the Errored's fallback as the server now writes it); (c3) added for the escape arm; `test/server/frame-fragment-error-outcome.spec.tsx` pins the sink on both faces, the nested-Loading pass-up, and the unchanged non-component case. The maintainer's refetch question (a) is pinned `test.fails` in `test/frames-errored-reset-refetch.spec.tsx`: it fails at its first step — the client `<Errored>` never catches a frame's `:error` on this branch (the landing resolves on it) — and the two-part fix is described in the pin and in frames-rulings 3.3. Internal surface: `HydrationContext.registerFragment`'s resolver takes a third `escaped?: { frame?: string }`; `HydrationContext.frameId` (`@internal`, set by `frameTransformDirectResult`); the module-internal `ErrorContext` handler takes `(err, outcome?: true)`. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — the server half built (A6), the 2026-10-06 approvals, the refetch rule under 3.3 - The server-half drafts section gets a status block: the three drafts as built on `fix/frames-a6-server-half`, each with where it differs from the draft (the per-flush `frame:applied`, the signal-as-time-bound, the document face's escape op) and what is not built (the client's outward `:error` face). - The maintainer's approvals of 2026-10-06 recorded: A4 = the declared slot record (not the `_$HY.r` hook); A1b's surface removals approved ("not even beta"); S-adopted to be built; the 30 KB target stated against `page: base` without the router, to be reviewed after the first size pass. - 3.3 gains the refetch rule (a) as a ruling-by-A0 with its pin status (`test.fails`, failing at the client <Errored> never catching) and the two-part fix; (b) what slot content can reach today (nothing frame-specific; full app context) and the smallest addition (`SlotContext.address`). - The as-landed table's A6 row, the still-red list and the public-surface list updated with A6's items. - Savings pass §6 decision 5: "accepted for now; review after the first size pass" (maintainer, 2026-10-06). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: the A6 client half, trimmed (−56 B min) `chunkToRecords`' `ops` arm as one `Object.assign` over the mapped members; `:bound` written beside `:complete` unconditionally (`undefined` for a settled response, as `holes` is for a root without them); `applyLiveOp`'s frame gate as `op.fid && op.fid !== id`. Frames eager: +99 B min / +46 B br over the branch (was +155 / +42). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * test(hydration): generic hydration consistency pins and harness arm Answers "are the frames consistency reds SC-only, or generic hydration holes?" for Solid 2's plain `hydrate()`: classifies C1–C19 / R1–R10, drives the generic twin of every candidate through a frames-free page (two streamed `<Loading>` boundaries over module-level state, rendered by `renderToStream` in both fragment orders), by hand and under a property harness, and pins what it finds. Tests and docs only; no engine change. Six generic reds (`test.fails`, each with a control): - GH1 C19 — a memo created before snapshot capture (module-level) is read live by a boundary resume's claim pass; the server text stays. - GH2 C19 — a shell async memo adopted pending (no creation snapshot), re-run by a client write before a later boundary resumes — same. - GH3 C19/C1 — a store write to a leaf no reader materialized is not snapshotted: `<For>` at the resume renders the post-write length, key miss for the extra row, list short until the next structural change. - GH4 C9/C12/events — a boundary resuming while a shell async source is in flight commits its fallback over the settled server content at the snapshot release (unrevealed core boundary); the server nodes are detached then re-attached, a click queued on them is lost. - GH5 C3 — hydration-done does not count a root's module preload: a second root finishing synchronously drains done while the first still waits; the bootstrap then stops capturing events for it. - GH6 C14 — disposing a root during its module preload does not cancel the deferred render; the root mounts after dispose and stays live. Holds confirmed on plain pages (1000 harness cases, two seeds, nothing outside the six): C2, C3 for `<Loading>` holds, C10, C12 at settle points, C14 for boundary disposal, queued-event replay, the #3504 snapshot for plain signals. packages/web/test/consistency/generic/: scenario.ts, run.tsx (oracle), campaign.spec.tsx (opt-in, `CONSISTENCY_FUZZ=1`, same knobs as test/consistency/harness), replay.spec.tsx (GH1–GH4 + holds), preload-hold.spec.tsx (GH5, GH6), README.md. Shared page test/harness/generic-hydration.tsx; artifacts from test/server/generic-hydration.gen.spec.tsx. Contract doc: new section "Generic hydration — classification and pins (2026-10-06)". Co-authored-by: Claude via Cursor <noreply@cursor.com> * size: raise hydrating caps (maintainer Size-Exception); frames/page caps unchanged The three hydrating scenarios carry A2b's sharedConfig.hydrateWindow and A5′'s _$HY.fa in solid-js — costs the maintainer accepted 2026-10-06 (frames-rulings 3.2 "Cost as landed", 3.3). Caps set at measured + 10 B at the 0.01 KB step, recorded minified updated: app: hydrating (no stores) 17.73 -> 17.85 KB (52,626 -> 52,794 min) app: hydrating + stores 28.93 -> 29.08 KB (91,684 -> 91,858 min) app: compiled hydrating 31.03 -> 31.09 KB (99,257 -> 99,431 min) No ratchet: frames eager / page base / page live measure over their caps by 14 / 143 / 115 B brotli with minified −435 / −138 / −138 vs the recorded sizes (the gate's minified rule passes them with a warning); nothing is below its cap by a 0.01 KB step that this PR moved. * docs(server-components): Phase A gate taken — reds green / unrepresentable, harness clean on two seeds, frames eager measured against the target; rulings status → Phase A landed Co-authored-by: Cursor <cursoragent@cursor.com> * test(server): the generic-hydration artifact renders on a fake clock stepped 1 ms — its three flush points are three chunks on every host CI (#3849) ran the server suite under load: the page's 5 ms data value and its 15 ms fragment fell due in one event-loop turn, deferFlush wrote them as one chunk, and the hydrate suite then read a two-chunk artifact — every generic schedule's chunk indices shifted (C2 with no chunk → the C14 dispose pin threw at 'matchAll'; the GH1/GH2 'write between the reveals' pins became writes after both reveals and passed, failing as test.fails). Locally the same artifact self-re-recorded under the parallel suite. The generator now steps a fake clock (setTimeout + setImmediate faked) one millisecond at a time so each timer fires in its own turn with its flush; the chunks are byte-identical to the committed artifacts and stable under load (10/10 runs beside the full server suite). The assertions pin the shape: exactly three chunks, the first a script with no template, the other two fragment templates. Co-authored-by: Cursor <cursoragent@cursor.com> * frames: Phase B — the server-announced tier mechanism (seam only, no tier cut) The server knows at render time which frames-client capabilities a response or a document needs — it mints each feature — and announces their names so the client starts the tier's import in parallel with the content instead of at first use (frames savings pass §2; decision 3's additive wire): - sink: `needs(tier)` at the mint sites — a binding-slot position read (`bind`), a nested region (`regions`), an assets chunk (`assets`), a traced container in a slot arg (`trace`), a `live` response (`wire`); `announce()` for the response head. - stream face: `X-Frame-Tiers` set by `serverComponentResponse` from what the sync render pass minted (the `ReadableStream` start runs before the Response is built); a tier minted after the head rides in-band as `tiers` on the next chunk out (`FrameChunk.tiers`) — the trace mint precedes the serializer's synchronous emission of the record's initial node, so it lands on the very `data` chunk whose node tree needs it. - document face: `_$HY.r["sc:tiers"]`, re-written cumulative at each mint (every pre-shell write lands in the shell script; the last wins); a `modulepreload` per tier whose URL the integration gave (`frameTransformDirectResult(value, { id, args, tierUrls })`), through `registerAsset("module")`. A sync `renderToString` document announces nothing (no shared render slot) — the client detects. - client: `installServerComponents(host?, { tiers })` takes the loader map, reads the record and starts each load; `applyFrameResponse` reads the header before the body and `chunk.tiers` per chunk; a `data` chunk awaits the tiers it names before decoding. `prepareTier` is idempotent per name; a name with no loader is resident (every name today). The install calls the module's `install()` and flushes every live frame. - the held set is A2's registered set (frames-rulings 3.1 / 3.2): a fresh mount whose tier is absent — a data occurrence's `bind`, a region-carrying record's `regions` — waits like a recordless called occurrence; on the adopt path the frame's hold registers (hydration-done waits, the replay window stays open), on the stream path the record stays pending; the readiness check starts the load itself when nothing announced it (detection is the fallback), and the install's flush mounts it with the record it was held on. Measured before written (edited dist copies, scripts/size): frames eager +473 min / +145 br (≤ +150 budget; the ≈ +100 estimate's ×2.5 pre-estimate was ≈ +250; the first honest cut measured +745 / +233 and was golfed), pages +478 min, non-SC scenarios 0; frames server dist +793 min, the web server entry 0. Pins: test/server/tier-announce.spec.tsx (both faces announce what was minted, nothing when nothing is; the in-band form; the trace on its data chunk; the record cumulative; links only with URLs; sync renders silent), test/consistency/tier-prepare.spec.tsx (idempotence; the record and header reads; in-band + the codec await; the 3.1 hold with two frames and one install — positions untouched, hydration-done waits, both mount; the stream path's buffer-and-retry). preload-links.spec: the late assets chunk now carries `tiers: ["assets"]`. Artifacts: 5 of 150 re-recorded (frame-live-document-* announce `wire`, welcome-status-* `trace`). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(plans): frames savings pass — Phase B landed (measured +145 br vs ≈ +100 est.), decision 3 sub-item: tier chunk URLs (tierUrls + the client loader map) Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: C3 — traces tier: the lazy store materializer on the tier mechanism The container-trace materializer is the store engine's one edge into a server-component page (signals `store/*`, the projection/reconcile machinery, ~8 KB brotli) — and the frames client imported it from `solid-js/internal` and installed it at module load. It is now the frames client's TRACES TIER (frames savings pass §3 row C3 — S1 re-based onto Phase B's tier mechanism, not merged as built), loaded through `prepareTier("trace")`: - `solid-js/internal/container-trace` — a NEW `solid-js` dist entry carrying `materializeContainerTrace(marker, claiming?)` (S1 commit 1's entry, with commit 3's `claiming`). Its own entry because the main build is one flat module: any binding in it that reaches the engine welds the engine to whoever imports the binding. It reaches `createProjection` through `@solidjs/signals` and reads the store wrappers' hydration dispatch (`withStoreHydration`, new, `@internal`), `applyPatches` and `forwardIteratorReturn` back from `solid-js` by name; the server mirrors the three as inert stubs. The materializer leaves `solid-js`'s main and `solid-js/internal` entries. - `@solidjs/web/frames/trace` — a NEW `@solidjs/web` export path, the tier module (`frames/src/trace-tier.ts`): solid's entry plus the plugin's client half (`frame-container-plugin.js`: the shared hook state, the memo, the marker test, the deep revive walk). Its `install()` — B's `install()` shape, unchanged — sets the materializer on the plugin's registered state and the shared host's `revive` (`getFrameHost().revive`, assignable; the eager client wires no reviver). In the dist the tier's import of the client entry is externalized to `@solidjs/web/frames` (instance identity). - The eager client keeps the trigger alone: the loader entry (`tierLoaders.trace`), the container probe read off the plugin's registered state (no copy of the plugin rides the eager client), and the held-set predicate — `needsTrace`: a `{ $tr }` marker anywhere in an adopt-time record's literal args while the tier is not resident holds the occurrence (its server interior on screen; the frame's hold registered as a pending boundary under frames-rulings 3.1, hydration-done waits) and starts the load. The walk runs only while the tier is absent: once it is resident a decoded arg may be a live container whose traps throw, and before it is resident no live container can exist. - S1 commit 3's two frame-side fixes ported: the `claiming` hint (`#invokeSlot`'s `adopted` → `#resolveArgs` → `FrameHostOptions.revive (value, claiming?)` → `reviveContainerTraces` → `materialize`) keys the materializer's parked backlog on the claim again (rulings 3.6 "Landed": the port's unconditional park made every fresh mount pay one beat; now a fresh mount reads the fold of its whole backlog at once); the held-record mount (`#heldRecords`): an adopt-path occurrence held — on its tier, on its record's reads — mounts with the record it was held on (what the server interior was rendered from) and applies a record that replaced it meanwhile as the args change it is. Generalized: every adopt-path hold (`bind`, `regions`, `trace`) uses it. - Not shipped: S1's `FrameHostOptions.prepareData(chunk)` / `prepareArgs` / `#argsUnprepared` (B's `prepareTier` + the held set replace them) and S1's codec-face node scan — B's in-band `tiers` rides the very `data` chunk that carries the plugin's node, so the transport awaits the tier before the chunk decodes without a scan (measured +79 B min / +25 B br to keep; dropped). An un-announced chunk from a producer that predates the tier decodes the inert marker — a skew one package never ships. Measured before written (edited dist copies through scripts/size's bundler, re-attribution §7; then the real build) against Phase B's head e05ba0283: frames eager 43,452 / 13,949 -> 42,968 / 13,866 -484 min / -83 br page base 146,097 / 45,210 -> 122,028 / 38,598 -24,069 / -6,612 page live 158,060 / 48,873 -> 133,899 / 42,147 -24,161 / -6,726 app hydrating (no stores), + stores, compiled hydrating, CSR: 0 / 0 trace.js (lazy, reported not counted) 25,409 min / 8,170 br The deletion alone (F.trace's eager half out of the frames client, the engine + materializer out of the pages) measured -951 / -239 on frames and -28,763 / -8,016 on page base; the trigger left behind +467 min / +156 br on frames (loader entry + probe +160 / +45, the held-set predicate +126 / +67, the claiming thread +6 / -9, the held-record mount +175 / +53) — about 3x the plan's ~150-min estimate, which counted the loader alone. Against `next` @ 9d89df731: frames -446 min / +79 br (Phase A's and B's bytes), page base -6,284 br, page live -6,448 br. `@solidjs/web` server floor +4 B min (`materialize`'s second parameter in the codec plugin the SSR runtime bundles; brotli -9). Caps lowered (the ratchet): page base 44.89 -> 38.61 KB, page live 48.60 -> 42.16 KB; frames eager's cap stays 13.79 KB (76 B over by the earlier steps' bytes; its recorded minified lowered to 42,968). No cap raised. Pins — S1's seven ported through the general seam (the hold re-armed per test by dropping the tier's load through `tierLoads`, the runtime's test seam): `frames-container-lazy-codec` (announced form: the `data` chunk's `tiers` is the trigger), `frames-container-lazy-document` (the production loader), `hydration/welcome-status-lazy` (the artifact's `sc:tiers` record starts the import at install), `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`; `container-trace-hold-hydration- end` RE-PINNED under 3.1 — hydration waits for the load, the mount claims before done (S1 asserted the opposite order). New `consistency/tier-trace- hold.spec` (§1's pin: the un-announced hold with the held-record mount, the announced start-at-install, the codec wait). C3 (b) asserts the hold and the 3.1 order (it was never `.fails` on `next`). The id-drift `.fails` pin stays red under rulings 3.4 (unfixed; a keyed sibling after the frame). The resident cells (`container-args`, C11, C19, the harness) warm the tier with `prepareTier("trace")`. Solid's `container-trace.spec` pins the keyed park. Artifacts: 0 of 150 changed (no wire change). Harness 500 cases, seeds 3289 / 91501: SC arm 0 / 0; generic arm with CONSISTENCY_IGNORE= C1,C9,C19,E 0 / 0. Co-authored-by: Claude via Cursor <noreply@cursor.com> * docs(plans, server-components): frames savings pass — C3 landed (measured −83 / −6,612 / −6,726 br; glue +467 min vs the ≈ 150 estimate), §5 S1 re-based; rulings 3.6 the park keyed on the claim again, 3.4 the id-drift pin on the tree Co-authored-by: Claude via Cursor <noreply@cursor.com> * frames: C4 — regions tier: nested server-content regions as a lazy chunk Nested server-content regions — server content passed as a PROP to a client fill (`{$frame}` in the occurrence's record), rendered as a `<solid-frame>` region element with a frame bound over it that the host routes the region's chunks to — are now the frames client's REGIONS TIER (frames savings pass §3 row C4), loaded through `prepareTier("regions")`: - `@solidjs/web/frames/regions` — a NEW `@solidjs/web` export path, the tier module (`frames/src/regions-tier.ts`): the per-frame region cache (a `WeakMap` the tier keeps, no frame field), element discovery in an adopted interior (`collectRegionElements`, the range walk), the `{$frame}` arm of arg resolution (`resolve`: mint / reuse / rename), the bind of a frame over each region element (`bind`: `createFrame` from the eager entry, the parent linked as `options.parent`), disposal (`unmount`), and the staged preview's two region reads (`changed`, `frames`). The module's exports ARE its appliers: `prepareTier` records the installed module in a dispatch table (`tierModules`) and the frame calls through it; no `install()` — nothing of this tier lives outside the runtime's dispatch. In the dist the tier's import of the client entry is externalized to `@solidjs/web/frames` (instance identity: `createFrame` must be the runtime the app's host routes to); frame-client's pure DOM helpers (`eachInRange`, `makeFrameElement`, `isFrameElement`, exported `@internal`) bundle as the chunk's own copy. - The eager client keeps: `needsRegions` — one test on the host's `record.regions` note (no arg walk) at TWO sites: a fresh mount waits in the held set (adopt path: the hold registers under frames-rulings 3.1, the server interior stays on screen), and a MOUNTED occurrence's new record naming a region stays pending in the store (the live binding keeps the args it shows) until the install's flush — either way the check starts the load (the un-announced fallback); the document face's `sc:region:` drain arm in client.ts (an occluded region's html lands in the store regardless; the frame the tier binds on install seeds from it); the loader entry; an `options` getter on the frame (`@internal`, not on `Frame`) the tier reads the parent's host / claim scope / owner scope from. The slot-resolution thread-up (`#resolveSlot` / `#resolveSlotRecord` / `#removeSlotRecord`) walks a `parent` link (`#outer`) by private access across instances — the three per-region option closures are gone. - The rename machinery (`renameRegion`, the rename arm) is MOVED into the tier, not deleted — flagged: it is live, not dead. A single-flight response renders a shown boundary's regions under the call's address while a direct response and the document render them under the function id, so a flight refresh renames every region of the boundary (`preview`'s region check anticipates exactly this; `lifecycle-matrix/call-driven-args` › regions and `frames-optimistic-hold` pin the rebind). It deletes with S7's store-boundary normalization (principles §5.3), not here. Eager cost of keeping it: 0. Already gone on the base: `isFrameRef` (the host's inline `$frame` note is the detection), `tableFor`'s prefix walk and `#reconcileRegions` (A1b). - `InstallOptions.tiers` types a tier's module as the new exported `TierModule` (`{ install?(): void; [applier: string]: unknown }`): the old `{ install?(): void }` is a TypeScript weak type a module whose exports are its appliers cannot satisfy. Measured before written (edited dist copies through scripts/size's bundler, re-attribution §7), then the real build, against C3's head 89954fa1b: frames eager −1,078 min / −237 br (the deletion alone −1,596 / −423; the glue +518 / +186 — over the ≈ 150-min line, reported: the installed-module table, the loader entry, `needsRegions` at both sites, the tier calls at bind / resolve / unmount / preview, the `options` getter, the `parent` thread-up; TypeScript's TS18030 — no private name in an optional chain — forced the longer `#outer && #outer.#x()` form, +33 min over the edited copy's −250 br); page base −1,083 / −281; page live −1,083 / −261; the four non-SC scenarios 0. Chunk `regions.js` 1,872 min / 805 br (reported, not counted). Vs `next` 9d89df731: −1,524 / −158, −24,812 / −6,565, −24,904 / −6,709. Caps lowered (the ratchet, measured + 10 B at the 0.01 KB step): frames eager 13.79 → 13.64 KB (under the cap for the first time since Phase A), page base 38.61 → 38.33 KB, page live 42.16 → 41.90 KB; no cap raised. Pins: new `consistency/tier-regions-hold.spec` (4 — un-announced `{$frame}` in an adopted record → held under 3.1, interior and region content on screen, mounts with the held record, the adopted region element discovered and bound, no TypeError, hydration-done after; announced → the import starts at install; an occluded `sc:region:` record drained before the tier seeds the bound frame; stream face: a mounted occurrence's region-naming record waits and applies at the install into the live binding). `tier-prepare`'s regions test releases the real module. Resident cells warm with `prepareTier("regions")`: `lifecycle-matrix/call-driven-args`, `frames-hn-client`, `frames-occlusion-client`, `frames-optimistic-hold`, `frames-used-region-client`; `test/server/frame-hn` (the runtime without the entry) registers the loader itself. Harness 500 × seeds 3289 / 91501: SC arm 0 / 0; generic arm (`C1,C9,C19,E` ignored) 0 / 0. Wire unchanged; 0 of 150 artifacts changed. Suites: web client (128 files), server (159), hydrate + consistency (85), solid (42) green; `test-types` green on web and solid. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(plans, server-components): frames savings pass — C4 landed (measured −237 / −281 / −261 br; deletion −423, glue +186 br vs the ≈ 150-min line; chunk 1,872 min / 805 br); principles §4 row 19 re-dispositioned — the region rename is live (the flight path renames under the call's address), moved into the regions tier, deletes with S7's normalization Co-authored-by: Cursor <cursoragent@cursor.com> * test(frames): pin the stylesheet gate and inline-style application (ensureStylesheet, applyInlineStyles) The SC layer audit's one 0-coverage gap (§2.4): no client test reached frame-client's stylesheet gate or inline-style application. Pinned before the assets tier moves them (frames savings pass §3 row C5), as the code behaves today: - a stylesheet named by a segment's assets record is inserted once content and reveal are in the store (the placeholder may still be missing) and the segment reveals when the link settles — load or error; a link already in the document counts as settled; one link per href; one waiter per frame on a pending link however many flushes re-check; a disposed frame's settle is inert - the gate is the assets record, not the reveal chunk's waitForStyles flag - the link is requested at the reveal record, not at the assets record - inline styles never gate; they land in the head in entry order, deduped by data-asset id (a document-emitted element is adopted), before the segment's content, after its stylesheet gate * frames: C5 — assets tier with the reveal-readiness term The head mirror a segment's `seg:<k>:assets` record drives — the stylesheet gate (ensureStylesheet), module and typed preloads, inline styles, findHeadElement / qualifierValue — leaves the eager frames client for the lazy chunk `@solidjs/web/frames/assets` (frames/src/assets-tier.ts), loaded through the tier mechanism (frames savings pass §3 row C5; §1 row "assets"; §6 decision 2 — ruled: tiered WITH the term). The eager client keeps the record cases (chunkToRecords' `assets`, the host's `seg::assets` accumulate), the walk over `:assets` records (now deduped through the applied map — #processedAssets deleted — and run before the segments, so a segment's inline styles precede its content as the document face orders them), and the reveal-readiness term in assets.styles && !tierReady("assets")?.gate(assets.styles, this) -> not ready A style-gated segment is not ready while the tier is not resident (`tierReady` starts the load if nothing announced it; the server's <Loading> fallback stays on screen) and, once resident, until every named sheet has settled — the reveal is at max(tier load, stylesheet load); no segment reveals unstyled. A segment without stylesheets never consults the tier; a stream with no asset records never loads it; modules, preloads and inline styles are not reveal-gating and apply at the record's arrival when the tier is resident, at the install's flush when it was not. The tier is import-free and has no install(): its exports are the dispatch. `prepareTier` stamps a load's `r` with the module (truthy = resident, as before); `tierReady("assets")` returns it and the client calls `gate` / `apply` off it. `InstallOptions.tiers` loaders resolve `TierModule` (`{ install?(): void } & object`, exported from `@solidjs/web/frames`) so a module with no install type-checks. The server already announces `assets` at every assets-chunk emit site (frame-sink's needs("assets")). Measured before written (edited dist copies through scripts/size's bundler), frames eager / page base / page live, min / br vs C3's head: the whole F.assets group deleted -2,419 / -666, -2,420 / -681, -2,420 / -627; the kept record cases +208 / +64; the term + loader entry + the walk's dispatch +300 / +67. Real build: -1,913 / -534, -1,926 / -628, -1,926 / -496; every non-SC scenario 0 / 0; chunk assets.js 2,040 / 783. Caps lowered (the ratchet): frames eager 13.79 -> 13.35 KB, page base 38.61 -> 37.98, live 42.16 -> 41.67; no cap raised. Pins: consistency/tier-assets-ready.spec (the FOUC guard, 7); the two coverage pins (frames-assets-client.spec, committed first) re-run through the resident tier; preload-links-frame-client warms the tier; tier-prepare's resident stamp is the module. Harness 500 x seeds 3289/91501: SC 0, generic (C1,C9,C19,E ignored) 0; 0 artifacts changed. * frames: C5 follow-up — inline styles hold on the assets tier too The ruling "inline styles are not reveal-gating" predated the tier. With the tier absent, a segment whose assets record carried inline styles revealed at once and its <style> landed one chunk load later, at the install's flush — a bounded unstyled window, which is exactly the FOUC the reveal-readiness term exists to prevent. The term in #segmentReady now reads (assets.styles || assets.inlineStyles) && !tierReady("assets")?.gate(assets.styles || [], this) so while the tier is absent such a segment holds like a stylesheet-gated one (the server's fallback stays; the install's flush lands the style in the assets walk, then reveals the segment); once the tier is resident inline styles still never gate — they apply at the record's arrival, ahead of the segments in the same flush, as before. The assets walk stays a separate pass before the segments. Measured first on the edited dist copy through scripts/size's bundler: +22 min / +14 br on frames eager (the explicit two-branch form was +50; widening the tier's `gate` to accept undefined was +18 but changed its signature — not taken). The real build matched to the byte: frames eager 41,077 / 13,346 (cap 13.35 KB, within; recorded minified left at 41,055), page base 120,124 / 37,969 (+22 / −1), page live 131,995 / 41,650 (+22 / −1), every non-SC scenario 0 / 0. No cap raised. Pins — test/consistency/tier-assets-ready.spec.tsx (7 → 9): tier absent, an inline-only segment holds (loader called once, fallback on screen, no <p>, no <style>) and the install lands the style before the content (one MutationObserver over head and boundary records the order); tier resident, the segment's three records in one frame.apply reveal synchronously with the style, style first; the mixed-stream pin now holds the inline-only segment beside the style-gated one. A mutant with the previous term fails 2 of 9. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * size: ratchet frames eager / page base / page live caps to the integrated head (13.10 / 37.79 / 41.44 KB) Phases B + C3 + C4 + C5 together, measured on wip/frames-tiers-integration against the Phase A base 0aab93230 and next @ 9d89df731 (both built fresh, measured with this head's harness): frames: eager client consumer 40,000 min / 13,083 br -2,979 / -721 vs base -3,414 / -704 vs next page: base server components 119,044 min / 37,772 br -26,575 / -7,261 -26,713 / -7,110 page: live server components 130,915 min / 41,427 br -26,667 / -7,288 -26,805 / -7,168 Non-SC scenarios 0 / 0 vs base. Caps set at measured + 10 B at the 0.01 KB step (the ratchet); recorded minified 40,000 / 119,044 / 130,915. The two unrelated caps the ratchet would also lower by one step (createStore, CSR observe + attribution) are left alone — not this PR's. No cap raised. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(plans): frames savings pass — Phases B + C3–C5 landed together (measured −704 / −7,110 / −7,168 br vs next; every seam ≈ 3× its glue estimate, every deletion held); §4 the page numbers as measured; §6 decision 3 the tier-URL sub-decision as built (three tiers on (i)+(ii), TierModule's index signature) Co-authored-by: Cursor <cursoragent@cursor.com> * frames: trace tier — update-site wait for a mounted occurrence's new `{ $tr }` record `#syncSlots`' mounted branch guarded the update site against the regions tier only (`needsRegions`); the trace tier's wait covered the fresh mount alone. A MOUNTED occurrence's new record whose literal args carry a `{ $tr }` marker while the trace tier is absent — a live slot op minting the page's first trace after the shell, a refetch adding a projection arg — was pushed into the live binding raw, the marker read as the value. Now that record stays pending in the store: the live binding keeps the args it shows, `needsTrace` starts the load (nothing announced it), and the install's flush re-syncs with the tier in place and applies the record as an args change into the same mount with the marker materialized. C4's `regions` update-site wait, mirrored. Pin: consistency/tier-trace-hold.spec "update site" (document face: a live slot op mints the page's first trace post-shell with the tier absent → no raw read, args kept, one load, applied on the install's flush into the same `<li>`). Fails on the base at the first assertion (the loader never called). Measured (exact A/B of the built dist through scripts/size's bundler, base numbers reproduce the Phase 1 record byte-for-byte): frames: eager client consumer 40,012 min / 13,086 br +12 / +3 page: base server components 119,056 min / 37,747 br +12 / -25 page: live server components 130,927 min / 41,396 br +12 / -31 Non-SC scenarios 0 / 0. No cap changed. Web client suite 1187, hydrate suite 439 green; harness seeds 3289 / 91501 ×500: SC arm 0 findings, generic arm (IGNORE=C1,C9,C19,E) 0 findings. Co-authored-by: Cursor <cursoragent@cursor.com> * frames: drop the dead #slotNodes writes for data occurrences The slot sync wrote a data occurrence's consumer elements into #slotNodes at the mount, the rebind and the re-call. The map's only reader is the zombie check, which skips data occurrences (a replaced consumer is a consumer change, not a destroyed mount), so the arrays were built and never read. Independent cleanup ahead of the bind tier (frames savings pass C6). Co-authored-by: Claude via Cursor <noreply@cursor.com> * frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window The chunk `@solidjs/web/frames/bind` (frames/src/bind-tier.ts; `bind.js` and `bind.dev.js` under the `development` condition) takes the `_s:*` marker parsers and the consumer walk (`text` / `positions`), the per-frame consumer sets and rebinders (`sync` / `rebinder` / `unmount`), the morph's owned-position arms (`owned` / `apply`) and the fill's binding (`bind`, `assign` over every consuming element). The exports are the appliers (`tierLoads.bind.r`); no install. `assign` leaves the eager frames client. The eager client keeps the detection: a marker met at the walk while the tier is absent is a note on the found map and the sync holds the frame under frames-rulings 3.1 (hydration-done waits; the readiness check starts the load; the install's flush binds). The hold does NOT bound the delegated-event replay window — the bootstrap queues only under a not-yet-completed `_hk` element and a server component's interior (NoHydration) has none. Option (a) of the ruling closes it: the server stamps a bare ` _hk` on each DOCUMENT-face element with an `_s:on:*` position (ssrClaim / spreadBehaviorMarkers; 4 bytes each; ref-only and attribute-only consumers unstamped; the stream face unstamped — the bootstrap stops capturing at `_$HY.done`); the tier marks the element completed at the bind and drains the queue (`runHydrationEvents`). Bare, nothing gathers or claims the stamp: the ambient sweep skips frame interiors, a prefix gather cannot match the empty value. Kept eager, a deviation from the "nopair" variant: `reconcileChildren`'s text-pair arm — reconciled as ordinary nodes a client-owned text node is removed and, the start marker unchanged, the consumer set compares equal (no rebind; text lost — the pin "a refetch re-sends the empty pairs"). A tier dispatch point there costs what the arm costs. `isAsyncValue` moves to frame-client.ts and is re-exported `@internal` from the frames entry: the tier's import of the entry is the edge that keeps the chunk attached to the page's graph (without it Rolldown splits the shared runtime out of the live page's entry). Behaviour: a data occurrence mounts with the CURRENT record (its positions are written whole at the bind — no claim to match the markup to); its fresh mount also waits on the regions / trace tiers when its record names them; the walk no longer skips a text pair's interior. Pins: consistency/tier-bind-hold.spec (6 — the hold; replay arm (i) a click queued before hydrate() replays at the install; arm (ii) a click and an input during the hold are queued and replay at the bind; the stamp-less mutant loses both; current-record mount; announced → import at install); server/frame-binding-slots gains the stamp pin (event-slot consumers only, document face only); tier-prepare's bind arm releases the real module; frames-binding-slots and hydration/binding-slot-adoption warm the tier. Artifacts: welcome-status-streamed re-recorded (C3's serializer drift, not the stamp). Size (min / br, vs the integration base 6b7213d64): frames eager 36,007 / 11,901 (-4,005 / -1,185); page base 115,087 / 36,647 (-3,969 / -1,100); page live 126,964 / 40,286 (-3,963 / -1,110); non-SC 0; `bind.js` 4,771 / 1,844 (lazy, not counted). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * size + docs: C6 ratchet (frames eager 11.92 / page base 36.66 / live 40.30 KB); the hold is not the replay window Caps set at measured + 10 B at the 0.01 KB step, lower only: frames eager 13.10 -> 11.92 KB (11,901 B; 36,007 minified), page base 37.79 -> 36.66 (36,647; 115,087), page live 41.44 -> 40.30 (40,286; 126,964). No cap raised; the unrelated caps the ratchet would also lower are left alone. Plan §3 row C6: the landed sentence (measured bytes, the ruling, the kept pair arm, the chunk, the Rolldown edge). §1 bind row and the "3.1 participant" definition corrected: a 3.1 hold makes hydration-done wait but does NOT keep the delegated-event replay window open — the C6 probe's click during the hold was never queued (no `_hk` to queue under); option (a)'s ` _hk` stamp on event-slot consumers does. Principles §9.2.3 documents the stamp. frames-rulings 3.1 / 3.2 make no replay claim — unchanged. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * frames: residue 1 — the D list (documentAddress, one dispose map, the zombie heuristic, the default reveal seam, the brand) Five of the residue pass's seven D units (frames-residue-pass.md §1.4 / §2), measured on an edited dist before writing (frames eager, min / br, each alone against the C6 head 36,007 / 11,901; non-SC scenarios 0 / 0): - 2b `documentAddress` and its call: −95 / −44. An adopted mount's address comes with its binding (every serialized reference resolves to the call's binding; a `dynamic` mount is called with the live accessor); a mount with no binding — the per-function placeholder rendered directly — binds the function id. `adopted-claim-args-address.spec` mounts the binding (the production shape) and now asserts the store the frame bound. - 2c the second dispose map folded into `fillScopes`: −73 / −24. A live range binding's `insert` lives under the fill's own owner where there is one (stream-mounted), else under an owner registered the same way, so a re-call disposes whichever path the previous invocation took. - 2e the zombie heuristic and `#slotNodes` (its only reader): −186 / −68. The morph relocates ranges among siblings and recreates nothing (DR-5); "mounted" is `#mountedSlots`. Principles §9.2.3 / the bind notes amended. - 2f the imperative branch of `#revealSegment`: −34 / −19 as built (the ceiling −121 / −30 would make `FrameOptions.reveal` required; a default seam keeps it optional). A frame created without `reveal` reveals through `revealAtOnce` — `content()` inserted before the closing comment, the comment removed — so the content is applied as it is revealed on both paths. A placeholder missing its closing comment is not revealed (dev error; `#showFallback` already materialized nothing there — the boundary path threw on the null anchor before). - 2g `showing…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — frames correctness pass, step 2 (C3). Stacked on #3830 (base is
fix/frames-s-flush-address-source; this PR's diff is one commit,b39248f1a). Ground truth: frames-rulings 3.1 (ruled 2026-10-05: hydration-done follows non-SC Solid 2) and 3.2 (its mechanism).What this does
Done is what plain hydration says it is; the frames client registers its holds with it. An adopted occurrence the frame has not claimed yet — waiting for its args record (#2968) or for a
{$ref}'s data — is a pending boundary in everything but a resume. It now registers as one through the same registration a streamed<Loading>resume takes, soonHydrationEndandisHydrationInProgress()mean the same thing with or without server components. No parallel accounting, no second "done", no SC consumer API.solid-js:sharedConfig.holdBoundary(id)(@internal, assigned inenableHydration) —initBoundaryResume's registration for a holder with nothing to resume: the pending count, the owner's_hpmark (a rerun under it is still the claim in progress), the disposal release; returns the release, which checks completion. One assignment, ≈ 59 B min on hydrating pages (the rulings estimated ≈ 25; see Size).@solidjs/web(frames):FrameOptions.hold(): () => void(adopt path; internal use — flagged as surface below).adoptBoundarywires it: under the component's owner (disposal releases), only whileisHydrationInProgress()(a hold taken on a page that never hydrated — a client render adopting server markup — or after it settled is the frame's business, not the page's), keyedsc:<fid>soinitBoundaryResume's fragment-ledger bookkeeping (cleanupFragment, the claim retire,boundaryScopes) touches no fragment's entry, and untracked (the registration reads its trigger once).FrameImpl.#syncSlotstracks whether a full sync left an unmounted occurrence waiting and registers/releases ONE hold at the sync's end;disposereleases.Bounds (L1): the record wait by the document's records running out (
recordsPending), the ref wait by the stream'scomplete/:error, any hold by disposal — exactly a<Loading>resume's.Pins
Flipped
test.fails→test: C3 (a), harness C3 ×1. Still red (expected-fail, 9): C2 (a2), C5 (a, b, e), C12 (c), C13 (a, b), harness C19 ×2. S1'scontainer-trace-hold-hydration-endspec is not onnext— nothing to re-pin here; it re-pins on S1 ("hydration waits for the load; the mount claims before done").Harness correction (the oracle, not the contract text): the C3 law now exempts a done that fired at or after the mount's disposal — a disposed holder owes no claim; its release is what lets done fire, as a disposed
<Loading>'s registration does (initBoundaryResume: "a boundary that can never resume must not hold global hydration open forever"). Verified to hide nothing onnext(with the corrected oracle,nextstill reports C3 in 280/500 cases, seed 3289). Of the 280, 138 had no dispose and are the R1 shape this fixes; the 142 with a dispose were counted onnextbecause done fired at step 0 — before the dispose — which the correction still counts.Decisions made by default
initBoundaryResume'sid= the frame id. Itsreleaseretires the fragment claim underidand its disposal cleanup runssharedConfig.cleanupFragment(id)— both keyed where fragments are; a frame's wire id can collide (C14 c broke that way in the first cut). The holder passessc:<fid>. Alternative: a counting-only registration in solid without the fragment bookkeeping (≈ +150 B min on hydrating pages — the first cut; rejected for size).isHydrationInProgress(). A hold registered on a page that never ranhydrate()(acreateRootrender adopting server markup — C14 a's shape) would, on release, runcheckHydrationCompletewith nothing pending and flip the page to done, holding every later fragment swap. Alternative: guard inside solid (if (!isHydrationInProgress()) return noop) — same semantics, the bytes paid by every hydrating page instead of the frames client.Public API changes (own heading)
sharedConfig.holdBoundary?: (id: string) => () => voidonSharedConfig(@internal, assigned byenableHydration; absent in CSR bundles).FrameOptions.hold?(): () => void(@experimentalframes surface; wired byadoptBoundary, intended for integrations).Gate
packages/web: client 124 files / 1142 passed / 1 expected fail; server 154 / 1474 / 3 expected fail / 2 skipped; hydrate 68 (+1 skipped) / 354 passed / 9 expected fail (was 11 on fix(web/frames): the address is an async source — one landing per bound address, one response per store (S-flush) #3830).test-typesclean. Artifacts unchanged.packages/solid: 41 files, 821 passed;tsc -p tsconfig.json --noEmitclean.next): app: hydrating (no stores) 52,567 → 52,626 min (+59), 17,672 → 17,728 br (+56) — over its cap locally by 18 B and over the 20 B minified allowance; hydrating + stores +59 min; compiled hydrating +59 min; frames eager 43,411 min / 13,768 br (+288 min vs fix(web/frames): the address is an async source — one landing per bound address, one response per store (S-flush) #3830: theholdoption, the waiting flag, the wiring); page base 145,754 min / 44,881 br; page live 157,717 / 48,564. CI will fail the hydrating scenarios on minified growth (+59 B > 20 B allowance). No cap raised; the maintainer decides — the ≈ 59 B is onesharedConfigassignment whose arrow wrapsinitBoundaryResumeandcheckHydrationComplete; the property name (holdBoundary) and the two unmangleable calls are most of it. Signals-only scenarios unchanged.