Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/frames-data-call-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Send data server functions called during hydration on a page with server components (#4033). The frames client holds a server-function call while the document may still deliver a boundary for it, and a data function never has one. A wait created under a hydration trace cached the trace's never-settling `Promise` stub, so every later call for that function hung; and a call made after the page's last reveal (a hydration-end recompute) was released only by a reveal that never came. Waits made under a trace are no longer kept, and outstanding waits are released when hydration ends as well as on reveals.
5 changes: 5 additions & 0 deletions .changeset/live-attr-escalation-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Write one `data-lha` address on an element whose attribute was still pending inside a `<Loading>` in a live server component (#4032). When the in-tag hole escalated, the attribute capture was dropped but the address it had spliced into the tag stayed, and the boundary's resume minted a second one in front of it. The stale address rode every later attr op for the element, so the first op moved the element off its bound address and every op after it was dropped. The address now goes with the dropped capture.
50 changes: 33 additions & 17 deletions packages/web/frames/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
createSignal,
getOwner,
onCleanup,
onHydrationEnd,
runWithOwner,
untrack
} from "solid-js";
Expand Down Expand Up @@ -1141,11 +1142,38 @@ function awaitBoundary(id: string) {
if (!arrival) {
let resolve!: (v: boolean) => void;
const promise = new Promise<boolean>(r => (resolve = r));
// Under a hydration trace `Promise` is a stub that never runs its
// executor: hand it to the trace, but never keep it, or every later ask
// for this id waits on it forever (#4033).
if (!resolve) return promise;
arrivals.set(id, (arrival = { promise, resolve }));
// Hydration ending is the other moment a waiter's answer can change
// (see boundaryMayArrive): a call made after the page's last reveal —
// a data function, which never has a boundary — is released there.
onHydrationEnd(releaseArrivals);
}
return arrival.promise;
}

// A waiter the page can no longer answer must not wait forever: once the
// document is done and no fragment is left outstanding (truncated ones
// included), nothing else can deliver this element, so release the waiter —
// the caller mounts fresh (the client-only shape) or goes to the wire —
// instead of holding the fallback on screen. (The ledger reads the revealing
// fragment as delivered from its swap, so the LAST reveal of a page is the
// exhaustion it looks like: the `_fr` stamp the same batch executes after
// this notification is not what it waits on.)
function releaseArrivals() {
if (!arrivals.size) return;
const exhausted = !boundaryMayArrive();
for (const [id, arrival] of arrivals) {
const el = boundaryIndex && boundaryIndex.get(id);
if (!el && !exhausted) continue;
arrivals.delete(id);
arrival.resolve(!!el);
}
}

/**
* Whether the document may still deliver boundary elements — the hydration
* runtime's fragment ledger's answer (`_$HY.fr.pending()`: any declared
Expand All @@ -1166,7 +1194,10 @@ function awaitBoundary(id: string) {
function boundaryMayArrive() {
const hy = (globalThis as any)._$HY;
if (!hy) return false;
return !hy.done || !!(hy.fr && hy.fr.pending());
// `sharedConfig.done` flips when hydration ends; `_$HY.done` follows a
// task later, and a call in that gap would wait on a reveal that never
// comes (#4033).
return !(hy.done || sharedConfig.done) || !!(hy.fr && hy.fr.pending());
}

/**
Expand Down Expand Up @@ -1227,22 +1258,7 @@ function installRevealHook() {
if (!boundaryIndex) return;
const root = parent || (typeof document !== "undefined" ? document.body : null);
if (root) indexBoundaries(root);
if (!arrivals.size) return;
// A waiter the page can no longer answer must not wait forever: once the
// document is done and no fragment is left outstanding (truncated ones
// included), nothing else can deliver this element, so release the
// waiter — the caller mounts fresh (the client-only shape) or goes to
// the wire — instead of holding the fallback on screen. (The ledger
// reads the revealing fragment as delivered from its swap, so the LAST
// reveal of a page is the exhaustion it looks like: the `_fr` stamp the
// same batch executes after this notification is not what it waits on.)
const exhausted = hy.done && !hy.fr.pending();
for (const [id, arrival] of arrivals) {
const el = boundaryIndex && boundaryIndex.get(id);
if (!el && !exhausted) continue;
arrivals.delete(id);
arrival.resolve(!!el);
}
releaseArrivals();
});
}

Expand Down
26 changes: 20 additions & 6 deletions packages/web/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4198,6 +4198,7 @@ function attrNames(text) {
while ((m = re.exec(text))) names.push(m[1]);
return names;
}

export function ssr(template: string[] | string, ...nodes: any[]): { t: string };

// rendering
Expand Down Expand Up @@ -4255,7 +4256,7 @@ export function ssr(t) {
result.t[lastOpen.seg] = seg.slice(0, lastOpen.off) + inject + seg.slice(lastOpen.off);
prefix = result.t[lastOpen.seg].slice(lastOpen.off + inject.length);
}
cap = { id, parts: [prefix], base: prefix };
cap = { id, parts: [prefix], base: prefix, at: lastOpen.seg, inject };
return true;
};
for (let i = 1; i < len; i++) {
Expand Down Expand Up @@ -4307,8 +4308,15 @@ export function ssr(t) {
}
} else {
// Group escalation: the tag (if one is being captured) went
// async mid-attrs — it latches.
cap = null;
// async mid-attrs — it latches, and the address its capture
// spliced in goes with it (#4032): the resume re-renders the open
// tag and mints its own, so a leftover would sit beside it and
// move the element off its bound address on the first op. The
// address is unique, so the first match is the splice.
if (cap) {
result.t[cap.at] = result.t[cap.at].replace(cap.inject, "");
cap = null;
}
result.h.push(ssrGroupSlot(lastGroupVal.fn, lastGroupIdx++));
result.p.push(lastGroupVal.p);
result.t.push("");
Expand Down Expand Up @@ -4377,7 +4385,9 @@ export function ssr(t) {
cap.base += result.t[li].slice(before);
cap.parts.push({ f: hole });
} else {
// The hole escalated mid-attrs: the tag latches.
// The hole escalated mid-attrs: the tag latches, its address
// with it (see the group escalation above).
result.t[cap.at] = result.t[cap.at].replace(cap.inject, "");
cap = null;
}
}
Expand All @@ -4393,8 +4403,12 @@ export function ssr(t) {
cap.parts.push({ f: hole });
}
} else {
// The hole escalated mid-attrs: the tag latches.
if (capturing) cap = null;
// The hole escalated mid-attrs: the tag latches, its address
// with it (see the group escalation above).
if (capturing) {
s = s.replace(cap.inject, "");
cap = null;
}
result = { t: [s], h: [], p: [] };
s = "";
appendResolvedNode(result, r);
Expand Down
109 changes: 109 additions & 0 deletions packages/web/test/hydration/adopted-fallback-residue-nested.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*
* Document-face fallback residue (chat example, 2026-08-10): a slot fill
* whose async arg settled ON THE SERVER before the document closed leaves
* the region's markup showing the settled content — the server's inline
* swap already replaced the fallback. When the client hydrates, the arg is
* still pending LOCALLY for a beat (the record's settle applies just after
* the first render), so the fill's <Loading> renders its fallback — fresh
* nodes, because the server markup no longer carries the fallback to claim.
* When the arg settles, the boundary switches to content and claims the
* server's nodes… and the client-created fallback nodes must be REMOVED,
* not left sitting beside the content.
*
* Observed live: the status row showed the ticker (client-created fallback,
* no _hk) AND the settled stats side by side.
*/
import { afterEach, describe, expect, test, vi } from "vitest";
import { flush, Loading } from "solid-js";
import { hydrate } from "@solidjs/web";
import { installServerComponents, createFrameHost } from "../../frames/src/client.js";
import { createJSONDataTable } from "../../serialization/src/serializer.js";

const settle = () => new Promise(r => setTimeout(r));

function makeHost() {
const table = createJSONDataTable();
return createFrameHost({
applyData: (c: any) => table.apply(c),
resolve: (r: any) => table.resolve(r)
});
}

// The nested-fallback case of adopted-fallback-residue.spec.tsx, on its own
// page: the frames client's boundary index is module state.
describe("adopted slot fill whose arg settled server-side before flush", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete (globalThis as any)._$HY;
delete (globalThis as any)._$SC;
document.body.innerHTML = "";
});

