Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/container-trace-claim-reads-snapshot.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .changeset/frames-claim-through-hydrate-window.md
Original file line number Diff line number Diff line change
@@ -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 `<Loading>` 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.
2 changes: 1 addition & 1 deletion documentation/plans/frames-savings-pass.md

Large diffs are not rendered by default.

119 changes: 88 additions & 31 deletions documentation/server-components/frames-consistency-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand All @@ -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 `<Loading>` 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/`):

Expand Down
28 changes: 26 additions & 2 deletions documentation/server-components/frames-rulings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);
Expand Down
Loading