diff --git a/.changeset/frames-data-call-hydration.md b/.changeset/frames-data-call-hydration.md new file mode 100644 index 000000000..894efdcfa --- /dev/null +++ b/.changeset/frames-data-call-hydration.md @@ -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. diff --git a/.changeset/live-attr-escalation-address.md b/.changeset/live-attr-escalation-address.md new file mode 100644 index 000000000..2eaf0e440 --- /dev/null +++ b/.changeset/live-attr-escalation-address.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Write one `data-lha` address on an element whose attribute was still pending inside a `` 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. diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 2419309fe..475a236c1 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -23,6 +23,7 @@ import { createSignal, getOwner, onCleanup, + onHydrationEnd, runWithOwner, untrack } from "solid-js"; @@ -1141,11 +1142,38 @@ function awaitBoundary(id: string) { if (!arrival) { let resolve!: (v: boolean) => void; const promise = new Promise(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 @@ -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()); } /** @@ -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(); }); } diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 2136d32ba..c64cb66b7 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -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 @@ -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++) { @@ -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(""); @@ -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; } } @@ -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); diff --git a/packages/web/test/hydration/adopted-fallback-residue-nested.spec.tsx b/packages/web/test/hydration/adopted-fallback-residue-nested.spec.tsx new file mode 100644 index 000000000..9cea7f8fd --- /dev/null +++ b/packages/web/test/hydration/adopted-fallback-residue-nested.spec.tsx @@ -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 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 = + `` + + "
" + + `92 tokens` + + "
" + + "
"; + 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( + () => ( + ( + …}> + {p.progress} + + } + > + {p.stats.tokens} tokens +
+ )} + /> + ), + 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(); + }); +}); diff --git a/packages/web/test/hydration/adopted-fallback-residue.spec.tsx b/packages/web/test/hydration/adopted-fallback-residue.spec.tsx index 7d2f6817c..78fbf233f 100644 --- a/packages/web/test/hydration/adopted-fallback-residue.spec.tsx +++ b/packages/web/test/hydration/adopted-fallback-residue.spec.tsx @@ -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(); @@ -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 = - `` + - "
" + - `92 tokens` + - "
" + - "
"; - 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( - () => ( - ( - …}> - {p.progress} - - } - > - {p.stats.tokens} tokens -
- )} - /> - ), - 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(); - }); }); diff --git a/packages/web/test/hydration/data-call-during-hydration-4033.spec.tsx b/packages/web/test/hydration/data-call-during-hydration-4033.spec.tsx new file mode 100644 index 000000000..7217c915b --- /dev/null +++ b/packages/web/test/hydration/data-call-during-hydration-4033.spec.tsx @@ -0,0 +1,98 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * #4033: on a page with server components, a DATA server function called + * during hydration was held by the frames intercept waiting for a boundary + * a data function never has, and the wait never ended: + * + * - a call made under a hydration trace cached the trace's never-settling + * Promise stub as the wait, so every later call for that function hung; + * - a call made after the page's last reveal (a hydration-end recompute) + * was released only by a reveal, and none came. + * + * Both calls must reach the network once hydration ends. + */ +import { afterEach, expect, test, vi } from "vitest"; +import { onHydrationEnd } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { createServerReference } from "../../server-functions/src/client.js"; +import { bootPage, frameHtml, freshFid, quiesce, type Page } from "../consistency/support.js"; + +let page: Page | undefined; +const disposers: (() => void)[] = []; +afterEach(async () => { + for (const d of disposers.splice(0)) d(); + await page?.cleanup(); + page = undefined; +}); + +function stubFetch() { + const calls: string[] = []; + vi.stubGlobal("fetch", (input: any) => { + calls.push(typeof input === "string" ? input : input.url); + return new Promise(() => {}); + }); + return calls; +} + +/** A server-component page; `during` runs inside the hydrating render. */ +function hydratePage(during: () => void) { + const fid = freshFid("sc-4033"); + page = bootPage(frameHtml(fid, "

doc

")); + const calls = stubFetch(); + const Comp = (globalThis as any)._$SC.r(fid); + const App = () => { + during(); + return ; + }; + disposers.push(hydrate(() => , page.container)); + return calls; +} + +test("a call made after the last reveal goes to the network at hydration end", async () => { + const getData = createServerReference(freshFid("data-4033-late")); + let settled = false; + const calls = hydratePage(() => { + // A hydration-end recompute (`ssrSource: "client"` and the like). + onHydrationEnd(() => { + getData(1).then( + () => (settled = true), + () => (settled = true) + ); + }); + }); + await quiesce(); + expect(calls.length).toBe(1); + expect(settled).toBe(false); +}); + +test("a call made under a hydration trace does not strand later calls", async () => { + const getData = createServerReference(freshFid("data-4033-trace")); + // The trace's global Promise, as subFetch installs it: never runs its + // executor, never settles. + class TraceStub { + then() { + return new TraceStub(); + } + catch() { + return new TraceStub(); + } + finally() { + return new TraceStub(); + } + } + const calls = hydratePage(() => { + const native = Promise; + (globalThis as any).Promise = TraceStub; + try { + getData(1); + } finally { + (globalThis as any).Promise = native; + } + // A later call for the same function, still during hydration. + getData(2); + }); + await quiesce(); + expect(calls.length).toBe(1); +}); diff --git a/packages/web/test/server/live-attr-escalation-4032.spec.tsx b/packages/web/test/server/live-attr-escalation-4032.spec.tsx new file mode 100644 index 000000000..65698fa30 --- /dev/null +++ b/packages/web/test/server/live-attr-escalation-4032.spec.tsx @@ -0,0 +1,82 @@ +/** + * @jsxImportSource @solidjs/web + */ +// #4032: an in-tag hole that escalates (its value is still pending) drops +// its attribute capture. The address that capture spliced into the tag must +// go with it: the resume re-renders the same open tag and mints again, and +// a leftover address both duplicated `data-lha` on the element and rode in +// every later attr op for it, moving the element off its bound address. +import { describe, expect, test } from "vitest"; +import { renderToStream, Loading } from "@solidjs/web"; +import { createMemo } from "solid-js"; +import { frameTransformDirectResult, ServerComponentPlugin } from "../../frames/src/frame-sink.js"; + +const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); + +function collect(code: () => any): Promise { + return new Promise(resolve => { + const chunks: string[] = []; + renderToStream(code, { plugins: [ServerComponentPlugin] } as any).pipe({ + write: (c: string) => chunks.push(c), + end: () => resolve(chunks.join("")) + }); + }); +} + +const addressesPerTag = (html: string) => + [...html.matchAll(/<[a-z]+\b[^>]*>/g)] + .map(m => (m[0].match(/data-lha=/g) || []).length) + .filter(n => n > 0); + +describe("an escalated in-tag hole leaves one address on its element (#4032)", () => { + test("a pending attribute inside ", async () => { + const S = () => { + const ready = createMemo(async () => (await wait(5), "r")); + const tick = createMemo(async function* () { + yield 0; + await wait(20); + yield 1; + }); + const label = createMemo(() => ready() + ":" + tick()); + return ( + FB}> +
    +
  • x
  • +
+
+ ); + }; + const Inline = frameTransformDirectResult(S, { id: "r/4032-a" }) as any; + const html = await collect(() => Inline({})); + + expect(html).toMatch(/
    /); + expect(addressesPerTag(html)).toEqual([1]); + // The later value rides an attr op whose text carries no address. + expect(html).toContain("r:1"); + expect(html).not.toMatch(/attrs:"[^"]*data-lha/); + }); + + test("pending attributes on nested elements", async () => { + const S = () => { + const ready = createMemo(async () => (await wait(5), "r")); + const tick = createMemo(async function* () { + yield 0; + await wait(20); + yield 1; + }); + const label = createMemo(() => ready() + ":" + tick()); + return ( + FB}> +
      +
    • x
    • +
    +
    + ); + }; + const Inline = frameTransformDirectResult(S, { id: "r/4032-b" }) as any; + const html = await collect(() => Inline({})); + + expect(addressesPerTag(html)).toEqual([1, 1]); + expect(html).not.toMatch(/attrs:"[^"]*data-lha/); + }); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 71827e81e..e8560bde6 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -24,7 +24,7 @@ "minified": 3324 }, "server: renderToString (the server-render floor)": { - "cap": "20.55 KB", - "minified": 72242 + "cap": "20.58 KB", + "minified": 72373 } } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 93e6893ba..fae851854 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4132,10 +4132,16 @@ module.exports = [ // locally on top of the claim dedupe (+34 B brotli, +93 B minified). A // document mount picks the unclaimed site whose `data-fa` matches its // call's arguments, so sites minted out of mount order bind their own - // frames. Cap set to that measurement, not above it. Pending the - // maintainer's OK. - limit: "11.495 KB", - capMinified: 34411, + // frames. Cap set to that measurement, not above it. Accepted by the + // maintainer (2026-10-14). + // #4033 (2026-10-14): measured at 11,522 B brotli / 34,465 B minified + // locally (+27 B brotli, +54 B minified). A boundary wait made under a + // hydration trace is not cached, and waits are released when hydration + // ends as well as on reveals, so a data server function called during + // hydration reaches the network. Cap set to that measurement, not above + // it. Pending the maintainer's OK. + limit: "11.522 KB", + capMinified: 34465, alias: framesAlias, external: framesExternal }, @@ -5182,8 +5188,21 @@ module.exports = [ // in `buildAsyncWrap`'s retry wrapper, retained by renderToString. // Accepted by the maintainer (2026-10-14), together with #4055's growth // under one combined cap of about 20.58 KB: whichever of the two merges - // second re-measures on top of the first. The cap is frozen again at - // 20.55 KB. + // second re-measures on top of the first (#4055, below). + // Size-Exception (#4032, 2026-10-14): 20.51 -> 20.56 KB, measured + // locally at 20,555 B against `next` @ c8186046's 20,497 (+58 B; 45 B over + // the cap; +131 B minified, 72,087 -> 72,218). An in-tag hole that + // escalates drops the `data-lha` its attr capture spliced in, so the + // resume's own address is the element's only one; the cost is the + // capture's splice record and the removal written inline at the three + // escalation sites (a closure in `ssr()` was 10 B smaller but cost ~13% + // on the link-claim bench, one allocation per template instance), + // retained by renderToString. Accepted by the maintainer (2026-10-14) as + // one combined cap with #4043 (+34 B); whichever of the two merges + // second re-measures and sets the cap. Merged second, on top of #4043: + // measured at 20,570 B against `next` @ f048691f's 20,531 (+39 B; + // +131 B minified, 72,242 -> 72,373). The combined cap is frozen at + // 20.58 KB, as the maintainer accepted. path: "../../packages/web/dist/server.js", import: "{ renderToString }", // Ratchet (2026-10-06, seed, next @ fff1615ee, Size run 37443080193): cap