diff --git a/.changeset/container-trace-claim-reads-snapshot.md b/.changeset/container-trace-claim-reads-snapshot.md new file mode 100644 index 000000000..483d2be1b --- /dev/null +++ b/.changeset/container-trace-claim-reads-snapshot.md @@ -0,0 +1,8 @@ +--- +"solid-js": patch +--- + +A fill that claims adopted markup reads the state the server rendered it from (frames-rulings 3.6 (iii), "the consumer parks" — S1's third commit ported onto `next` without its `claiming` hint). + +- `materializeContainerTrace` parks a replayed backlog beyond the snapshot until hydration ends (`onHydrationEnd`; the next microtask when no hydration is in progress) and then applies it as one ordinary update. A container trace is materialized at a fill's arg-read; when that fill claims server markup — the document's pass, a frame's deferred claim under its hold, a claim at a fragment's reveal after hydration-done — the snapshot is what the markup was rendered from, the claim trusts the markup (a text hole is never rewritten during a claim), and a store already past the markup left the DOM diverged for good. Parked, the claim reads the snapshot and the backlog lands after it, so the DOM catches up outside hydration. The release order is the one 3.2 pins: the claim, the frame's hold release, done, then the backlog. A fresh mount pays one beat for not being told apart: its backlog lands a microtask after its snapshot, before any paint. A failure in the backlog applies in order, after the parked patches. +- The materializer creates its projection under a DETACHED root. Rooted under the reading owner — during hydration an id-carrying one — a trace revived at t=0 consumed one child id per trace while one revived by a late claim consumed none, and a keyed sibling after the frame hydrated under different keys in the two runs. diff --git a/.changeset/frames-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/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index 0c27bd60b..45cbc8001 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -281,7 +281,7 @@ harness clean on **two seeds** with every law un-ignored; frames eager | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **A0** | **C18 — classification waits for the drain** (rulings step 1, 3d / 3.5; **in flight**). The predicate is one term in `adoptBoundary.recordsPending`: a `prop#n` occurrence is classified only after every delivered record has drained. The only page-halting red; lands as the rulings specified it. A1 then makes the mechanism moot (one write per drain; `#` decides the class — C18 becomes unrepresentable) and the pins stay as the assertion of the pending read. | **≈ +15 / +15 / +15 / 0** (the rulings' ≈ +50 min / +15 br; ≈ +25 br with the batched drain, which A1 supersedes) | none | **flip:** C18 ×3. Size: ±0.02 KB on every scenario (frames eager ≤ 13.8). | #3813 landed | none | | **A1** | **S-flush + the R deletions it unlocks.** `content = createMemo(() => host.landing(binding()))` — one reactive node per bound address resolved at the version's first root / error write (the host's `landing(address)`: a promise for a cold store, the value for a warm one); the enclosing `` pends on it, a switch is a new flight (`_inFlight` supersession, 1.6 (i) by construction), a refetch's landing is staged by the Transaction that read it (G7 closes). Deletes: **R.gate** (`arm`/`release`/`settle`/`setGate`/`mountGate`, the adopted twin), **R.stage** (`stage` / `stageTables` / `stagedContent` / `CONTENT_TOKEN` / `STAGED_DATA` / `FrameImpl#preview` / `#regionsChange` / `host.preview`; the chunk buffer-until-`complete` stays, one write), **R.version** (2a: one applied record keyed by identity; `#appliedRoot`), **R.dedupe** (per-prop memos in `slotArgsProxy`; `argsEquivalent` / `#refArgsUnchanged` / `#slotResolvedRefs` go), **R.error**'s latch. Files: `frames/src/client.ts` (`boundaryComponent`, `adoptBoundary`, `followAddress`), `frame-transport.ts` (`stage*`, `handle`), `frame-client.ts` (`#apply`, `#flush`, `preview`, `#syncSlots`' dedupe arms). | **≈ −1,150 / ≈ −1,150 / ≈ −1,150 / 0** (`est.` from the measured R total −1,863 with every feature kept, scaled to the 4,170 of 6,655 R-min these groups are; S-flush's own glue ≈ +110–190 min / +40 br is inside this) | none | **flip:** C5 (a, b, e) with the per-response data cell (1.2), C6 (b2), C7 (c), C17 (a); **C17 (c) re-pins** to 1.6 (i) `waiting → B`; C6 (b1) inverts (asserts the opposite of A0). Size: frames eager ≤ 12.7 KB (from 13.78), page base ≤ 43.7, live ≤ 47.5. | A0 | **removed / changed:** `ServerComponentHandlerOptions.onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost.preview` / `Frame.preview`, `STAGED_DATA` — the rulings' step-4 list. **New:** `FrameHost.landing(address)` (internal). | -| **A2** | **C3 via `initBoundaryResume` — S-hold** (the rulings' 3a, pulled forward: **this is what lets any later hold register**). `hydrateWindow(id, fn, roots?)` factored out of `resumeBoundaryHydration`; `initBoundaryResume`'s registration reachable from the adopter (`sharedConfig.resumeBoundary`); `adoptBoundary` registers the adopted frame's owner while `#syncSlots` leaves any adopt-time occurrence deferred (the held set — one registration per frame, 3.2) and releases when a sync leaves none or the frame disposes. The #2968 `setTimeout` poll becomes the registration with the drain's end as its bound (3.5); the resumed fill re-enters hydration through the window and claims under the producer's keys (C1 / C9 stay green). **With it, 3e ported onto `next`** (the detached root + the parked backlog beyond the snapshot, 3.6 (iii), without S1's `claiming` plumbing — the rulings' "else ≈ +90 / +25" arm, because S1 no longer lands first and the Phase A gate counts C19), and 3.2's release order (claim → hold release → done → backlog) pinned. Deletes **R.claim** (the range-scoped registry beside `gatherHydratable(el, root)`) and the counter half of **R.drain**. | **≈ −130 / ≈ −130 / ≈ −130 / ≈ +40** (`est.`: R.claim ≈ 472 min + R.drain's defer ≈ 270 min ≈ −215 br of cuts; the registration ≈ +100 min frames ≈ +60 br incl. the `hold` option; the 3e port ≈ +90 min / +25 br; solid `hydrateWindow` + the reach ≈ +40–65 min ≈ +12–20 br, the detached root ≈ +20 br) | none | **flip:** C3 (a) + the harness's C3 replay; C19 ×2 (3e); S1's C3 (b) flips at C3 (the traces tier). Size: frames eager ≤ 12.55; **app hydrating / compiled hydrating +≈ 40 br — the first cap raise, the maintainer's** (compiled hydrating is at its cap on this head: 30,943 vs 30.93 KB). | A1 (the deferred set is right only once the drain is one write and the landing node exists) | **solid:** `sharedConfig.resumeBoundary` or an `internal` export of the registration — no new counter, no new done path (3.1 ruled; the draft's `holdHydration` withdrawn); the detached projection root (3e). **frames:** `FrameOptions.hold(): () => void` (internal, wired by `adoptBoundary`). | +| **A2** | **C3 via `initBoundaryResume` — S-hold** (the rulings' 3a, pulled forward: **this is what lets any later hold register**). `hydrateWindow(id, fn, roots?)` factored out of `resumeBoundaryHydration`; `initBoundaryResume`'s registration reachable from the adopter (`sharedConfig.resumeBoundary`); `adoptBoundary` registers the adopted frame's owner while `#syncSlots` leaves any adopt-time occurrence deferred (the held set — one registration per frame, 3.2) and releases when a sync leaves none or the frame disposes. The #2968 `setTimeout` poll becomes the registration with the drain's end as its bound (3.5); the resumed fill re-enters hydration through the window and claims under the producer's keys (C1 / C9 stay green). **With it, 3e ported onto `next`** (the detached root + the parked backlog beyond the snapshot, 3.6 (iii), without S1's `claiming` plumbing — the rulings' "else ≈ +90 / +25" arm, because S1 no longer lands first and the Phase A gate counts C19), and 3.2's release order (claim → hold release → done → backlog) pinned. Deletes **R.claim** (the range-scoped registry beside `gatherHydratable(el, root)`) and the counter half of **R.drain**. **Landed in two parts** — #3837 (`holdBoundary`) and #3840 (`hydrateWindow` + the R.claim deletion; the park unconditional, rulings 3.6 "Landed") — measured **frames −109 br / hydrating +105 br** against the −130 / +40 estimate; the maintainer accepted the hydrating cost (2026-10-06; caps raised under a Size-Exception at the next integration PR), and **every further solid-side seam (S-adopted next) is to be measured on an edited dist copy before it is written.** | **≈ −130 / ≈ −130 / ≈ −130 / ≈ +40** (`est.`: R.claim ≈ 472 min + R.drain's defer ≈ 270 min ≈ −215 br of cuts; the registration ≈ +100 min frames ≈ +60 br incl. the `hold` option; the 3e port ≈ +90 min / +25 br; solid `hydrateWindow` + the reach ≈ +40–65 min ≈ +12–20 br, the detached root ≈ +20 br) | none | **flip:** C3 (a) + the harness's C3 replay; C19 ×2 (3e); S1's C3 (b) flips at C3 (the traces tier). Size: frames eager ≤ 12.55; **app hydrating / compiled hydrating +≈ 40 br — the first cap raise, the maintainer's** (compiled hydrating is at its cap on this head: 30,943 vs 30.93 KB). | A1 (the deferred set is right only once the drain is one write and the landing node exists) | **solid:** `sharedConfig.resumeBoundary` or an `internal` export of the registration — no new counter, no new done path (3.1 ruled; the draft's `holdHydration` withdrawn); the detached projection root (3e). **frames:** `FrameOptions.hold(): () => void` (internal, wired by `adoptBoundary`). | | **A3** | **C2 / C4 — a reveal is an apply (S-reveal, interim 2b).** `fr.subscribe((_, parent) => el.contains(parent) && frame.sync(parent))` — a document `$df` into adopted content syncs the frame (2.3, 2.4); a bare `children` mounts at the revealed range (C2 b); a `#`-named occurrence found recordless is a pending read (C2 a2, through A2's hold). Deletes **R.reveal**'s readiness / retry model (`#segmentReady`'s retry loop, the `#revealed` / `#fallbackShown` second set) — the segment swap's DOM half stays (T.morph). DR-4's structural form (2c, the document fragment as a store write) is its own plan and not this step. | **≈ −115 / ≈ −115 / ≈ −115 / 0** (`est.`: R.reveal 467 min ≈ −140 br; the one-liner +58 min / +23 br, measured as `Tglue-reveal` − `L8`) | none | **flip:** C2 (a2, b) + the harness's C2 replay; C4 (d) (the ledger is the store; the drain is one write). Size: frames eager ≤ 12.45. | A1, A2 | a `Frame` sync hook for the document reveal — internal, through the spread-cast options seam `adoptBoundary` already uses (rulings' list) | | **A4** | **C5 / C6 / C17 residue — S-record, S-ref.** **S-ref:** the codec table answers an undelivered `{$ref}` with a pending promise rejected at `complete` / `:error` (L1 — closes the silent-ref hole, re-attribution §5.3 item 1); a record's refs resolve through the table current at its apply (1.3) — the per-response data cell (1.2, ≈ 140 min, replacing `stageTables`) that A1 left as the C5 condition. **S-record:** the server half — the document sink writes `sc:slot::` as a **declared** pending ref at the marker (as `registerFragment` writes `_fr`) and settles it with the args, so `readHydratedValue`'s `.then` path carries the wait — or the solid write hook on `_$HY.r` (+40 B); either removes the poll's last reason. Deletes **R.refwait** (`#refsUnresolved`, the threaded `resolve`) and the poll half of **R.drain**. | **≈ −15 / ≈ −15 / ≈ −15 / 0** (`est.`: R.refwait 145 min + the poll ≈ 120 min ≈ −75 br; the cell ≈ +45 br + reject-at-complete ≈ +15; decode chunk +≈ 60 B min for pending-on-missing — lazy, not counted) | `decode.js` +≈ 60 B (S-ref) | **flip:** C6 (a1); C5 (a, b, e) if A1 shipped them conditional; C17 (c) confirmed under 1.6 (i). Size: frames eager ≤ 12.45 (±). | A1 (the landing node), A2 (a pending read is a hold) | **server:** the declared slot record (output shape, +≈ 30 B/record) **or solid:** the `_$HY.r` write hook (+40 B) — one of the two, the maintainer's pick (the declared record is recommended: it is A5's shape). | | **A5** | **C12 (c) client half + `claimRegionFragments` — S-adopted, S-key.** **S-adopted:** `_adoptedRoots: Set` in `hydration.ts`; `fragmentPolicy` swaps an unclaimed fragment after `_hydrationDone` when its `pl-` placeholder is inside an adopted root (`_$HY.fr.adopt(el)` / `unadopt(el)` from `adoptBoundary`, ≈ 30 B frames) — G4 closes and **`claimRegionFragments`** (R.claimant) deletes. **S-key:** `whenRevealed` published on `_$HY.fr` (+≈ 15 B solid) and the SC reference carries its covering fragment key (+≈ 30 B server); `installRevealHook`'s rescan and `boundaryWaiters` (D) collapse into `whenRevealed(key).then(...)`. **C12 (c) client half:** the adopted face shows what the server rendered (A0 withdraws the pin's expectation, 3.3); post-done the swap goes through S-adopted rather than freezing the fallback — the pin's **server half** (the sink's error markup) is A6's draft. | **≈ −65 / ≈ −65 / ≈ −65 / ≈ +5** (`est.`: R.claimant 153 min + `boundaryWaiters` ≈ 110 min + the rescan's rebind ≈ 80 min ≈ −95 br; frames glue ≈ +30 br; solid `_adoptedRoots` + `whenRevealed` ≈ +20 min net of the detached root already paid in A2) | none | **flip:** C12 (c) client arm (shows the server's outcome; swaps post-done); the G4 and G9 timing pins (new: `adopted-swap-post-done.spec`, `boundary-arrival.spec`). Size: frames eager ≤ 12.35; hydrating scenarios +≈ 5 (inside A2's raise). | A2 (the adopted frame's registration is what `fr.adopt` keys off), A3 | **solid:** `_$HY.fr.adopt/unadopt`, `whenRevealed` on `_$HY.fr`. **server:** the fragment key on the SC reference (+30 B of output). | diff --git a/documentation/server-components/frames-consistency-contract.md b/documentation/server-components/frames-consistency-contract.md index 49de0dfdf..fa8b22768 100644 --- a/documentation/server-components/frames-consistency-contract.md +++ b/documentation/server-components/frames-consistency-contract.md @@ -69,16 +69,22 @@ range) or removed by a deliberate replacement — never claimed by two passes, never left in the document beside a fresh clone of itself; a boundary element is adopted by at most one frame. -- **Mechanism:** `frames/src/client.ts:claimRender` (a range-scoped registry - handed over from the root registry), `client.ts:slotsFor.settle` (the - in-place check that turns a render into a claim), `frame-client.ts:FrameImpl.#replaceRange`, - `client.ts:adoptBoundary` + `claimedBoundaries` (one adopter per element), - `client.ts:documentBoundary` (a second mount goes fresh). +- **Mechanism:** `frames/src/client.ts:claimRender` (the claim window — + `sharedConfig.hydrateWindow`, the re-entry a streamed boundary's resume + takes: the range's keys gathered by the producer prefix into the registry + of the root the frame adopted under; A2b replaced the range-scoped + registry handed over from the root registry), `client.ts:slotsFor.settle` + (the in-place check that turns a render into a claim), + `frame-client.ts:FrameImpl.#replaceRange`, `client.ts:adoptBoundary` + + `claimedBoundaries` (one adopter per element), `client.ts:documentBoundary` + (a second mount goes fresh). - **Pin:** `c01-claim-once.spec.tsx` — arms: (a) two occurrences claim once each with no key miss and node identity preserved; (b) a fill that returns fresh nodes replaces, leaving no server node of the range behind; (c) a second mount of the same function while the first adopted mounts fresh and - the adopted element is untouched. + the adopted element is untouched. `c01-claim-window-roots.spec.tsx` (A2b): + a claim the frame makes after another `hydrate()` root replaced the live + registry/gather pair gathers against the root it adopted under (#2917). - **Verdict:** **holds on `next`** (3/3); the harness's C1 laws (key miss, unclaimed, duplicate, node identity) fired in none of 1000 cases. @@ -254,16 +260,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 +465,26 @@ read — may evaluate a render prop as a zero-arg accessor. A fill claiming server-rendered text shows, after the claim, the value its first read produced: when a container trace's patches landed before the claim, the DOM shows the patched value, not the snapshot the server rendered. +Under frames-rulings 3.6 (iii) the sentence is carried the other way round — +the first read IS the snapshot (what the markup was rendered from), the claim +keeps it, and the patches land after the claim as the update they are — so +what the settled DOM shows is still the value the fill read, patched. - **Mechanism:** `web/src/client.ts:insertExpression` (a hydrating render is a claim pass, not a mutation pass — by design), `materializeContainerTrace` - (replays snapshot + patches synchronously at revive, so the first read is - already the patched value), `claimRender`. -- **Pin:** `harness/replay.spec.tsx` C19 ×2 (`test.fails`) + control. -- **Verdict:** **red on `next`**, **green on S1** (§S1 delta). See §Red R10. + (replays snapshot + patches synchronously at revive and parks the patches + beyond the snapshot until hydration ends — 3.6 (iii), the 3e port; the + park is unconditional until S1's `claiming` hint lands at C3, 3.6 + "Landed"), `claimRender`. +- **Pin:** `harness/replay.spec.tsx` C19 ×2 + control; + `c19-claim-reads-snapshot.spec.tsx` — arms: (a) the t=0 claim with a trace + past the markup, (b) the deferred claim under the frame's hold, (c) the + release order (claim → hold release → done → backlog, rulings 3.2), + (d) a claim after hydration-done (a fragment's reveal), (e) the C11 + consequence (the store reads the snapshot inside the park), (f) id + determinism (the materializer's detached root). +- **Verdict:** was **red on `next`** (§Red R10); **green with the 3e port** + (`wip/frames-pass-integration`, A2b) — the harness clean on both seeds. ## Red on `next` @@ -728,13 +756,27 @@ the claimed text equals the value read. **Where it goes wrong.** A hydrating `insertExpression` is a claim pass — "not a mutation pass" — by design; the trace model assumes the server text IS the store's first value, which holds only if no patch precedes the claim. On S1 (`9927ddddd`, "a held -container-trace fill hydrates like a resident one") the shape is green: the -held fill's claim runs under a path that reconciles the text with the live -value (the same path that produces C3(b)'s red there). **Severity:** stale -value shown after hydration with no diagnostic; self-heals on the next -distinct patch (medium). **Should have been caught by:** `c11-trace-equals- -oracle` (d) — it patches only after the claim; no hydration test lets a -container trace move between SSR and claim. +container-trace fill hydrates like a resident one") the shape is green — not +because the claim reconciles the text (it never does; `9927ddddd`'s own +comment: "a text hole is never rewritten during a claim") but because the +materializer, told it is read for a claim, serves the snapshot and PARKS the +backlog until hydration ends; the DOM catches up after the claim. S1 is +evidence for frames-rulings 3.6 (iii), the consumer parks — not for (i), the +claim pass reconciling. **Fixed** by the 3e port (A2b on +`wip/frames-pass-integration`, #3840): `materializeContainerTrace` parks every +replayed backlog beyond the snapshot until `onHydrationEnd` (a microtask +when none is in progress), and roots its projection detached. The park is +**unconditional** (maintainer, 2026-10-06 — frames-rulings 3.6 "Landed"): +keying it on hydration being in progress at materialize time left post-done +claims (a fragment revealed after done, a record owed past done — corollary +4) red, because no hydration state says "claim" at that moment; the port +carries no `claiming` hint, so a fresh mount pays one beat instead. S1's +`revive(value, claiming?)` hint arrives at plan step C3 and keys the park on +the claim again then. +**Severity:** stale value shown after hydration with no diagnostic; +self-heals on the next distinct patch (medium). **Should have been caught +by:** `c11-trace-equals-oracle` (d) — it patches only after the claim; no +hydration test lets a container trace move between SSR and claim. ## Harness @@ -763,6 +805,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 +850,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 +866,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/documentation/server-components/frames-rulings.md b/documentation/server-components/frames-rulings.md index 96d05dd72..f0e654613 100644 --- a/documentation/server-components/frames-rulings.md +++ b/documentation/server-components/frames-rulings.md @@ -742,6 +742,19 @@ through the existing registration, not a new seam.** invoke → revive → materialize → claim), the frame's hold releases after that sync, done after the hold, the backlog after done. No deadlock; pin the order. +- **Cost as landed (maintainer, 2026-10-06).** The registration landed in + two parts — #3837 (`sharedConfig.holdBoundary`, the reach into + `initBoundaryResume`) and #3840 (`hydrateWindow` factored out of + `resumeBoundaryHydration`, the claim window `claimRender` enters through + it, R.claim deleted). Measured: frames eager −109 br; **every hydrating + page ≈ +120 min / +105 br** (the estimate was ≈ +40 br). The excess is the + window's scope capture — `markSnapshotScope`/`openLiveScope` and the + snapshot capture a claim after hydration-done needs so a late fragment's + claim reads under the producer's keys (C1); it is kept for that fidelity. + **Accepted.** The hydrating caps it crosses (app hydrating +103 B, + hydrating + stores +15, compiled hydrating +69, page live +31) are raised + under a maintainer Size-Exception at the next integration PR, not on + #3840. ### 3.3 A claim is a promise to account for the outcome @@ -955,13 +968,24 @@ update it is. The claim pass never rewrites a hole.** - **Ruled (iii) — the consumer parks** (maintainer, 2026-10-06, with the recommendations). The materializer, read for a claim, serves the snapshot and parks the backlog until hydration ends; the backlog applies as - ordinary updates. **C19 ×2 flip when the park is ported** — in progress - on `fix/frames-a2b-park-and-window` (the plan's A2 "3e port": the + ordinary updates. **C19 ×2 flipped with the park's port** on + `fix/frames-a2b-park-and-window` (#3840 — the plan's A2 "3e port": the detached root and the parked backlog beyond the snapshot, without S1's `claiming` plumbing). **C11** is read as "every observable point outside a claim's park". **Correction to the contract:** its R10 read S1 as evidence for (i); S1 is evidence for (iii) — `9927ddddd`'s mechanism is the park, and its own comment says a text hole is never rewritten during a claim. +- **Landed (maintainer, 2026-10-06): the park is unconditional.** The port + parks every replayed backlog beyond the snapshot, not only one + materialized while hydration is in progress — the plan's key + (`isHydrationInProgress()` at materialize time) left post-done claims red: + under corollary 4 a fragment revealed after done, or a record owed past + done, claims legitimately, and at that moment no hydration state says + "claim". A fresh (non-claiming) mount therefore pays one beat — the + backlog lands a microtask later. Accepted as the shape until S1: the + `claiming` hint (S1's `revive(value, claiming?)`, threaded from the + adopt-time mount) arrives at plan step C3 with S1 and converts the park + back to keyed-on-claim then. - **Mechanism today.** `web/src/client.ts:insertExpression` under hydration is a claim pass, not a mutation pass (C1/C9: nothing moves); diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 5726b07d7..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[]; }; /** @@ -1237,12 +1269,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 +1317,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 +1349,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 +1385,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 +1394,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 +1402,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 +1436,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 --- @@ -1893,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 @@ -2510,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, @@ -2525,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/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/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 (`