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
6 changes: 6 additions & 0 deletions .changeset/frames-hold-is-pending-boundary.md
Original file line number Diff line number Diff line change
@@ -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 `<Loading>` resume takes (`sharedConfig.holdBoundary`, internal), so `onHydrationEnd` and `isHydrationInProgress()` mean the same thing with or without server components (contract C3 a).
26 changes: 26 additions & 0 deletions packages/solid/src/client/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Loading>` 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;
};

/**
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions packages/web/frames/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Loading>` 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.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
Expand Down
49 changes: 43 additions & 6 deletions packages/web/frames/src/frame-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -1341,22 +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)) 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,
Expand Down Expand Up @@ -1386,18 +1406,19 @@ 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 ||= !mounted;
if (this.#options.adopt && this.#options.recordsPending?.()) {
this.#recordRefresh ??= setTimeout(() => {
this.#recordRefresh = null;
if (this.#disposed) return;
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:<id>` record and mount with
// empty props; render-function occurrences mount with resolved props.
// Mounting replaces the range interior: on a fresh stream it is
Expand Down Expand Up @@ -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 `<Loading>` 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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<ul>${slotRange("item#0", fillHtml(fid, "item#0", "one"))}</ul>`)
);
const Comp = (globalThis as any)._$SC.r(fid);
const invocations: number[] = [];
let invocationsAtEnd = -1;
let inProgressAtEnd: boolean | undefined;
const dispose = hydrate(
() => (
<Comp
item={(p: { text: string }) => {
invocations.push(1);
return <li>{p.text}</li>;
}}
/>
),
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 <Loading>
// 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, `<ul>${slotRange("item#0", fillHtml(fid, "item#0", "one"))}</ul>`)
);
const Comp = (globalThis as any)._$SC.r(fid);
const invocations: number[] = [];
let invocationsAtEnd = -1;
let inProgressAtEnd: boolean | undefined;
const dispose = hydrate(
() => (
<Comp
item={(p: { text: string }) => {
invocations.push(1);
return <li>{p.text}</li>;
}}
/>
),
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
Expand Down
10 changes: 9 additions & 1 deletion packages/web/test/consistency/harness/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Loading>'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",
Expand Down
20 changes: 9 additions & 11 deletions packages/web/test/consistency/harness/replay.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down