From b39248f1aa63fedbe629fcaafec146825d664947 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 04:16:11 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(web/frames):=20hydration-done=20counts?= =?UTF-8?q?=20the=20frame's=20holds=20=E2=80=94=20a=20waiting=20adopted=20?= =?UTF-8?q?occurrence=20is=20a=20pending=20boundary=20(C3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frames-rulings 3.1 (ruled): hydration-done follows non-SC Solid 2 — done is what `checkHydrationComplete` says (the root pass over, `_pendingBoundaries` zero), and the frames client participates through that mechanism, with no accounting of its own. 3.2, the carrier: ONE registration per frame while a sync leaves an adopted occurrence waiting to mount (for its record, for a `{$ref}`'s data), released by the first sync that leaves none or by disposal. solid-js: `sharedConfig.holdBoundary(id)` (internal) — `initBoundaryResume`'s registration for a holder with nothing to resume: the count, the owner's `_hp` mark, the disposal release; returns the release, which checks completion. One assignment in `enableHydration`. web/frames: `FrameOptions.hold()` (adopt path; wired by `adoptBoundary` under the component's owner, only while `isHydrationInProgress()`, keyed `sc:` so no fragment's bookkeeping is touched); `#syncSlots` tracks whether a full sync left an unmounted occurrence waiting and registers/releases at its end; `dispose` releases. Harness: the C3 law exempts a done that fired at or after the mount's disposal — a disposed holder owes no claim (its release is what lets done fire, as a disposed 's is). Verified to hide nothing on `next` (C3 stays 280/500 there). Pins flipped to `test`: C3 (a), harness C3 ×1. Campaign (500 cases): C3 280 → 0 (seed 3289), 268 → 0 (seed 91501); only C19 remains. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/frames-hold-is-pending-boundary.md | 6 ++ packages/solid/src/client/hydration.ts | 26 ++++++ packages/web/frames/src/client.ts | 19 ++++ packages/web/frames/src/frame-client.ts | 39 +++++++- .../c03-hydration-done-counts-holds.spec.tsx | 93 +++++++++---------- .../web/test/consistency/harness/oracle.ts | 10 +- .../test/consistency/harness/replay.spec.tsx | 20 ++-- 7 files changed, 153 insertions(+), 60 deletions(-) create mode 100644 .changeset/frames-hold-is-pending-boundary.md diff --git a/.changeset/frames-hold-is-pending-boundary.md b/.changeset/frames-hold-is-pending-boundary.md new file mode 100644 index 000000000..b8dd2ada4 --- /dev/null +++ b/.changeset/frames-hold-is-pending-boundary.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Hydration-done counts the frames client's holds (frames-rulings 3.1, ruled): an adopted occurrence the frame has not claimed yet — waiting for its args record or a `{$ref}`'s data — registers as a pending boundary through the same registration a streamed `` resume takes (`sharedConfig.holdBoundary`, internal), so `onHydrationEnd` and `isHydrationInProgress()` mean the same thing with or without server components (contract C3 a). diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index cc7aa8422..5726b07d7 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -202,6 +202,24 @@ type SharedConfig = { * @internal */ isClaiming?: () => boolean; + /** + * Register a hold on hydration-done under the current owner — a pending + * boundary in everything but a resume. Hydration-done follows non-SC + * Solid 2 (frames-rulings 3.1): a client hold on adopted server markup + * — an occurrence waiting for its args record, a `{$ref}` wait — counts + * through the same registration a streamed `` resume takes, so + * `onHydrationEnd` and `isHydrationInProgress` mean the same thing with + * or without server components. Call it under the holding owner (its + * disposal releases), with an `id` no fragment uses, and only while + * `isHydrationInProgress()` — a hold taken on a page that never hydrated, + * or after it settled, is the holder's business, not the page's. Returns + * the release (idempotent). Assigned by enableHydration(); absent in CSR + * bundles (nothing to hold). Cross-package wiring; not part of the + * user-facing API. + * + * @internal + */ + holdBoundary?: (id: string) => () => void; }; /** @@ -1867,6 +1885,14 @@ export function enableHydration() { sharedConfig.isHydrationInProgress = isHydrationInProgress; sharedConfig.onHydrationEnd = onHydrationEnd; sharedConfig.isClaiming = isClaiming; + // A client hold on adopted markup is a resume's registration — the count, + // the owner's `_hp` mark (a rerun under it is still the claim in + // progress), the disposal release — with nothing to resume; `id` keys the + // registration's bookkeeping, and the holder passes one no fragment uses. + sharedConfig.holdBoundary = id => { + const release = initBoundaryResume(getOwner()!, id)[2]; + return () => release() && checkHydrationComplete(); + }; // Take ownership of streamed-fragment reveals (see the fragment ledger). // The header script creates `_$HY` before any module runs, so the hook is diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index ffb2cf770..cdb0afa40 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -1416,6 +1416,25 @@ function adoptBoundary( return !!(hy && hy.fr && hy.fr.pending()); }, drainRecords, + // Hydration-done follows non-SC Solid 2 (frames-rulings 3.1, ruled): + // an adopted occurrence the frame has not claimed yet — waiting for + // its record, for a `{$ref}`'s data — is a pending boundary in + // everything but a resume, and registers as one through the same + // registration a streamed `` takes (`sharedConfig. + // holdBoundary`), under this component's owner so disposal releases + // it, keyed where no fragment is. No parallel accounting, no second + // "done": `onHydrationEnd` and `isHydrationInProgress()` mean the + // same thing with or without server components. Only while hydration + // is in progress: a hold taken on a page that never hydrated (a + // client render adopting server markup) or after it settled is the + // frame's business, not the page's. Untracked: the registration reads + // its trigger once, which is not a read of this component's. + hold: () => { + const sc: any = sharedConfig; + return sc.holdBoundary && sc.isHydrationInProgress() + ? runWithOwner(owner, () => untrack(() => sc.holdBoundary("sc:" + id))) + : () => {}; + }, // The identity split binds the frame to the call ADDRESS (id + args // hash), but the document producer stamped `_hk` keys and region fids // under the wire name — the bare function id. Hydration-claim prefixes diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index d711c90c1..e0ff04583 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -365,6 +365,16 @@ export interface FrameOptions { recordsPending?(): boolean; /** Re-absorb the document's arrived-by-now records (idempotent per key). */ drainRecords?(): void; + /** + * Adopt path only. Called when a sync leaves an adopted occurrence + * waiting — for its args record, for a `{$ref}`'s data — while none was + * before; returns the release, called when a sync leaves none waiting or + * the frame disposes. The integration registers the hold with whatever + * counts its page as not yet settled (hydration-done counts it as a + * pending boundary — frames-rulings 3.1): a claim the frame has not made + * yet is page work still pending. + */ + hold?(): () => void; } /** * Client frame runtime — the consumer side of the frame stream (port of the @@ -927,6 +937,9 @@ class FrameImpl { // The pending re-check for adopt-time occurrences deferred on a // still-arriving args record (#2968 — see #syncSlots). #recordRefresh = null; + // The release of the frame's hold with the integration while a sync + // leaves an occurrence waiting to mount (see #syncSlots' end). + #hold; #disposed = false; // Stable identity so a pending stylesheet holds at most one waiter per // frame across repeated readiness checks. @@ -1314,6 +1327,10 @@ class FrameImpl { const found = new Map(); if (root) collectSlots(root.firstChild, null, found, found); else this.#collectSlots(found, found); + // Whether this sync leaves an occurrence WAITING to mount — for its + // record, for a `{$ref}`'s data: a claim the frame owes the page and has + // not made yet (see the hold at the end). + let waiting = false; for (const [occurrence, start] of found) { const callback = this.#resolveSlot(propOf(occurrence)); @@ -1341,7 +1358,10 @@ class FrameImpl { // real args (an async one then suspends and holds, as the value tier // intends). A fresh mount is skipped for the same reason — mounting // with a fabricated `undefined` is what makes it visible. - if (record && record.kind === "slot" && this.#refsUnresolved(record.args)) continue; + if (record && record.kind === "slot" && this.#refsUnresolved(record.args)) { + waiting ||= !this.#mountedSlots.has(occurrence); + continue; + } // A mount whose output the morph destroyed (its range was recreated // inside a different server parent — ranges only relocate among // siblings) is a zombie: remount fresh so content stays correct, even @@ -1386,6 +1406,7 @@ class FrameImpl { // (a record dropped, or marker and record minted under different // ids), never something the fill can fix; dev names it. if (record === undefined && isCalled(occurrence)) { + waiting ||= !this.#mountedSlots.has(occurrence); if (this.#options.adopt && this.#options.recordsPending?.()) { this.#recordRefresh ??= setTimeout(() => { this.#recordRefresh = null; @@ -1514,9 +1535,24 @@ class FrameImpl { for (const occurrence of [...this.#mountedSlots]) { if (!found.has(occurrence)) this.#unmountSlot(occurrence); } + // The frame's hold (frames-rulings 3.2): ONE registration with the + // integration while a sync leaves an occurrence waiting to mount — + // whatever it waits for (the record, the `{$ref}` data, a hold added + // later) — released by the first sync that leaves none, or by + // disposal. The waits are bounded as a `` resume's is: the + // record by the document's records running out (`recordsPending`), + // the ref by the stream's `complete`/`:error`. + if (waiting && !this.#hold) this.#hold = this.#options.hold?.(); + else if (!waiting && this.#hold) this.#releaseHold(); } } + #releaseHold() { + const release = this.#hold; + this.#hold = undefined; + release && release(); + } + /** * Invoke a slot occurrence's callback with resolved props. `ctx.existing` * carries the range's current interior (server-rendered client content on @@ -1968,6 +2004,7 @@ class FrameImpl { clearTimeout(this.#recordRefresh); this.#recordRefresh = null; } + this.#releaseHold(); for (const key of [...this.#slotCleanups.keys()]) this.#runSlotCleanups(key); // Release this frame's occurrences' records from the store that owns them // (an ancestor's, for a region frame's nested occurrences) so a torn-down diff --git a/packages/web/test/consistency/c03-hydration-done-counts-holds.spec.tsx b/packages/web/test/consistency/c03-hydration-done-counts-holds.spec.tsx index 4d33f994b..e4724619c 100644 --- a/packages/web/test/consistency/c03-hydration-done-counts-holds.spec.tsx +++ b/packages/web/test/consistency/c03-hydration-done-counts-holds.spec.tsx @@ -40,53 +40,52 @@ describe("C3 — hydration-done counts every hold", () => { // boundary adopts (document.readyState "loading"), and the occurrence's // args record has not executed yet — the frame defers the mount. Hydration // must not report done while that occurrence's server nodes are unclaimed. - test.fails( - "(a) record defer: hydration does not report done while an adopted occurrence waits on its record", - async () => { - const fid = freshFid("c3a"); - vi.spyOn(document, "readyState", "get").mockReturnValue("loading"); - page = bootPage( - frameHtml(fid, `
    ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
`) - ); - const Comp = (globalThis as any)._$SC.r(fid); - const invocations: number[] = []; - let invocationsAtEnd = -1; - let inProgressAtEnd: boolean | undefined; - const dispose = hydrate( - () => ( - { - invocations.push(1); - return
  • {p.text}
  • ; - }} - /> - ), - page.container - ); - onHydrationEnd(() => { - invocationsAtEnd = invocations.length; - inProgressAtEnd = hydrationInProgress(); - }); - await quiesce(); - // The record script the parser was still owed. - page.slotRecord(fid, "item#0", { text: "one" }); - await quiesce(); - await quiesce(); - // The occurrence did claim in the end (the deferral is invisible)… - expect(invocations.length).toBe(1); - expect(page.container.textContent).toBe("one"); - // …but hydration-done ran ahead of it: at the end callback the fill had - // not run, `isHydrationInProgress()` already read false, and nothing - // counted the hold (`_pendingBoundaries` only knows - // boundaries). Observed on `next`: invocationsAtEnd === 0 (expected 1). - // The dev completion check stays quiet here only because the deferred - // claim lands before its timer reads the registry. - expect(page.warnings.filter(w => w.includes("unclaimed server-rendered"))).toEqual([]); - expect(inProgressAtEnd).toBe(false); - expect(invocationsAtEnd).toBe(1); - dispose(); - } - ); + // Was red on `next` (the deferral registered with nothing hydration + // counts); green under frames-rulings 3.1 (ruled) / 3.2: the frame's hold + // is a pending boundary — registered through `sharedConfig.holdBoundary` + // while a sync leaves an adopted occurrence waiting, released by the sync + // that claims it. + test("(a) record defer: hydration does not report done while an adopted occurrence waits on its record", async () => { + const fid = freshFid("c3a"); + vi.spyOn(document, "readyState", "get").mockReturnValue("loading"); + page = bootPage( + frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `) + ); + const Comp = (globalThis as any)._$SC.r(fid); + const invocations: number[] = []; + let invocationsAtEnd = -1; + let inProgressAtEnd: boolean | undefined; + const dispose = hydrate( + () => ( + { + invocations.push(1); + return
  • {p.text}
  • ; + }} + /> + ), + page.container + ); + onHydrationEnd(() => { + invocationsAtEnd = invocations.length; + inProgressAtEnd = hydrationInProgress(); + }); + await quiesce(); + // The record script the parser was still owed. + page.slotRecord(fid, "item#0", { text: "one" }); + await quiesce(); + await quiesce(); + // The occurrence did claim in the end (the deferral is invisible)… + expect(invocations.length).toBe(1); + expect(page.container.textContent).toBe("one"); + // …and hydration-done waited for it: at the end callback the fill had + // run (on `next` invocationsAtEnd was 0 — done ran ahead, nothing + // counted the hold). + expect(page.warnings.filter(w => w.includes("unclaimed server-rendered"))).toEqual([]); + expect(inProgressAtEnd).toBe(false); + expect(invocationsAtEnd).toBe(1); + dispose(); + }); // Arm (b): a container-trace arg present at adoption. The record and its // trace snapshot are in the page when the boundary adopts; the fill reads diff --git a/packages/web/test/consistency/harness/oracle.ts b/packages/web/test/consistency/harness/oracle.ts index 8b1d85ab1..8cedea9e7 100644 --- a/packages/web/test/consistency/harness/oracle.ts +++ b/packages/web/test/consistency/harness/oracle.ts @@ -208,7 +208,15 @@ export function settled(w: World): Finding[] { export function end(w: World): Finding[] { const f: Finding[] = []; const at = (id: string, law: string, detail: string) => f.push({ id, law, step: w.step, detail }); - if (w.hydrationEnd && w.hydrationEnd.mountedButUninvoked.length) + // A mount disposed before done owes no claim: its hold releases at the + // disposal (as a disposed 's registration does — a boundary that + // can never resume must not hold global hydration open forever), and the + // server markup it left behind is nobody's to claim. + if ( + w.hydrationEnd && + w.hydrationEnd.mountedButUninvoked.length && + !(w.disposedAt >= 0 && w.hydrationEnd.step >= w.disposedAt) + ) at( "C3", "done-counts-holds", diff --git a/packages/web/test/consistency/harness/replay.spec.tsx b/packages/web/test/consistency/harness/replay.spec.tsx index bcc0d6407..4b864a3c3 100644 --- a/packages/web/test/consistency/harness/replay.spec.tsx +++ b/packages/web/test/consistency/harness/replay.spec.tsx @@ -149,17 +149,15 @@ describe("harness replay — reduced counterexamples", () => { ).toEqual([]); }); - // C3 (R1, rediscovered): hydration-end fires while the deferred - // occurrence is still unclaimed. Observed: end at step 0 with item#0 - // mounted and uninvoked. Expected: none. - test.fails( - "C3 done-counts-holds: hydration-end fires before the deferred record claim (R1)", - async () => { - expect( - await findings({ ...base, occurrences: [render(0)], events: [H, R(0)] }, "C3") - ).toEqual([]); - } - ); + // C3 (R1, rediscovered): hydration-end fired while the deferred + // occurrence was still unclaimed. Was: end at step 0 with item#0 mounted + // and uninvoked. Green under frames-rulings 3.1/3.2 (the frame's hold is + // a pending boundary): none. + test("C3 done-counts-holds: hydration-end fires before the deferred record claim (R1)", async () => { + expect(await findings({ ...base, occurrences: [render(0)], events: [H, R(0)] }, "C3")).toEqual( + [] + ); + }); // Smoke: the canonical order — records, hydrate — holds every law. test("smoke: records before hydrate, no fragments: no finding", async () => { From af64ec143099341c8c84d8363448c15c7e7c817e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 04:39:11 -0700 Subject: [PATCH 2/2] =?UTF-8?q?web/frames:=20trim=20the=20hold's=20bytes?= =?UTF-8?q?=20=E2=80=94=20one=20mounted=20read=20per=20occurrence=20in=20#?= =?UTF-8?q?syncSlots,=20the=20guard=20reads=20isHydrationInProgress=20alon?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit −48 B min on the frames client (43,411 → 43,363); the zombie check now precedes the {$ref} wait, so a zombie held on a ref is unmounted at once rather than when the ref resolves. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- packages/web/frames/src/client.ts | 2 +- packages/web/frames/src/frame-client.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index cdb0afa40..0b0fafaee 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -1431,7 +1431,7 @@ function adoptBoundary( // its trigger once, which is not a read of this component's. hold: () => { const sc: any = sharedConfig; - return sc.holdBoundary && sc.isHydrationInProgress() + return sc.isHydrationInProgress?.() ? runWithOwner(owner, () => untrack(() => sc.holdBoundary("sc:" + id))) : () => {}; }, diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index e0ff04583..df5cbc9ae 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -1358,25 +1358,25 @@ class FrameImpl { // real args (an async one then suspends and holds, as the value tier // intends). A fresh mount is skipped for the same reason — mounting // with a fabricated `undefined` is what makes it visible. - if (record && record.kind === "slot" && this.#refsUnresolved(record.args)) { - waiting ||= !this.#mountedSlots.has(occurrence); - continue; - } // A mount whose output the morph destroyed (its range was recreated // inside a different server parent — ranges only relocate among // siblings) is a zombie: remount fresh so content stays correct, even // though state can't survive a destroyed node. const prev = this.#slotNodes.get(occurrence); const prevFirst = Array.isArray(prev) ? prev[0] : prev; + let mounted = this.#mountedSlots.has(occurrence); // A data occurrence is never a zombie: its nodes are the server's // consumers, not the fill's output — a replaced element is a consumer // change (rebind, below), and an occurrence no element reads any more // is simply not found (unmounted at the end). - const zombie = - !consumers && this.#mountedSlots.has(occurrence) && prevFirst && !prevFirst.parentNode; - if (zombie) { + if (!consumers && mounted && prevFirst && !prevFirst.parentNode) { this.#mountedSlots.delete(occurrence); this.#runSlotCleanups(occurrence); + mounted = false; + } + if (record && record.kind === "slot" && this.#refsUnresolved(record.args)) { + waiting ||= !mounted; + continue; } // The occurrence's name decides its class: the producer mints every // CALLED occurrence as `prop#n` and emits its record at the call, @@ -1406,7 +1406,7 @@ class FrameImpl { // (a record dropped, or marker and record minted under different // ids), never something the fill can fix; dev names it. if (record === undefined && isCalled(occurrence)) { - waiting ||= !this.#mountedSlots.has(occurrence); + waiting ||= !mounted; if (this.#options.adopt && this.#options.recordsPending?.()) { this.#recordRefresh ??= setTimeout(() => { this.#recordRefresh = null; @@ -1414,11 +1414,11 @@ class FrameImpl { this.#options.drainRecords?.(); this.#syncSlots(); }); - } else if ("_SOLID_DEV_" && consumers && !this.#mountedSlots.has(occurrence)) + } else if ("_SOLID_DEV_" && consumers && !mounted) devSlotOrphan(this, occurrence, consumers, "record"); continue; } - if (!this.#mountedSlots.has(occurrence)) { + if (!mounted) { // Direct-insert occurrences have no `slot:` record and mount with // empty props; render-function occurrences mount with resolved props. // Mounting replaces the range interior: on a fresh stream it is