From 7982c1a5b5d8974d922e2e344abe4dfc6b2c44bf Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 09:58:31 -0700 Subject: [PATCH 01/55] fix(solid): a claim reads the trace's snapshot; the backlog lands after hydration ends (frames-rulings 3.6 (iii), the 3e port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .../container-trace-claim-reads-snapshot.md | 8 + .../frames-consistency-contract.md | 94 +++-- packages/solid/src/client/hydration.ts | 81 +++- packages/solid/test/container-trace.spec.ts | 111 +++++- .../c19-claim-reads-snapshot.spec.tsx | 345 ++++++++++++++++++ .../web/test/consistency/harness/oracle.ts | 12 + .../test/consistency/harness/replay.spec.tsx | 45 ++- packages/web/test/consistency/harness/run.tsx | 2 + 8 files changed, 636 insertions(+), 62 deletions(-) create mode 100644 .changeset/container-trace-claim-reads-snapshot.md create mode 100644 packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx diff --git a/.changeset/container-trace-claim-reads-snapshot.md b/.changeset/container-trace-claim-reads-snapshot.md new file mode 100644 index 000000000..483d2be1b --- /dev/null +++ b/.changeset/container-trace-claim-reads-snapshot.md @@ -0,0 +1,8 @@ +--- +"solid-js": patch +--- + +A fill that claims adopted markup reads the state the server rendered it from (frames-rulings 3.6 (iii), "the consumer parks" — S1's third commit ported onto `next` without its `claiming` hint). + +- `materializeContainerTrace` parks a replayed backlog beyond the snapshot until hydration ends (`onHydrationEnd`; the next microtask when no hydration is in progress) and then applies it as one ordinary update. A container trace is materialized at a fill's arg-read; when that fill claims server markup — the document's pass, a frame's deferred claim under its hold, a claim at a fragment's reveal after hydration-done — the snapshot is what the markup was rendered from, the claim trusts the markup (a text hole is never rewritten during a claim), and a store already past the markup left the DOM diverged for good. Parked, the claim reads the snapshot and the backlog lands after it, so the DOM catches up outside hydration. The release order is the one 3.2 pins: the claim, the frame's hold release, done, then the backlog. A fresh mount pays one beat for not being told apart: its backlog lands a microtask after its snapshot, before any paint. A failure in the backlog applies in order, after the parked patches. +- The materializer creates its projection under a DETACHED root. Rooted under the reading owner — during hydration an id-carrying one — a trace revived at t=0 consumed one child id per trace while one revived by a late claim consumed none, and a keyed sibling after the frame hydrated under different keys in the two runs. diff --git a/documentation/server-components/frames-consistency-contract.md b/documentation/server-components/frames-consistency-contract.md index 49de0dfdf..9d5818df1 100644 --- a/documentation/server-components/frames-consistency-contract.md +++ b/documentation/server-components/frames-consistency-contract.md @@ -254,16 +254,25 @@ or after a fragment reveal. ### C11 — a trace materializes to one value, equal to its oracle -A materialized container trace reads, at every observable point, as the -direct materialization of the same snapshot and patch prefix would — -not-ready before the snapshot, then the snapshot with every patch applied so -far — and its value is independent of how the data was split and timed; one -trace materializes to one store however many readers revive it. +A materialized container trace reads, at every observable point **outside a +claim's park**, as the direct materialization of the same snapshot and patch +prefix would — not-ready before the snapshot, then the snapshot with every +patch applied so far — and its value is independent of how the data was +split and timed; one trace materializes to one store however many readers +revive it. _Outside a claim's park_ (frames-rulings 3.6 (iii), amended with +the 3e port): a backlog replayed at materialization — patches delivered +before the fill that reads the store claimed its markup — is parked beyond +the snapshot until hydration ends (the next microtask when no hydration is +in progress), so while the park holds the store reads the snapshot although +its oracle has the patch; the park releases after the frame's hold (3.2), so +a settle point under another occurrence's hold can fall inside it. Every +settle point after hydration-done is outside it. - **Mechanism:** `solid/hydration.ts:materializeContainerTrace` (sync `.on()` replay into a queue the projection drains; version bump per live - emission), `frame-container-plugin.ts:materialize` (WeakMap memo per - stream), `reviveContainerTraces`, `ContainerTracePlugin.deserialize`. + emission; the backlog beyond the snapshot parked under `limit` until + `onHydrationEnd`), `frame-container-plugin.ts:materialize` (WeakMap memo + per stream), `reviveContainerTraces`, `ContainerTracePlugin.deserialize`. - **Pin:** `c11-trace-equals-oracle.spec.tsx` — arms: (a) snapshot before revival, patches after; (b) revival before the snapshot (not-ready, then equal); (c) 1 batch vs N batches vs random partitions give equal prefixes @@ -450,13 +459,25 @@ read — may evaluate a render prop as a zero-arg accessor. A fill claiming server-rendered text shows, after the claim, the value its first read produced: when a container trace's patches landed before the claim, the DOM shows the patched value, not the snapshot the server rendered. +Under frames-rulings 3.6 (iii) the sentence is carried the other way round — +the first read IS the snapshot (what the markup was rendered from), the claim +keeps it, and the patches land after the claim as the update they are — so +what the settled DOM shows is still the value the fill read, patched. - **Mechanism:** `web/src/client.ts:insertExpression` (a hydrating render is a claim pass, not a mutation pass — by design), `materializeContainerTrace` - (replays snapshot + patches synchronously at revive, so the first read is - already the patched value), `claimRender`. -- **Pin:** `harness/replay.spec.tsx` C19 ×2 (`test.fails`) + control. -- **Verdict:** **red on `next`**, **green on S1** (§S1 delta). See §Red R10. + (replays snapshot + patches synchronously at revive and parks the patches + beyond the snapshot until hydration ends — 3.6 (iii), the 3e port), + `claimRender`. +- **Pin:** `harness/replay.spec.tsx` C19 ×2 + control; + `c19-claim-reads-snapshot.spec.tsx` — arms: (a) the t=0 claim with a trace + past the markup, (b) the deferred claim under the frame's hold, (c) the + release order (claim → hold release → done → backlog, rulings 3.2), + (d) a claim after hydration-done (a fragment's reveal), (e) the C11 + consequence (the store reads the snapshot inside the park), (f) id + determinism (the materializer's detached root). +- **Verdict:** was **red on `next`** (§Red R10); **green with the 3e port** + (`wip/frames-pass-integration`, A2b) — the harness clean on both seeds. ## Red on `next` @@ -728,13 +749,21 @@ the claimed text equals the value read. **Where it goes wrong.** A hydrating `insertExpression` is a claim pass — "not a mutation pass" — by design; the trace model assumes the server text IS the store's first value, which holds only if no patch precedes the claim. On S1 (`9927ddddd`, "a held -container-trace fill hydrates like a resident one") the shape is green: the -held fill's claim runs under a path that reconciles the text with the live -value (the same path that produces C3(b)'s red there). **Severity:** stale -value shown after hydration with no diagnostic; self-heals on the next -distinct patch (medium). **Should have been caught by:** `c11-trace-equals- -oracle` (d) — it patches only after the claim; no hydration test lets a -container trace move between SSR and claim. +container-trace fill hydrates like a resident one") the shape is green — not +because the claim reconciles the text (it never does; `9927ddddd`'s own +comment: "a text hole is never rewritten during a claim") but because the +materializer, told it is read for a claim, serves the snapshot and PARKS the +backlog until hydration ends; the DOM catches up after the claim. S1 is +evidence for frames-rulings 3.6 (iii), the consumer parks — not for (i), the +claim pass reconciling. **Fixed** by the 3e port (A2b on +`wip/frames-pass-integration`): `materializeContainerTrace` parks every +replayed backlog beyond the snapshot until `onHydrationEnd` (a microtask +when none is in progress — the port carries no `claiming` hint, so a fresh +mount pays one beat instead), and roots its projection detached. +**Severity:** stale value shown after hydration with no diagnostic; +self-heals on the next distinct patch (medium). **Should have been caught +by:** `c11-trace-equals-oracle` (d) — it patches only after the claim; no +hydration test lets a container trace move between SSR and claim. ## Harness @@ -763,6 +792,17 @@ Campaigns on `next` (`1f8b2caf4`): | 91501 | 500 | — | 327 | C3 268, C19 68, C18 55, C2 26+25 | | 91501 | 500 | C3, C18, C19, C2 | **0** | nothing else surfaces | +On `wip/frames-pass-integration` with the A2b port (S-flush, the C3 hold, +C5, C12 (c), the 3e park): seeds 3289 and 91501, 500 cases, **0 with +findings**, every law un-ignored. The oracle's one amendment for it: the +settled trace law (C11 / C19) exempts a settle point INSIDE a claim's park — +a fill that claimed with patches already delivered, hydration still in +progress (another occurrence's hold), the text at the snapshot — per C11's +"outside a claim's park"; the end is always outside it (hydration done) and +strict. Checked against the branch WITHOUT the park: the amendment hides 2 +(3289) / 3 (91501) of the 83 / 79 C19 cases — those a later distinct patch +heals before the end — and leaves the rest (81 / 76) red. + Shrink mode (seed 3289, ignore C3) reduces to `[item#0 item#1 children] :: H R1 R0` → C18 on the first failing case. Replay pins (`harness/replay.spec.tsx`): C18 ×3 (two records drained after @@ -797,9 +837,10 @@ campaign). Versus `next` (61 passed, 22 expected-fail, 1 skipped): R1 (a hold hydration does not count). - **Newly green:** C19 ×2 — the `test.fails` pins pass on S1: a trace patch before the claim IS shown (`R0 T H` runs with no finding at all, node - identity included; `H T R0` shows the oracle and only C3 fires). The held - container-trace fill of `9927ddddd` claims through a path that reconciles - the text with the live value. + identity included; `H T R0` shows the oracle and only C3 fires). The + mechanism is `9927ddddd`'s park (the materializer serves the snapshot to + the claim and applies the backlog at hydration end), not a reconciling + claim — see R10's correction. - Everything else identical to `next` (every other pin and expected-fail agrees; the codec warm-up probe chunk keeps C5/C6 portable). @@ -812,10 +853,13 @@ Hydration-core (`packages/solid/src/client/hydration.ts`, `web/src/client.ts`): newly-red C3(b). 2. **R6/C12** — give a rejected server `` fragment a consumer (error fallback + surfaced rejection) instead of the blank swap. -3. **R10/C19** — decide: either the claim pass reconciles a text hole whose - value already differs (narrow, trace-only), or the trace model forbids - patches before the claim (the producer holds them until the record's - claim) — S1's held-fill path shows the former is reachable. +3. **R10/C19** — decided (frames-rulings 3.6 (iii), the consumer parks): the + materializer serves the snapshot to the claim and parks the backlog until + hydration ends; the claim pass stays non-mutating. (The alternatives were + (i) the claim pass reconciling a text hole whose value already differs, + and (ii) the producer holding patches until the record's claim; S1's + held-fill path is the park, (iii), not evidence for (i).) Landed as the + 3e port on `wip/frames-pass-integration`. Frames-client (`packages/web/frames/src/`): diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 5726b07d7..6a15e6d93 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -1237,12 +1237,33 @@ function hydrateStoreFromAsyncIterable( * the server value did: not-ready until the snapshot lands, then a * read-only store the batches keep updating, done when the trace ends. * - * Created DETACHED (`runWithOwner(null)`): revival can run inside a render - * effect's owner, and the store is memoized per trace (see the plugin's - * WeakMap) — a store owned by its first reader would be disposed by that - * reader's re-render while other readers still hold it. Consumption is - * pull-driven and the trace is response-bounded, so the projection settles - * on its own; GC collects the pair with the trace. + * Created under a DETACHED root (see `detachedRoot`): revival can run inside + * a render effect's owner, and the store is memoized per trace (see the + * plugin's WeakMap) — a store owned by its first reader would be disposed by + * that reader's re-render while other readers still hold it, and one rooted + * under it would take a hydration id from it. Consumption is pull-driven and + * the trace is response-bounded, so the projection settles on its own; GC + * collects the pair with the trace. + * + * A replayed backlog beyond the snapshot is PARKED until hydration ends + * (frames-rulings 3.6 (iii), "the consumer parks"): the first reads see the + * snapshot alone. A trace is materialized at a fill's arg-read, and when + * that fill CLAIMS adopted markup — the document's pass, a frame's deferred + * claim under its hold (3.1 / 3.2), a claim at a fragment's reveal or by a + * frame adopted after done — the snapshot is the state the server rendered + * that markup from; the claim renders against it and trusts it — a text + * hole is never rewritten during a claim — so a store already past the + * markup left the DOM diverged from it for good (the trace had nothing + * further to emit). Applied after the claim, the backlog re-runs the fill's + * reads outside hydration and the DOM catches up: the same parking + * `hydrateStoreFromAsyncIterable` gives a buffered backlog. The release + * order is the one 3.2 pins: claim, the frame's hold release, done, then + * the backlog — and the next microtask when no hydration is in progress, + * which is what a claim made after hydration-done gets, and what a FRESH + * mount pays for not being told apart: its backlog lands one beat after + * its snapshot, before any paint. Live emissions land after the claim by + * construction. A failure applies in order, after everything queued before + * it, so it, too, waits on a parked backlog. * * @internal — consumed by the serialization layer (@solidjs/web). */ @@ -1264,11 +1285,15 @@ export function materializeContainerTrace(marker: { let failed: { error: any } | undefined; let cursor = 0; let first = true; + // How far into the queue a compute may apply: everything, except a + // claim's replayed backlog beyond the snapshot, parked until hydration + // ends (see above). + let limit = Infinity; // Everything lives under the detached root (see the block comment // below): materialization runs at arg-read inside a reader's render // scope, and a version signal owned by that reader would be disposed by // its re-render while the memoized store lives on. - return coreRoot(() => { + return detachedRoot(() => { const [version, setVersion] = coreSignal(0); // Subscribe before creating the projection: the buffered replay runs // synchronously inside on(), filling the queue the first compute @@ -1292,10 +1317,27 @@ export function materializeContainerTrace(marker: { } }); live = true; + // The park (see above). Decided here, unconditionally: the + // projection's first compute runs at creation, so the decision cannot + // wait for the first read, and materialization runs at arg-read — + // before the frame opens its claim window and, for a claim made after + // hydration-done (an occurrence inside a server `` whose + // fragment reveals after done; a frame adopted late), with no + // hydration state that says "claim" at all. Serving the snapshot + // first costs a fresh mount one beat (the next microtask, before any + // paint) and nothing else. Released at hydration end with a version + // bump, so the compute drains the backlog as one ordinary update. + if (queue.length > 1) { + limit = 1; + onHydrationEnd(() => { + limit = Infinity; + bump(); + }); + } return createProjection( (draft: any) => { version(); - while (cursor < queue.length) { + while (cursor < queue.length && cursor < limit) { const value = queue[cursor++]; if (first) { first = false; @@ -1311,7 +1353,8 @@ export function materializeContainerTrace(marker: { applyPatches(draft, value); } } - if (failed) throw failed.error; + // In order: after everything queued before it has applied. + if (failed && cursor === queue.length) throw failed.error; // Nothing buffered yet (revival raced ahead of the record's data // script): pending until the snapshot lands, marked on the // projection's own node — the version bump reruns this compute. @@ -1319,7 +1362,7 @@ export function materializeContainerTrace(marker: { }, (marker.$ta ? [] : {}) as any ); - })!; + }); } // A root, not a bare null owner: the projection's async machinery routes // its pending/error states through the owner's queue, and with no owner @@ -1327,7 +1370,7 @@ export function materializeContainerTrace(marker: { // surfaces as an unhandled error in dev. The root is never disposed — // the projection settles itself when the trace ends and is collected // with the store. - return coreRoot(() => + return detachedRoot(() => createProjection( (draft: any) => ({ [Symbol.asyncIterator]() { @@ -1361,7 +1404,21 @@ export function materializeContainerTrace(marker: { }), (marker.$ta ? [] : {}) as any ) - )!; + ); +} + +/** + * A root with NO parent, for the container-trace materializer. It runs at + * arg-read, under whatever owner is reading — during hydration an + * id-carrying one — and a root created there inherits the next child id, + * shifting every key the reader mints after it: a trace revived at t=0 + * consumed one root id while one revived by a late claim (no ambient owner) + * consumed none, and a keyed sibling after the frame hydrated under + * different keys in the two runs. The store is shared and memoized per + * trace; it belongs to no reader's id space. + */ +function detachedRoot(init: () => T): T { + return runWithOwner(null, () => coreRoot(init))!; } // --- Hydration-aware implementations --- diff --git a/packages/solid/test/container-trace.spec.ts b/packages/solid/test/container-trace.spec.ts index 5ed017c5a..9f50d61aa 100644 --- a/packages/solid/test/container-trace.spec.ts +++ b/packages/solid/test/container-trace.spec.ts @@ -5,9 +5,10 @@ // PatchOp batches — and materializes into a live local projection: reads // are not-ready until the snapshot lands, then a read-only store the // batches keep updating, latched when the trace ends. -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; +import { createOwner } from "@solidjs/signals"; import { createRoot, createRenderEffect, flush } from "../src/index.js"; -import { materializeContainerTrace } from "../src/index.js"; +import { enableHydration, materializeContainerTrace, sharedConfig } from "../src/index.js"; /** * A hand-cranked RAW seroval stream (the wire shape since the stream-mint @@ -183,3 +184,109 @@ describe("materializeContainerTrace", () => { expect(store[1]).toBe("b"); }); }); + +// The materializer's root is DETACHED (frames-rulings 3.6, S1's "id +// determinism" fix): materialization runs at a fill's arg-read, under +// whatever owner is reading — during hydration an id-carrying one — and a +// root created there would inherit the next child id, shifting every key +// the reader mints after it. The store belongs to no reader's id space. +describe("materializeContainerTrace — id neutrality", () => { + test("materializing under an id-carrying owner consumes no child id", () => { + const stream = makeStream(); + stream.next({ name: "Ada" }); + const ids: (string | undefined)[] = []; + createRoot( + () => { + ids.push(createOwner().id); + materializeContainerTrace({ $tr: stream, $ta: 0 } as any); + ids.push(createOwner().id); + }, + { id: "p" } + ); + const control: (string | undefined)[] = []; + createRoot( + () => { + control.push(createOwner().id); + control.push(createOwner().id); + }, + { id: "p" } + ); + expect(ids).toEqual(control); + }); +}); + +// The park (frames-rulings 3.6 (iii), "the consumer parks"): a replayed +// backlog beyond the snapshot applies after hydration ends — the first reads +// see the snapshot, what the server's markup was rendered from — so a claim +// pass over that markup reads the state it shows, and the backlog lands +// after the claim as the update it is. +describe("materializeContainerTrace — the parked backlog", () => { + afterEach(() => { + sharedConfig.hydrating = false; + delete (globalThis as any)._$HY; + }); + + const ahead = () => { + const stream = makeStream(); + stream.next({ name: "Ada", edits: 0 }); + stream.next([[["edits"], 1]]); + stream.next([ + [["name"], "Ada (edited)"], + [["edits"], 2] + ]); + return stream; + }; + + test("during hydration the snapshot serves; the backlog lands at hydration end, as one update", () => { + enableHydration(); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; + sharedConfig.hydrating = true; + const store: any = materializeContainerTrace({ $tr: ahead(), $ta: 0 } as any); + const reads: string[] = []; + createRoot(() => { + createRenderEffect( + () => `${store.name}/${store.edits}`, + (v: string) => void reads.push(v) + ); + }); + flush(); + expect(reads).toEqual(["Ada/0"]); + expect(sharedConfig.isHydrationInProgress!()).toBe(true); + // The root pass ends with nothing pending: hydration is done, the park + // releases, the compute drains the whole backlog in one pass. + sharedConfig.hydrating = false; + flush(); + expect(reads).toEqual(["Ada/0", "Ada (edited)/2"]); + }); + + test("with no hydration in progress the backlog lands on the next microtask", async () => { + const store: any = materializeContainerTrace({ $tr: ahead(), $ta: 0 } as any); + expect(store.name).toBe("Ada"); + expect(store.edits).toBe(0); + await Promise.resolve(); + flush(); + expect(store.name).toBe("Ada (edited)"); + expect(store.edits).toBe(2); + }); + + test("a snapshot alone is not a backlog: live emissions apply as they land", () => { + const stream = makeStream(); + stream.next({ name: "Ada" }); + const store: any = materializeContainerTrace({ $tr: stream, $ta: 0 } as any); + expect(store.name).toBe("Ada"); + stream.next([[["name"], "Grace"]]); + flush(); + expect(store.name).toBe("Grace"); + }); + + test("a failure in the backlog applies in order, after the parked patches", async () => { + const stream = ahead(); + stream.throw(new Error("boom")); + const store: any = materializeContainerTrace({ $tr: stream, $ta: 0 } as any); + // Parked: the snapshot reads, the failure has not surfaced. + expect(store.name).toBe("Ada"); + await Promise.resolve(); + flush(); + expect(() => store.name).toThrow("boom"); + }); +}); diff --git a/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx b/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx new file mode 100644 index 000000000..51ffc6bdc --- /dev/null +++ b/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx @@ -0,0 +1,345 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * C19 — a claim shows the value it read, and reads what the markup was + * rendered from (frames-rulings 3.6 (iii), "the consumer parks"). + * + * "A fill that claims adopted markup reads the state the server rendered + * it from — a container trace's snapshot — and claims against it; what + * moved before the claim lands after the claim as the update it is. The + * claim pass never rewrites a hole." + * + * Mechanism: solid/src/client/hydration.ts `materializeContainerTrace` + * parks a replayed backlog beyond the snapshot until hydration ends + * (`onHydrationEnd`; the next microtask when none is in progress) and + * creates its projection under a detached root. The shapes are S1's + * `container-trace-hold-{snapshot, hydration-end, id-determinism}` specs + * (`9927ddddd`) re-cut for `next`, where the late claim is the #2968 + * record defer under the frame's hold (3.1 / 3.2) rather than S1's lazy + * materializer hold. + * + * Release order pinned (3.2, "ordering to pin with it"): the claim, then + * the frame's hold release, then done, then the backlog. + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createSignal, flush, untrack } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { + bootPage, + fillHtml, + frameHtml, + freshFid, + hydrationInProgress, + microtasks, + onHydrationEnd, + placeholderHtml, + quiesce, + slotRange, + traceMarker, + type Page +} from "./support.js"; + +let page: Page | undefined; +afterEach(async () => { + await page?.cleanup(); + page = undefined; +}); + +/** A trace the server rendered at `n = snapshot` and then moved past. */ +function movedTrace(snapshot: number, ...patches: number[]) { + const trace = traceMarker(); + trace.snapshot({ n: snapshot }); + for (const n of patches) trace.patch([[["n"], n]]); + return trace; +} + +describe("C19 — a claim reads the snapshot; the backlog lands after the claim", () => { + // (a) Resident at t=0, the trace already past the markup: the record and + // its snapshot AND two patches are in the page before hydrate. The server + // rendered `2`; the fill's first read is the snapshot (`2`), the claim + // keeps the markup's text, and the backlog (`5`) lands at hydration end. + // Was red on `next`: the fill read `5` (the materializer replayed every + // patch at revive), the claim kept `2`, nothing healed it. + test("(a) t=0 claim with a trace past the markup: the claim shows the snapshot, hydration end brings the fold", async () => { + const fid = freshFid("c19a"); + page = bootPage( + frameHtml(fid, `
    ${slotRange("item#0", fillHtml(fid, "item#0", "2"))}