test("nested Loading in the fallback (the chat Status shape): no residue", async () => {
const FID = "residue/nested";
const container = document.createElement("div");
container.innerHTML =
`<solid-frame data-fid="${FID}" style="display:contents">` +
"<div><!--slot:status#0:start-->" +
`<span _hk="sc-residue/nested-status#0-2000" class="done"><!--$-->92<!--/--> tokens</span>` +
"<!--slot:status#0:end--></div>" +
"</solid-frame>";
document.body.appendChild(container);
(globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} };
vi.stubGlobal("fetch", () => {
throw new Error("fetch must not be called");
});
installServerComponents(makeHost());

let resolveStats!: (v: any) => void;
const stats = new Promise(r => (resolveStats = r));
// progress: an async iterable that has yielded (so the nested Loading
// shows the ticker, not its own dots) and never ends — like a ticker
// stream mid-generation.
const progress = (async function* () {
yield "thinking…";
await new Promise(() => {});
})();
(globalThis as any)._$HY.r[`sc:slot:${FID}:status#0`] = { stats, progress };

const Comp = (globalThis as any)._$SC.r(FID);
const dispose = hydrate(
() => (
<Comp
status={(p: any) => (
<Loading
fallback={
<Loading fallback={<span class="ticker">…</span>}>
<span class="ticker">{p.progress}</span>
</Loading>
}
>
<span class="done">{p.stats.tokens} tokens</span>
</Loading>
)}
/>
),
container
);
flush();
await settle();
flush();

resolveStats({ tokens: 92 });
await settle();
flush();
await settle();
flush();

expect(container.querySelectorAll(".done").length).toBe(1);
expect(container.querySelectorAll(".ticker").length).toBe(0);
expect(container.textContent).toContain("92 tokens");

dispose();
container.remove();
});
});
67 changes: 3 additions & 64 deletions packages/web/test/hydration/adopted-fallback-residue.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ function makeHost() {
});
}

