Skip to content

Commit c2cdc82

Browse files
Matrix: the address-switch dimension gets its two missing cells
The brenelz round exposed two lifecycle shapes the matrix never pinned: - Byte-identical shell across a switch (dom-expressions#564's policy): slot-driven content ships its differences as records, so the new address's html can be the same bytes — root affinity is per stream, and the identical shell still answers the gate while the record-driven difference lands. - Double-switch mid-flight: the gate re-arms per switch, only the LIVE call's answer settles it, the superseded call's late stream warms its own store without touching the boundary — and switching back re-materializes from that warm synchronously. Plus the reveal-then-refetch cell over an adopted region (#2979's follow-through): the reveal-driven record drain is a one-time repair, and the region stays fully live for the refetch that follows. MATRIX.md gains the address-switch section (the #2977 trio was never tabled) and drops its two GAP rows — both were closed on this branch (shell gate, occurrence-unmount cleanup); the stale test.fails comments go with them. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 80970b7 commit c2cdc82

3 files changed

Lines changed: 205 additions & 25 deletions

File tree

packages/solid-web/test/frames-adopted-region-fragments.spec.tsx

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ function mountBoundary(fid: string) {
132132
}
133133

134134
describe("deferred fragments inside an adopted region (#2978)", () => {
135+
let host: any;
136+
135137
beforeAll(() => {
136138
// The whole page, before any boundary lookup: one SSR'd boundary per test.
137139
document.body.innerHTML =
@@ -140,14 +142,16 @@ describe("deferred fragments inside an adopted region (#2978)", () => {
140142
boundaryHtml("deadlock/nested", "21", "outer-fallback") +
141143
boundaryHtml("deadlock/replaced", "31", "replaced-fallback") +
142144
boundaryHtml("deadlock/records", "41", "records-fallback") +
145+
boundaryHtml("deadlock/refetch", "51", "refetch-fallback") +
143146
"</div>";
144147
(window as any)._$HY = { r: {}, fe() {} };
145148
enableHydration();
146149
installSwapRuntime();
147150
vi.stubGlobal("fetch", () => {
148151
throw new Error("fetch must not be called at t=0");
149152
});
150-
installServerComponents(makeHost());
153+
host = makeHost();
154+
installServerComponents(host);
151155
});
152156

153157
afterAll(() => {
@@ -315,4 +319,71 @@ describe("deferred fragments inside an adopted region (#2978)", () => {
315319

316320
dispose();
317321
});
322+
323+
// #2979's follow-through: the reveal-driven drain is a one-time repair for
324+
// records that rode the FRAGMENT — it must leave the region fully live.
325+
// After the reveal, a refetch's stream is the region's ordinary future:
326+
// its slot record updates the occurrence, its html morphs the interior,
327+
// and the ledger (already resolved by the reveal) stays resolved.
328+
test("reveal, then refetch: after the reveal-driven record drain a later stream still morphs and updates the occurrence", async () => {
329+
declareFragment("51");
330+
331+
const Page = (window as any)._$SC.r("deadlock/refetch");
332+
const appEl = document.getElementById("app") as HTMLElement;
333+
let mount!: HTMLDivElement;
334+
const dispose = createRoot(d => {
335+
<div ref={mount}>
336+
<Loading fallback={<span>shell-fallback</span>}>
337+
<Page row={(p: { label: string }) => <b>row:{p.label}</b>} />
338+
</Loading>
339+
</div>;
340+
appEl.appendChild(mount);
341+
return d;
342+
});
343+
flush();
344+
await settle();
345+
flush();
346+
347+
completeHydrationPass();
348+
await settle();
349+
350+
// The #2979 shape: the fragment's chunk carries the occurrence's markers
351+
// AND the record naming its args.
352+
(window as any)._$HY.r["sc:slot:deadlock/refetch:row#0"] = { label: "settled" };
353+
deliverFragment("51", "<!--slot:row#0:start--><!--slot:row#0:end-->");
354+
flush();
355+
await settle();
356+
flush();
357+
358+
const frameEl = document.querySelector('[data-fid="deadlock/refetch"]') as HTMLElement;
359+
expect(frameEl.textContent).toContain("row:settled");
360+
expect((window as any)._$HY.fr.pending()).toBe(false);
361+
362+
// The refetch: a navigation-shaped stream lands on the adopted boundary
363+
// (the document wire id IS the argless call's address). Its record
364+
// changes the occurrence's args; its html morphs around the slot range.
365+
host.apply({
366+
type: "slot",
367+
id: "deadlock/refetch",
368+
version: 1,
369+
key: "row#0",
370+
args: { label: "refetched" }
371+
});
372+
host.apply({
373+
type: "html",
374+
id: "deadlock/refetch",
375+
version: 1,
376+
html: "<h1>Page2</h1><section><!--slot:row#0:start--><!--slot:row#0:end--></section>"
377+
});
378+
flush();
379+
await settle();
380+
flush();
381+
382+
expect(frameEl.querySelector("h1")!.textContent).toBe("Page2");
383+
expect(frameEl.textContent).toContain("row:refetched");
384+
expect(frameEl.textContent).not.toContain("row:settled");
385+
expect((window as any)._$HY.fr.pending()).toBe(false);
386+
387+
dispose();
388+
});
318389
});

packages/solid-web/test/lifecycle-matrix/MATRIX.md

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ is mount-kind-independent; enumerated once where it is richest) · **existing**
4949
| args changed across responses — re-call path | `call-driven-slots``raw-frame/re-call-path` | pass — via a raw `createFrameElement`; unreachable through `dynamic()`: solid-web's `slotsFor` registers `ctx.onUpdate` for every invoked occurrence, so the frame never re-calls there |
5050
| `$key`ed occurrences across reorders (state follows key) | `call-driven-slots``call-driven/slot/keyed-reorder` | pass — live range relocates with its interior and signal state |
5151
| occurrence removed in a later response — DOM unmount | `call-driven-slots``call-driven/slot/occurrence-removed` (state reset half) | pass |
52-
| occurrence removed — fill's `onCleanup` runs at unmount | `call-driven-slots``call-driven/slot/occurrence-removed` (cleanup half) | **GAP**solid `onCleanup` in a fill registers on the BOUNDARY owner; occurrence unmount runs only frame-level ctx cleanups, so consumer cleanup fires at boundary dispose, not occurrence unmount |
52+
| occurrence removed — fill's `onCleanup` runs at unmount | `call-driven-slots``call-driven/slot/occurrence-removed` (cleanup half) | passclosed gap: a stream-mounted fill renders under a per-occurrence owner disposed at unmount (see closed-gap notes below) |
5353
| occurrence re-introduced after removal (fresh invocation, state reset) | `call-driven-slots``call-driven/slot/occurrence-removed` | pass |
5454
| re-sent identical record (dedupe: no re-call, no state loss) | `call-driven-slots``call-driven/slot/re-sent-identical-record` | pass |
5555
| adopted occurrence: identical re-send dedupes; changed record updates live | `document-adoption``t=0/adopted-occurrence-records` | pass |
@@ -86,20 +86,39 @@ is mount-kind-independent; enumerated once where it is richest) · **existing**
8686
| Cell | Spec / test | Status |
8787
| --- | --- | --- |
8888
| fill `onCleanup` fires on frame/boundary dispose, exactly once | `call-driven-slots``call-driven/cleanup-disposal` | pass |
89-
| fill `onCleanup` fires on occurrence unmount | `call-driven-slots``occurrence-removed` | **GAP** (see above) |
89+
| fill `onCleanup` fires on occurrence unmount | `call-driven-slots``occurrence-removed` | pass (see above) |
9090
| no double-dispose (second owner disposal is a no-op; ctx cleanups don't re-fire at frame dispose after occurrence unmount) | `call-driven-slots``cleanup-disposal` + `raw-frame/re-call-path` (ctx.onCleanup) | pass |
9191
| a disposed frame ignores late chunks (store warms; no DOM writes, no crash) | `call-driven-slots``cleanup-disposal` (late chunks) | pass |
9292

93+
## Address switch on a live site
94+
95+
The identity split (DR-1): an args change resolves to the SAME component, so
96+
the instance keeps its mount and rebinds to the new call's address. The
97+
binding resolves at response-header time — which is not an answer — so a
98+
re-armable gate holds the driving source's `isPending` until the new
99+
address's first content, server fallback, or error applies (#2977).
100+
101+
| Cell | Spec / test | Status |
102+
| --- | --- | --- |
103+
| switch holds pending until the new address's first content applies | `call-driven-lifecycle``call-driven/args-switch-gate` | pass |
104+
| a shell carrying a server `<Loading>` fallback IS an answer (pending drops at the shell, not the late reveal) | `call-driven-lifecycle``args-switch-gate` (server fallback) | pass |
105+
| an errored stream is an answer (pending must not outlive the response) | `call-driven-lifecycle``args-switch-gate` (error) | pass |
106+
| byte-identical shell across the switch still answers (root affinity is per stream — dom-expressions#564; the difference ships as records) | `call-driven-lifecycle``args-switch-gate` (byte-identical) | pass |
107+
| double-switch mid-flight: the gate re-arms; the superseded call's late answer warms its store, never the live boundary; switching back re-materializes from that warm | `call-driven-lifecycle``args-switch-gate` (mid-flight) | pass |
108+
| adopted site: t=0 zero-network mount, then a switch holds pending until the new address answers | `document-adoption``t=0/adopted-switch-gate` | pass |
109+
93110
## Loading / reveal gating
94111

95112
| Cell | Spec / test | Status |
96113
| --- | --- | --- |
97114
| fragment + reveal sequences reveal when ready, order-independently (reveal-before-content waits; independent segments reveal as their pairs complete) | `call-driven-lifecycle``call-driven/fragment-reveal-gating` | pass |
98115
| fallback reveal materializes the placeholder template for a late fragment; the real reveal swaps it out | `call-driven-lifecycle``fragment-reveal-gating` (fallback) | pass |
99-
| shell gate: fresh call-driven mount's covering `<Loading>` holds until first content applies | `call-driven-lifecycle``call-driven/shell-gate` | **GAP**the boundary releases at response HEAD (when the transport resolves the call with its binding); between head and the first html record the user sees an empty `<dx-frame>` (the empty-frame flash) |
100-
| shell gate releases on an error record (no eternal fallback) | `call-driven-lifecycle``call-driven/shell-gate` (error) | pass — note it passes *because* nothing gates today; if the gate above is added, this cell keeps it honest about the error path |
116+
| shell gate: fresh call-driven mount's covering `<Loading>` holds until first content applies | `call-driven-lifecycle``call-driven/shell-gate` | passclosed gap: the mount gates on the frame's first apply (no empty-frame flash) |
117+
| shell gate releases on an error record (no eternal fallback) | `call-driven-lifecycle``call-driven/shell-gate` (error) | pass — an `:error` record fires the apply notification, so the gate releases on a failed stream |
101118
| t=0 adoption × shell gate || n/a — adopted content is already on screen at mount; no covering fallback ever shows |
102119
| deferred segment reveals covering an unboundaried async client fill || existing — `frames-client.spec.tsx` (reveal seam reconstructs a client `<Loading>`) |
120+
| post-done fragment reveals into an adopted region (held-swap claim, nested cascade, records riding the fragment) || existing — `frames-adopted-region-fragments.spec.tsx` (#2978/#2979) |
121+
| reveal, then refetch: after the reveal-driven record drain a later stream still morphs the region and updates the occurrence || existing — `frames-adopted-region-fragments.spec.tsx` (reveal-then-refetch) |
103122

104123
## Not constructible in this config
105124

@@ -119,14 +138,13 @@ is mount-kind-independent; enumerated once where it is richest) · **existing**
119138

120139
## GAP summary
121140

122-
1. **Shell gate / empty-frame flash** (`call-driven-lifecycle.spec.tsx`):
123-
a fresh call-driven mount's covering `<Loading>` releases when the call
124-
resolves (response head), not when the frame first has content — a slow
125-
body shows an empty boundary instead of the fallback. Expected: hold
126-
until the first html record applies, releasing on an error record too.
127-
2. **Occurrence-unmount cleanup** (`call-driven-slots.spec.tsx`): `onCleanup`
128-
inside a slot fill does not run when a later response drops the
129-
occurrence — fills' reactive scopes belong to the boundary owner and only
130-
dispose with the boundary. Expected: occurrence unmount disposes the
131-
fill's scope (its `onCleanup` runs there), with no double-fire at
141+
No open gaps. The two the matrix originally surfaced are closed:
142+
143+
1. **Shell gate / empty-frame flash** (`call-driven-lifecycle.spec.tsx`) —
144+
closed: a fresh call-driven mount holds its covering `<Loading>` from
145+
response head until the frame's first content applies, releasing on an
146+
error record too (the runtime's error-apply notification).
147+
2. **Occurrence-unmount cleanup** (`call-driven-slots.spec.tsx`) — closed:
148+
a stream-mounted fill renders under a per-occurrence owner that occurrence
149+
unmount disposes (its `onCleanup` runs there), with no double-fire at
132150
boundary dispose.

packages/solid-web/test/lifecycle-matrix/call-driven-lifecycle.spec.tsx

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ const getShellErr = createServerReference("matrix/lc/shell-err");
3232
const getSwitch = createServerReference("matrix/lc/switch");
3333
const getSwitchFb = createServerReference("matrix/lc/switch-fb");
3434
const getSwitchErr = createServerReference("matrix/lc/switch-err");
35+
const getSwitchSame = createServerReference("matrix/lc/switch-same");
36+
const getSwitchTwice = createServerReference("matrix/lc/switch-twice");
3537

3638
function articleHtml(title: string) {
3739
return (
@@ -201,10 +203,8 @@ describe("call-driven/second-response-newer-version", () => {
201203
});
202204

203205
describe("call-driven/error-record", () => {
204-
// Requires the runtime's error-apply notification (an :error record fires
205-
// onApply so the shell gate releases and the empty frame mounts) —
206-
// present in the runtime this branch links; marked test.fails on next
207-
// until its dep bumps past it.
206+
// Relies on the runtime's error-apply notification (an :error record fires
207+
// onApply so the shell gate releases and the empty frame mounts).
208208
test("error/before-html: the record surfaces through frame.error; the boundary mounts empty, not stuck on fallback", async () => {
209209
const { host } = makeHost();
210210
installServerComponents(host);
@@ -470,10 +470,8 @@ describe("call-driven/shell-gate", () => {
470470
m.cleanup();
471471
});
472472

473-
// Requires the runtime's error-apply notification (an :error record fires
474-
// onApply so the gate releases on a failed stream) — present in the
475-
// runtime this branch links; marked test.fails on next until its dep
476-
// bumps past it.
473+
// Relies on the runtime's error-apply notification (an :error record fires
474+
// onApply so the gate releases on a failed stream).
477475
test("the shell gate releases on an ERROR record too (an errored stream must not hold the fallback forever)", async () => {
478476
const { host } = makeHost();
479477
installServerComponents(host);
@@ -508,15 +506,15 @@ describe("call-driven/args-switch-gate", () => {
508506
//
509507
// Mounts the pending probe alongside the boundary so the test reads
510508
// exactly what the issue's `<button disabled={isPending(count)}>` reads.
511-
function mountWithProbe(Comp: any, source: () => unknown) {
509+
function mountWithProbe(Comp: any, source: () => unknown, props: Record<string, any> = {}) {
512510
const container = document.createElement("div");
513511
document.body.appendChild(container);
514512
let div!: HTMLDivElement;
515513
const dispose = createRoot(d => {
516514
<div ref={div}>
517515
<span data-probe>{isPending(source) ? "pending" : "idle"}</span>
518516
<Loading fallback={<span>shell-fallback</span>}>
519-
<Comp />
517+
<Comp {...props} />
520518
</Loading>
521519
</div>;
522520
container.appendChild(div);
@@ -660,4 +658,97 @@ describe("call-driven/args-switch-gate", () => {
660658

661659
m.cleanup();
662660
});
661+
662+
// dom-expressions#564: root affinity is per STREAM, not per boundary.
663+
// Slot-driven content ships its differences as records, so two calls'
664+
// shells can be the same bytes — a value-skip keyed to the boundary would
665+
// swallow the new stream's apply and the gate would wait forever on a
666+
// morph that never differs.
667+
test("a byte-identical shell across the switch is still an ANSWER: the gate settles and the record-driven difference lands", async () => {
668+
const { host } = makeHost();
669+
installServerComponents(host);
670+
const held = heldPerArg();
671+
672+
const [count, setCount] = createSignal(0);
673+
const Page = dynamic(() => getSwitchSame(count()) as any);
674+
const m = mountWithProbe(Page, count, { comment: (p: any) => <li>{p.text}</li> });
675+
676+
await pump();
677+
held[0].send({ type: "start", id: "srv", version: 1 });
678+
held[0].send({ type: "slot", id: "srv", version: 1, key: "comment#0", args: { text: "zero" } });
679+
held[0].send({ type: "html", id: "srv", version: 1, html: articleHtml("Same") });
680+
await pump();
681+
expect(m.div.querySelector("ul li")!.textContent).toBe("zero");
682+
expect(m.probe()).toBe("idle");
683+
684+
setCount(1);
685+
await pump();
686+
expect(m.probe()).toBe("pending");
687+
688+
// The new address's shell: identical html, only the slot record differs.
689+
held[1].send({ type: "start", id: "srv", version: 1 });
690+
held[1].send({ type: "slot", id: "srv", version: 1, key: "comment#0", args: { text: "one" } });
691+
held[1].send({ type: "html", id: "srv", version: 1, html: articleHtml("Same") });
692+
held[1].close();
693+
await pump();
694+
expect(m.probe()).toBe("idle");
695+
expect(m.div.querySelector("h1")!.textContent).toBe("Same");
696+
expect(m.div.querySelector("ul li")!.textContent).toBe("one");
697+
698+
m.cleanup();
699+
});
700+
701+
test("a second switch MID-FLIGHT re-arms the gate; the superseded call's late answer warms its store but never the live boundary", async () => {
702+
const { host } = makeHost();
703+
installServerComponents(host);
704+
const held = heldPerArg();
705+
706+
const [count, setCount] = createSignal(0);
707+
const Page = dynamic(() => getSwitchTwice(count()) as any);
708+
const m = mountWithProbe(Page, count);
709+
710+
await pump();
711+
held[0].send({ type: "start", id: "srv", version: 1 });
712+
held[0].send({ type: "html", id: "srv", version: 1, html: articleHtml("Zero") });
713+
await pump();
714+
expect(m.probe()).toBe("idle");
715+
716+
setCount(1);
717+
await pump();
718+
expect(m.probe()).toBe("pending");
719+
720+
// The user switches again before the first switch answers: still one
721+
// unanswered question, still pending, still the ORIGINAL content.
722+
setCount(2);
723+
await pump();
724+
expect(m.probe()).toBe("pending");
725+
expect(m.div.querySelector("h1")!.textContent).toBe("Zero");
726+
727+
// Only the LIVE call's answer settles the gate.
728+
held[2].send({ type: "start", id: "srv", version: 1 });
729+
held[2].send({ type: "html", id: "srv", version: 1, html: articleHtml("Two") });
730+
held[2].close();
731+
await pump();
732+
expect(m.div.querySelector("h1")!.textContent).toBe("Two");
733+
expect(m.probe()).toBe("idle");
734+
735+
// The superseded call's stream finally answers — into its own address's
736+
// store (a warm), never the boundary that moved on.
737+
held[1].send({ type: "start", id: "srv", version: 1 });
738+
held[1].send({ type: "html", id: "srv", version: 1, html: articleHtml("One") });
739+
held[1].close();
740+
await pump();
741+
expect(m.div.querySelector("h1")!.textContent).toBe("Two");
742+
expect(m.probe()).toBe("idle");
743+
744+
// And that warm is real: switching back re-materializes the superseded
745+
// answer instantly (the refetch it also fires morphs later, unanswered
746+
// here) — the rebind's re-registration answers synchronously.
747+
setCount(1);
748+
await pump();
749+
expect(m.div.querySelector("h1")!.textContent).toBe("One");
750+
expect(m.probe()).toBe("idle");
751+
752+
m.cleanup();
753+
});
663754
});

0 commit comments

Comments
 (0)