`) + ); + const trace = movedTrace(2, 3, 5); + page.slotRecord(fid, "item#0", { data: trace.marker }); + const Comp = (globalThis as any)._$SC.r(fid); + const li = page.container.querySelector("li")!; + const reads: number[] = []; + let store: any; + const dispose = hydrate( + () => ( + { + store = untrack(() => p.data); + reads.push(untrack(() => store.n)); + return
  • {p.data.n}
  • ; + }} + /> + ), + page.container + ); + // The claim: the fill read the snapshot, the server's node is the node, + // its text untouched — the claim pass rewrote nothing. + expect(reads).toEqual([2]); + expect(page.container.querySelector("li")).toBe(li); + expect(li.textContent).toBe("2"); + await quiesce(); + await quiesce(); + // Hydration over: the backlog applied as one update and the DOM caught + // up — outside hydration, a real mutation of the claimed node. + expect(store.n).toBe(5); + expect(li.textContent).toBe("5"); + expect(page.container.querySelector("li")).toBe(li); + expect(page.warnings).toEqual([]); + // A live patch after that is an ordinary update. + trace.patch([[["n"], 7]]); + flush(); + expect(li.textContent).toBe("7"); + dispose(); + }); + + // (b) The late claim (the #2968 record defer under the frame's hold): the + // parser is still running at adoption and the record arrives later; the + // trace moves while the occurrence waits. The deferred claim runs under + // the hold — hydration still in progress — and parks the same way: it + // reads and keeps the snapshot, the backlog lands at hydration end. + test("(b) deferred claim: patches during the wait land after the claim, at hydration end", async () => { + const fid = freshFid("c19b"); + vi.spyOn(document, "readyState", "get").mockReturnValue("loading"); + page = bootPage( + frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "2"))}
    `) + ); + const trace = movedTrace(2); + const Comp = (globalThis as any)._$SC.r(fid); + const li = page.container.querySelector("li")!; + const reads: number[] = []; + let store: any; + const dispose = hydrate( + () => ( + { + store = untrack(() => p.data); + reads.push(untrack(() => store.n)); + return
  • {p.data.n}
  • ; + }} + /> + ), + page.container + ); + await quiesce(); + expect(reads).toEqual([]); + expect(hydrationInProgress()).toBe(true); + // The trace moves while the occurrence waits for its record. + trace.patch([[["n"], 3]]); + trace.patch([[["n"], 5]]); + // The record the parser was still owed; the poll drains it and the + // deferred mount claims. + page.slotRecord(fid, "item#0", { data: trace.marker }); + await quiesce(); + await quiesce(); + expect(reads).toEqual([2]); + expect(page.container.querySelector("li")).toBe(li); + expect(store.n).toBe(5); + expect(li.textContent).toBe("5"); + expect(page.warnings).toEqual([]); + dispose(); + }); + + // (c) The release order (3.2): claim → the frame's hold release → done → + // backlog. Observed through the fill (the claim runs while hydration is + // still in progress — the hold has not released), the end callback (the + // store still reads the snapshot when done fires: the backlog is parked + // past it), and the settled page (the fold). + test("(c) release order: claim, hold release, done, backlog", async () => { + const fid = freshFid("c19c"); + vi.spyOn(document, "readyState", "get").mockReturnValue("loading"); + page = bootPage( + frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "2"))}
    `) + ); + const trace = movedTrace(2); + const Comp = (globalThis as any)._$SC.r(fid); + const li = page.container.querySelector("li")!; + const order: string[] = []; + let store: any; + const dispose = hydrate( + () => ( + { + store = untrack(() => p.data); + order.push(`claim:${untrack(() => store.n)}:${hydrationInProgress()}`); + return
  • {p.data.n}
  • ; + }} + /> + ), + page.container + ); + // Registered before the deferred mount parks: runs before the park's + // own release. + onHydrationEnd(() => order.push(`done:${store.n}:${li.textContent}`)); + await quiesce(); + trace.patch([[["n"], 5]]); + page.slotRecord(fid, "item#0", { data: trace.marker }); + await quiesce(); + await quiesce(); + order.push(`settled:${store.n}:${li.textContent}`); + expect(order).toEqual([ + // the claim, under the hold (hydration in progress), reads the snapshot + "claim:2:true", + // the hold released → done; the backlog is still parked + "done:2:2", + // the backlog applied; the DOM caught up + "settled:5:5" + ]); + expect(page.warnings).toEqual([]); + dispose(); + }); + + // (d) A claim AFTER hydration-done: an occurrence inside a server + // `` whose fragment reveals after the page's hydration completed + // (the server's inner loading state registers nothing — corollary 4). The + // claim parks all the same — nothing about hydration state says "claim" + // there; the materializer parks every replayed backlog — and the backlog + // lands on the next microtask (hydration is not in progress, so + // `onHydrationEnd` fires at once). + test("(d) post-done claim at a fragment's reveal: the snapshot claims, the fold lands a microtask later", async () => { + const fid = freshFid("c19d"); + const frag = "c19d"; + page = bootPage(frameHtml(fid, `
      ${placeholderHtml(frag, "loading")}
    `)); + page.declareFragment(frag); + const trace = movedTrace(2, 5); + page.slotRecord(fid, "item#0", { data: trace.marker }); + const Comp = (globalThis as any)._$SC.r(fid); + const reads: number[] = []; + let store: any; + const dispose = hydrate( + () => ( + { + store = untrack(() => p.data); + reads.push(untrack(() => store.n)); + return
  • {p.data.n}
  • ; + }} + /> + ), + page.container + ); + await quiesce(); + await quiesce(); + // Nothing to claim yet; the page is done. + expect(reads).toEqual([]); + expect(hydrationInProgress()).toBe(false); + page.revealFragment(frag, slotRange("item#0", fillHtml(fid, "item#0", "2"))); + const li = page.container.querySelector("li")!; + // The reveal is an apply (2.3): the occurrence mounted as a claim of the + // revealed markup, reading the snapshot it was rendered from. + expect(reads).toEqual([2]); + expect(li.textContent).toBe("2"); + await microtasks(2); + flush(); + expect(store.n).toBe(5); + expect(li.textContent).toBe("5"); + expect(page.container.querySelector("li")).toBe(li); + expect(page.warnings).toEqual([]); + dispose(); + }); + + // (e) Consequence pinned (contract C11, "every observable point" read as + // "outside a claim's park"): while the park holds — hydration kept in + // progress by ANOTHER occurrence's hold — the store reads the snapshot + // although its oracle has the patch; the settle points after the release + // agree with the oracle. + test("(e) during the park the store reads the snapshot; after the release, the oracle", async () => { + const fid = freshFid("c19e"); + vi.spyOn(document, "readyState", "get").mockReturnValue("loading"); + page = bootPage( + frameHtml( + fid, + `
      ${slotRange("item#0", fillHtml(fid, "item#0", "2"))}${slotRange( + "item#1", + fillHtml(fid, "item#1", "one") + )}
    ` + ) + ); + const trace = movedTrace(2, 5); + page.slotRecord(fid, "item#0", { data: trace.marker }); + const Comp = (globalThis as any)._$SC.r(fid); + let store: any; + const dispose = hydrate( + () => ( + { + const data = untrack(() => p.data); + if (data) store = data; + return
  • {data ? data.n : p.text}
  • ; + }} + /> + ), + page.container + ); + await quiesce(); + // item#1 waits for its record: hydration is in progress, item#0's + // backlog is parked, the store reads the snapshot. + expect(hydrationInProgress()).toBe(true); + expect(store.n).toBe(2); + page.slotRecord(fid, "item#1", { text: "one" }); + await quiesce(); + await quiesce(); + expect(hydrationInProgress()).toBe(false); + expect(store.n).toBe(5); + expect(page.container.textContent).toBe("5one"); + expect(page.warnings).toEqual([]); + dispose(); + }); + + // (f) Id determinism (S1's "the materializer consumes no ambient id"): a + // keyed sibling after the frame hydrates under the same key whether or + // not a trace was revived during the pass. The materializer's root is + // detached; rooted under the reading owner it took one child id per + // trace and shifted every key minted after it. + test("(f) the materializer consumes no ambient id: a keyed sibling after the frame keys the same with and without a trace", async () => { + const outcomes: string[] = []; + for (const withTrace of [false, true]) { + const fid = freshFid("c19f"); + const p = bootPage( + frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "2"))}
    `) + + `

    after

    ` + ); + page = p; + if (withTrace) { + const trace = movedTrace(2); + p.slotRecord(fid, "item#0", { data: trace.marker }); + } else { + p.slotRecord(fid, "item#0", { text: "2" }); + } + const Comp = (globalThis as any)._$SC.r(fid); + const [label] = createSignal("after"); + const dispose = hydrate( + () => ( + <> + { + const data = untrack(() => props.data); + return
  • {data ? data.n : props.text}
  • ; + }} + /> +

    {label()}

    + + ), + p.container + ); + await quiesce(); + const miss = p.warnings.find(w => w.includes("Hydration key miss")); + outcomes.push(miss ? miss.match(/key miss for "([^"]+)"/)![1] : "claimed"); + dispose(); + await p.cleanup(); + page = undefined; + } + expect(outcomes[0]).toBe(outcomes[1]); + }); +}); diff --git a/packages/web/test/consistency/harness/oracle.ts b/packages/web/test/consistency/harness/oracle.ts index 8cedea9e7..9549f678d 100644 --- a/packages/web/test/consistency/harness/oracle.ts +++ b/packages/web/test/consistency/harness/oracle.ts @@ -43,6 +43,8 @@ export interface World { zeroArgCalls: number; /** Whether the host still has the boundary's store (read at settle points after dispose). */ hostHas: () => boolean; + /** Whether hydration is still in progress (`sharedConfig.isHydrationInProgress`). */ + hydrationInProgress: () => boolean; /** Snapshot taken in the hydration-end callback. */ hydrationEnd?: { step: number; mountedButUninvoked: string[] }; /** Trace occurrence index → the oracle's current `n`. */ @@ -169,6 +171,16 @@ export function settled(w: World): Finding[] { // cannot heal it); patches only after the claim are C11 proper. const preClaim = (w.firstPatchAt.get(i) ?? Infinity) < (w.invokedAt.get(o.name) ?? Infinity); + // "Every observable point" is read OUTSIDE a claim's park (contract + // C11, frames-rulings 3.6 (iii)): a fill that claimed with patches + // already delivered reads the SNAPSHOT — what the markup was + // rendered from — and its backlog is parked until hydration ends, + // which another occurrence's hold can keep open past this settle + // point (3.1 / 3.2: the park releases after the hold). The end is + // never inside a park: hydration is over by then and the law is + // strict. + if (preClaim && w.hydrationInProgress() && text.startsWith(`t${i}=${o.arg.snapshot}`)) + return; at( preClaim ? "C19" : "C11", preClaim ? "claim-shows-oracle" : "trace-equals-oracle", diff --git a/packages/web/test/consistency/harness/replay.spec.tsx b/packages/web/test/consistency/harness/replay.spec.tsx index 4b864a3c3..4347a55f3 100644 --- a/packages/web/test/consistency/harness/replay.spec.tsx +++ b/packages/web/test/consistency/harness/replay.spec.tsx @@ -96,29 +96,28 @@ describe("harness replay — reduced counterexamples", () => { ).toEqual([]); }); - // C19 — a claim shows the oracle, not the snapshot. Observed on `next`: - // a trace patch delivered BEFORE the fill claims leaves the server text - // (the snapshot's `2`) on screen although the fill's first read is the - // patched `5`; the DOM catches up only at the next patch. Expected: the - // claimed text equals the value the fill read. Any order where the patch - // precedes the claim fails: record→patch→hydrate, patch→record→hydrate, - // hydrate→patch→record (deferred claim). - test.fails( - "C19 claim-shows-oracle: a trace patch before the claim is not shown (record, patch, hydrate)", - async () => { - expect( - await findings({ ...base, occurrences: [trace(0, 2, [3])], events: [R(0), T(0), H] }, "C19") - ).toEqual([]); - } - ); - test.fails( - "C19 claim-shows-oracle: a trace patch before the claim is not shown (hydrate, patch, record)", - async () => { - expect( - await findings({ ...base, occurrences: [trace(0, 2, [3])], events: [H, T(0), R(0)] }, "C19") - ).toEqual([]); - } - ); + // C19 — a claim shows the oracle. Was red on `next`: a trace patch + // delivered BEFORE the fill claims left the server text (the snapshot's + // `2`) on screen although the fill's first read was the patched `5`; the + // DOM caught up only at the next distinct patch. Green under + // frames-rulings 3.6 (iii) — the consumer parks: materialized while + // hydration is in progress, the trace serves its snapshot (what the + // markup was rendered from) and parks the backlog beyond it until + // hydration ends; the claim pass still rewrites nothing, and the backlog + // then lands as the update it is, so the settled DOM equals the oracle. + // Both orders: record→patch→hydrate (the t=0 pass), hydrate→patch→record + // (the deferred claim under the frame's hold — rulings 3.1 / 3.2 are what + // make the late materialization see hydration in progress). + test("C19 claim-shows-oracle: a trace patch before the claim lands after it (record, patch, hydrate)", async () => { + expect( + await findings({ ...base, occurrences: [trace(0, 2, [3])], events: [R(0), T(0), H] }, "C19") + ).toEqual([]); + }); + test("C19 claim-shows-oracle: a trace patch before the claim lands after it (hydrate, patch, record)", async () => { + expect( + await findings({ ...base, occurrences: [trace(0, 2, [3])], events: [H, T(0), R(0)] }, "C19") + ).toEqual([]); + }); // C19 control: a patch after the claim lands (C11 proper). test("C19 control: a patch after the claim shows", async () => { diff --git a/packages/web/test/consistency/harness/run.tsx b/packages/web/test/consistency/harness/run.tsx index b8b637819..76444d3f4 100644 --- a/packages/web/test/consistency/harness/run.tsx +++ b/packages/web/test/consistency/harness/run.tsx @@ -19,6 +19,7 @@ import { frameHtml, freshFid, holeHtml, + hydrationInProgress, macrotask, microtasks, onHydrationEnd, @@ -118,6 +119,7 @@ export async function runScenario(scenario: Scenario): Promise { appliedAfterDispose: 0, zeroArgCalls: 0, hostHas: () => !!page.host.get(fid), + hydrationInProgress, traceN: new Map(), holeId, holeHistory: ["hole-v0"], From 3aeba4e193854c9308485be6874ec5fd1a5fc6e3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 10:23:10 -0700 Subject: [PATCH 02/55] feat(solid,web/frames): an adopted occurrence claims through the hydration window (hydrateWindow); R.claim deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .../frames-claim-through-hydrate-window.md | 10 + packages/solid/src/client/hydration.ts | 174 ++++++++++++++---- packages/solid/test/hydrate-window.spec.ts | 108 +++++++++++ packages/web/frames/src/client.ts | 116 +++++------- packages/web/src/client.ts | 23 ++- .../c01-claim-window-roots.spec.tsx | 78 ++++++++ 6 files changed, 390 insertions(+), 119 deletions(-) create mode 100644 .changeset/frames-claim-through-hydrate-window.md create mode 100644 packages/solid/test/hydrate-window.spec.ts create mode 100644 packages/web/test/consistency/c01-claim-window-roots.spec.tsx diff --git a/.changeset/frames-claim-through-hydrate-window.md b/.changeset/frames-claim-through-hydrate-window.md new file mode 100644 index 000000000..96542663c --- /dev/null +++ b/.changeset/frames-claim-through-hydrate-window.md @@ -0,0 +1,10 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +An adopted frame occurrence claims its server markup by re-entering hydration the way a streamed `` resume does (frames-rulings 3.1 / 3.2, the savings plan's A2 — S-hold's window form). + +- `solid-js`: `hydrateWindow(id, fn, scope?)` is factored out of a streamed boundary's resume and reached as `sharedConfig.hydrateWindow` (`@internal`): the keys under `id` gathered into the registry (the captured `scope` pair when another `hydrate()` root replaced the live one, #2917), hydrating on for the synchronous window, the current owner the claim owner (a render the window forces elsewhere is a client render, #3504), the owner the window's snapshot and live scope when none is open — so a write during a late claim is held and replays once the claim is over, and a late claim no longer re-marks the root's scope. `sharedConfig.claimRoots` (`@internal`) is typed: the claimant declares a range that may be detached around its window. `holdBoundary` stays the registration; the resume path is unchanged in behaviour. +- `@solidjs/web` (frames): `claimRender` is the window — one `createOwner({ id: prefix })` and the call — instead of a registry of its own gathered by walking the range, a hydrating flag flipped through `sharedConfig`'s setter (which reset hydration-done and re-ran its completion from outside the runtime), and a hand-over of keys from the root registry: `gatherClaims` and `hasPendingFragment` are deleted (the window gathers by the producer prefix and always engages). `adoptBoundary` captures the registry/gather pair it adopts under so a claim made long after — under the frame's hold, at a fragment's reveal — gathers against the root that holds the frame. +- `@solidjs/web`: `gatherHydratable`'s prefix-scoped gather selects its keys natively (`[_hk^="…"]`) instead of sweeping every `_hk` and filtering in JS — it now runs once per adopted occurrence, not only per late resume. diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 6a15e6d93..92eb50c72 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -220,6 +220,38 @@ type SharedConfig = { * @internal */ holdBoundary?: (id: string) => () => void; + /** + * Run `fn` as a CLAIM of server-rendered DOM under `id`'s keys — the + * re-entry a streamed `` resume takes, for an integration that + * owns server markup wholesale (the frames client's adopted occurrences): + * the keys under `id` gathered into the registry, hydrating on for the + * synchronous window, the current owner the claim owner (a render the + * window forces elsewhere is a client render — `isClaiming`), `scope` + * the registry/gather pair the claimant adopted under when another + * `hydrate()` root may have replaced the live one since (#2917). Call it + * only once a root has gathered (`sharedConfig.registry` is set): there + * is nothing to claim against before. Assigned by `enableHydration()`; + * absent in CSR bundles. Cross-package wiring; not part of the + * user-facing API. + * + * @internal + */ + hydrateWindow?: ( + id: string, + fn: () => T, + scope?: { registry?: Map; gather?: (key: string) => void } + ) => T; + /** + * The roots of the claim in progress when its range may be detached (an + * async slot fill renders before its boundary re-inserts it): the DOM + * runtime's hydration guard reads connectivity to tell claimed server + * nodes from fresh clones, and a node under one of these is as claimed as + * a connected one. Set by the claimant around its `hydrateWindow` (the + * frames client), read by the DOM runtime. + * + * @internal + */ + claimRoots?: Node[]; }; /** @@ -1950,6 +1982,10 @@ export function enableHydration() { const release = initBoundaryResume(getOwner()!, id)[2]; return () => release() && checkHydrationComplete(); }; + // An adopted occurrence's claim is a resume's window — the keys under its + // producer prefix, the current owner the claim owner — without a resume's + // registration (the frame's hold above is that). + sharedConfig.hydrateWindow = hydrateWindow; // Take ownership of streamed-fragment reveals (see the fragment ledger). // The header script creates `_$HY` before any module runs, so the hook is @@ -2567,6 +2603,83 @@ function createBoundaryTrigger(): () => void { return set; } +/** + * The claim window: `fn` runs claiming server-rendered DOM under `o`. + * + * - The keys under `id` are gathered into the registry (none without an + * `id`); `scope` is the registry/gather pair the claimant registered + * under — another `hydrate()` root may have replaced the live globals + * since (#2917) — swapped in for the synchronous window; without one the + * live globals apply. + * - Hydrating is on, `o` is the claim owner — the window claims `o`'s + * subtree only; a re-render it forces elsewhere (a write from the claimed + * content's user effects reaching a signal above it) is a client render + * (#3504). A claimant whose range may be detached declares it in + * `sharedConfig.claimRoots` around the window (the frames client does). + * - `o` is the window's snapshot and live scope (D8) when no scope is open + * — a late claim, after the root pass. Writes during the window are held + * from `o`'s subtree and replay at release, once the claim is over; the + * live nodes it hydrated take over then. Inside an open scope (the root + * pass; an enclosing window) the claim joins it and releases with it — + * releasing `o` on its own would let a write later in the pass cascade + * live into a claim pass whose DOM writes are skipped. Capture is on + * through hydration; a window opened after hydration-done (an adopted + * frame's claim at a fragment's reveal) turns it on for its span and + * clears what it captured. + * + * Everything is restored on the way out, nested windows included. The body + * of a streamed boundary's resume (below), factored so the frames client's + * adopted occurrences re-enter hydration the same way — it IS + * `sharedConfig.hydrateWindow`, `o` defaulting to the current owner there — + * instead of through a registry and a hydrating flag of their own. The + * caller sees to it that a root has gathered (`sharedConfig.registry`): a + * window with no registry to claim against would miss every key. + */ +function hydrateWindow( + id: string | undefined, + fn: () => T, + scope?: { registry?: Map; gather?: (key: string) => void }, + o: Owner | null = getOwner() +): T { + const prevRegistry = sharedConfig.registry; + const prevGather = sharedConfig.gather; + const prevHydrating = _hydratingValue; + const prevClaim = _claimOwner; + const own = !_snapshotRootOwner && o; + const capture = own && _hydrationDone; + if (scope) { + sharedConfig.registry = scope.registry; + sharedConfig.gather = scope.gather; + } + try { + if (id) sharedConfig.gather?.(id); + _hydratingValue = true; + _claimOwner = o; + if (own) { + if (capture) setSnapshotCapture(true); + markSnapshotScope(own); + openLiveScope(own); + _snapshotRootOwner = own; + } + return fn(); + } finally { + _hydratingValue = prevHydrating; + _claimOwner = prevClaim; + if (scope) { + sharedConfig.registry = prevRegistry; + sharedConfig.gather = prevGather; + } + if (own) { + _snapshotRootOwner = null; + releaseSnapshotScope(own); + // this claim's hydration is over: its live nodes take over now, + // without waiting for the rest of the page (D8) + releaseLiveScope(own); + if (capture) clearSnapshots(); + } + } +} + function resumeBoundaryHydration( o: Owner, id: string, @@ -2582,49 +2695,30 @@ function resumeBoundaryHydration( checkHydrationComplete(); return; } - // A late resume must claim against the root this boundary registered - // under — another hydrate() root may have replaced the global - // registry/gather since (#2917). Swap the captured pair in for the - // synchronous resume window; without a capture the live globals apply. - const prevRegistry = sharedConfig.registry; - const prevGather = sharedConfig.gather; - const prevClaim = _claimOwner; - if (scope) { - sharedConfig.registry = scope.registry; - sharedConfig.gather = scope.gather; - } - try { - if (shouldHydrate) sharedConfig.gather?.(id); - _hydratingValue = shouldHydrate; - if (shouldHydrate) { - markSnapshotScope(o); - openLiveScope(o); - _snapshotRootOwner = o; - // The window claims this boundary's subtree only: the rest of the - // tree hydrated in the root pass, and a re-render it takes during the - // window (a write from the resumed content's user effects) is a - // client render (#3504). - _claimOwner = o; - } - set(); - flush(); - if (shouldHydrate) _snapshotRootOwner = null; + if (shouldHydrate) { + // A late resume claims against the root this boundary registered under + // (the captured `scope`), its subtree the snapshot and live scope for + // the window; the trigger re-runs the boundary's compute inside it. + hydrateWindow( + id, + () => { + set(); + flush(); + }, + scope, + o + ); + } else { + // The client renders the boundary fresh (recover): no claim. _hydratingValue = false; - _claimOwner = prevClaim; - if (shouldHydrate) { - releaseSnapshotScope(o); - // this boundary's hydration is over: its live nodes take over now, - // without waiting for the rest of the page (D8) - releaseLiveScope(o); - } + set(); flush(); - } finally { - _claimOwner = prevClaim; - if (scope) { - sharedConfig.registry = prevRegistry; - sharedConfig.gather = prevGather; - } } + // Hydration mode is off once a boundary has resumed — whatever the flag + // read before (a resume never runs inside a root's synchronous pass; the + // client-gated nodes the resume created compute in the flush below). + _hydratingValue = false; + flush(); checkHydrationComplete(); } diff --git a/packages/solid/test/hydrate-window.spec.ts b/packages/solid/test/hydrate-window.spec.ts new file mode 100644 index 000000000..73b5a986d --- /dev/null +++ b/packages/solid/test/hydrate-window.spec.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment jsdom + * + * `sharedConfig.hydrateWindow` — the claim window a streamed boundary's + * resume opens, reachable by an integration that owns server markup + * wholesale (the frames client's adopted occurrences): hydrating on for the + * synchronous run, the current owner the claim owner, the keys under the id + * gathered into the registry (the captured pair when given), the claim + * roots declared, everything restored on the way out. + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createOwner, createRoot, runWithOwner } from "@solidjs/signals"; +import { enableHydration, isHydrating, sharedConfig } from "../src/client/hydration.js"; + +function stopHydration() { + sharedConfig.hydrating = false; + (sharedConfig as any).registry = undefined; + (sharedConfig as any).gather = undefined; + delete (globalThis as any)._$HY; +} + +describe("sharedConfig.hydrateWindow", () => { + afterEach(stopHydration); + + test("installed by enableHydration(); returns the window's result", () => { + enableHydration(); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; + (sharedConfig as any).registry = new Map(); + (sharedConfig as any).gather = () => {}; + const hw = sharedConfig.hydrateWindow!; + expect(typeof hw).toBe("function"); + let hydrating: boolean | undefined; + const out = hw("sc-x-", () => { + hydrating = sharedConfig.hydrating; + return 42; + }); + expect(out).toBe(42); + expect(hydrating).toBe(true); + expect(sharedConfig.hydrating).toBe(false); + }); + + test("hydrating on inside, the current owner the claim owner; all restored", () => { + enableHydration(); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; + const registry = new Map(); + const gather = vi.fn(); + (sharedConfig as any).registry = registry; + (sharedConfig as any).gather = gather; + const seen: Record = {}; + createRoot(() => { + const sibling = createOwner(); + const claimant = createOwner({ id: "sc-x-" }); + runWithOwner(claimant, () => { + sharedConfig.hydrateWindow!("sc-x-", () => { + seen.hydrating = sharedConfig.hydrating; + // The window claims the claimant's subtree only (#3504). + seen.claiming = isHydrating(); + seen.elsewhere = runWithOwner(sibling, () => isHydrating()); + }); + }); + }); + expect(gather).toHaveBeenCalledWith("sc-x-"); + expect(seen).toEqual({ hydrating: true, claiming: true, elsewhere: false }); + expect(sharedConfig.hydrating).toBe(false); + expect(isHydrating()).toBe(false); + }); + + test("a captured registry/gather pair is swapped in for the window and restored (#2917)", () => { + enableHydration(); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; + const liveRegistry = new Map(); + const liveGather = vi.fn(); + (sharedConfig as any).registry = liveRegistry; + (sharedConfig as any).gather = liveGather; + const captured = { registry: new Map(), gather: vi.fn() }; + let inside: { registry?: unknown; gather?: unknown } = {}; + sharedConfig.hydrateWindow!( + "sc-a-", + () => { + inside = { registry: sharedConfig.registry, gather: sharedConfig.gather }; + }, + captured + ); + expect(captured.gather).toHaveBeenCalledWith("sc-a-"); + expect(liveGather).not.toHaveBeenCalled(); + expect(inside).toEqual({ registry: captured.registry, gather: captured.gather }); + expect(sharedConfig.registry).toBe(liveRegistry); + expect(sharedConfig.gather).toBe(liveGather); + }); + + test("restores on a throw, and nests: an inner window leaves the outer one's state", () => { + enableHydration(); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; + (sharedConfig as any).registry = new Map(); + (sharedConfig as any).gather = () => {}; + let afterInner: unknown; + sharedConfig.hydrateWindow!("sc-o-", () => { + expect(() => + sharedConfig.hydrateWindow!("sc-i-", () => { + throw new Error("boom"); + }) + ).toThrow("boom"); + afterInner = sharedConfig.hydrating; + }); + expect(afterInner).toBe(true); + expect(sharedConfig.hydrating).toBe(false); + }); +}); diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index df1755987..3d250df63 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -281,71 +281,41 @@ function normalizeSlotContent(value: any): Node | Node[] { * instance disposes with its owning scope. */ /** - * Scoped hydration re-entry for one slot range (the late-boundary-resume - * pattern): gather the range's `_hk` nodes into a registry, flip the - * hydration window on for the synchronous render, and run under an owner - * whose id chain reproduces the document producer's keys. No claimable - * nodes in the range → plain client render (CSR boot, post-load streams). + * The registry/gather pair a boundary adopts under — read at adoption, so + * its occurrences' claims (which may run long after, under the frame's hold + * or at a fragment's reveal) gather against the root that holds the frame + * and not whichever `hydrate()` root replaced the live pair since (#2917). */ -function gatherClaims(el: Element, registry: Map) { - if (el.hasAttribute("_hk")) registry.set(el.getAttribute("_hk")!, el); - // A nested frame region is server-owned and opaque: the occurrences inside - // it run their own claims with their own registries. Not descending keeps - // gathering linear over an adopted tree — a blanket querySelectorAll here - // re-collected every nested comment's subtree once per enclosing level. - if (el.hasAttribute(FRAME_ID_ATTR)) return; - for (let c = el.firstElementChild; c; c = c.nextElementSibling) gatherClaims(c, registry); -} +type ClaimScope = { registry?: Map; gather?: (key: string) => void }; -// A deferred-fragment placeholder (`