// One page per file: the frames client's boundary index is module state, so
// a second page in this file would look its frame up in the first page's
// index. The nested-fallback case is in adopted-fallback-residue-nested.spec.tsx.
describe("adopted slot fill whose arg settled server-side before flush", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -99,68 +102,4 @@ describe("adopted slot fill whose arg settled server-side before flush", () => {
dispose();
container.remove();
});

test("nested Loading in the fallback (the chat Status shape): no residue", async () => {
const FID = "residue/nested";
const container = document.createElement("div");
container.innerHTML =
`<solid-frame data-fid="${FID}" style="display:contents">` +
"<div><!--slot:status#0:start-->" +
`<span _hk="sc-residue/nested-status#0-2000" class="done"><!--$-->92<!--/--> tokens</span>` +
"<!--slot:status#0:end--></div>" +
"</solid-frame>";
document.body.appendChild(container);
(globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} };
vi.stubGlobal("fetch", () => {
throw new Error("fetch must not be called");
});
installServerComponents(makeHost());

let resolveStats!: (v: any) => void;
const stats = new Promise(r => (resolveStats = r));
// progress: an async iterable that has yielded (so the nested Loading
// shows the ticker, not its own dots) and never ends — like a ticker
// stream mid-generation.
const progress = (async function* () {
yield "thinking…";
await new Promise(() => {});
})();
(globalThis as any)._$HY.r[`sc:slot:${FID}:status#0`] = { stats, progress };

const Comp = (globalThis as any)._$SC.r(FID);
const dispose = hydrate(
() => (
<Comp
status={(p: any) => (
<Loading
fallback={
<Loading fallback={<span class="ticker">…</span>}>
<span class="ticker">{p.progress}</span>
</Loading>
}
>
<span class="done">{p.stats.tokens} tokens</span>
</Loading>
)}
/>
),
container
);
flush();
await settle();
flush();

resolveStats({ tokens: 92 });
await settle();
flush();
await settle();
flush();

expect(container.querySelectorAll(".done").length).toBe(1);
expect(container.querySelectorAll(".ticker").length).toBe(0);
expect(container.textContent).toContain("92 tokens");

dispose();
container.remove();
});
});
Loading
Loading