From 0e0c0438a2bf91376a395a734364c2faf8c704de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:06:48 +0000 Subject: [PATCH 1/4] fix(web): drop a live attr capture's address when its tag escalates (#4032) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AV6Ya8uSU8gut3V3odqxck --- .changeset/live-attr-escalation-address.md | 5 ++ packages/web/src/server.ts | 20 ++++- .../server/live-attr-escalation-4032.spec.tsx | 82 +++++++++++++++++++ scripts/size/floor-caps.json | 4 +- 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 .changeset/live-attr-escalation-address.md create mode 100644 packages/web/test/server/live-attr-escalation-4032.spec.tsx diff --git a/.changeset/live-attr-escalation-address.md b/.changeset/live-attr-escalation-address.md new file mode 100644 index 0000000000..2eaf0e4406 --- /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/src/server.ts b/packages/web/src/server.ts index 65d2b91c48..2215365509 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -4235,9 +4235,21 @@ 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: result ? lastOpen.seg : 0, inject }; return true; }; + // An in-tag hole escalated (its value is pending): the tag latches, and + // the address the capture spliced in goes with it (#4032). The resume + // re-renders this open tag and mints its own; a leftover would sit + // beside it and ride every rebuilt attr text, moving the element off its + // bound address on the first op. The address is unique, so the first + // match is the splice: in `s` before escalation, else in the tag's + // segment (`s` became `result.t[0]`). + const dropCap = () => { + if (result) result.t[cap.at] = result.t[cap.at].replace(cap.inject, ""); + else s = s.replace(cap.inject, ""); + cap = null; + }; for (let i = 1; i < len; i++) { const hole = arguments[i]; const ht = typeof hole; @@ -4288,7 +4300,7 @@ export function ssr(t) { } else { // Group escalation: the tag (if one is being captured) went // async mid-attrs — it latches. - cap = null; + if (cap) dropCap(); result.h.push(ssrGroupSlot(lastGroupVal.fn, lastGroupIdx++)); result.p.push(lastGroupVal.p); result.t.push(""); @@ -4358,7 +4370,7 @@ export function ssr(t) { cap.parts.push({ f: hole }); } else { // The hole escalated mid-attrs: the tag latches. - cap = null; + dropCap(); } } } else { @@ -4374,7 +4386,7 @@ export function ssr(t) { } } else { // The hole escalated mid-attrs: the tag latches. - if (capturing) cap = null; + if (capturing) dropCap(); result = { t: [s], h: [], p: [] }; s = ""; appendResolvedNode(result, r); 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 0000000000..65698fa307 --- /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 81ff3e70e9..37d171932d 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.51 KB", - "minified": 72087 + "cap": "20.55 KB", + "minified": 72182 } } From 8163bc3238cc72fbc5f63fecbe13474ffdec1a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:06:49 +0000 Subject: [PATCH 2/4] fix(frames): data calls made during hydration reach the network (#4033) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AV6Ya8uSU8gut3V3odqxck --- .changeset/frames-data-call-hydration.md | 5 + packages/web/frames/src/client.ts | 50 +++++--- .../adopted-fallback-residue-nested.spec.tsx | 109 ++++++++++++++++++ .../adopted-fallback-residue.spec.tsx | 67 +---------- .../data-call-during-hydration-4033.spec.tsx | 98 ++++++++++++++++ scripts/size/scenarios.js | 21 +++- 6 files changed, 265 insertions(+), 85 deletions(-) create mode 100644 .changeset/frames-data-call-hydration.md create mode 100644 packages/web/test/hydration/adopted-fallback-residue-nested.spec.tsx create mode 100644 packages/web/test/hydration/data-call-during-hydration-4033.spec.tsx diff --git a/.changeset/frames-data-call-hydration.md b/.changeset/frames-data-call-hydration.md new file mode 100644 index 0000000000..894efdcfa2 --- /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/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 2419309fe0..475a236c18 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/test/hydration/adopted-fallback-residue-nested.spec.tsx b/packages/web/test/hydration/adopted-fallback-residue-nested.spec.tsx new file mode 100644 index 0000000000..9cea7f8fd6 --- /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 7d2f6817c8..78fbf233f1 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 0000000000..7217c915ba --- /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/scripts/size/scenarios.js b/scripts/size/scenarios.js index 60e19d8619..a7f853e562 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 }, @@ -5173,6 +5179,13 @@ module.exports = [ // document ends with `` again; the cost is the tail search and // splice in the marker helper, retained by renderToString. Accepted by // the maintainer (2026-10-13). The cap is frozen again at 20.51 KB. + // Size-Exception (#4032, 2026-10-14): 20.51 -> 20.55 KB, measured + // locally at 20,545 B against `next` @ a4b33919's 20,497 (+48 B; 35 B over + // the cap; +95 B minified, 72,087 -> 72,182). 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 at the three escalation + // sites, retained by renderToString. Pending the maintainer's OK. path: "../../packages/web/dist/server.js", import: "{ renderToString }", // Ratchet (2026-10-06, seed, next @ fff1615ee, Size run 37443080193): cap From e05e86639a92c3cb53dda6679471c49a10346361 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:10:33 +0000 Subject: [PATCH 3/4] chore(size): record the maintainer's OK on the renderToString exception Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AV6Ya8uSU8gut3V3odqxck --- scripts/size/scenarios.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index a7f853e562..40030ff65f 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -5185,7 +5185,9 @@ module.exports = [ // 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 at the three escalation - // sites, retained by renderToString. Pending the maintainer's OK. + // sites, retained by renderToString. Accepted by the maintainer + // (2026-10-14) as one combined cap of about 20.58 KB with #4043 (+34 B); + // whichever of the two merges second re-measures and sets the cap. path: "../../packages/web/dist/server.js", import: "{ renderToString }", // Ratchet (2026-10-06, seed, next @ fff1615ee, Size run 37443080193): cap From 00079cd95262b9d9b824038332d389d18299e131 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:09:39 +0000 Subject: [PATCH 4/4] perf(web): drop the escalated capture's address inline, without a per-call closure The dropCap closure in ssr() cost ~13% on the link-claim renderToString bench (one allocation per template instance). Write the removal at the three escalation sites instead; renderToString floor 20.55 -> 20.56 KB. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AV6Ya8uSU8gut3V3odqxck --- packages/web/src/server.ts | 40 +++++++++++++++++++----------------- scripts/size/floor-caps.json | 4 ++-- scripts/size/scenarios.js | 16 ++++++++------- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 2215365509..594f95a246 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -4178,6 +4178,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 @@ -4235,21 +4236,9 @@ 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, at: result ? lastOpen.seg : 0, inject }; + cap = { id, parts: [prefix], base: prefix, at: lastOpen.seg, inject }; return true; }; - // An in-tag hole escalated (its value is pending): the tag latches, and - // the address the capture spliced in goes with it (#4032). The resume - // re-renders this open tag and mints its own; a leftover would sit - // beside it and ride every rebuilt attr text, moving the element off its - // bound address on the first op. The address is unique, so the first - // match is the splice: in `s` before escalation, else in the tag's - // segment (`s` became `result.t[0]`). - const dropCap = () => { - if (result) result.t[cap.at] = result.t[cap.at].replace(cap.inject, ""); - else s = s.replace(cap.inject, ""); - cap = null; - }; for (let i = 1; i < len; i++) { const hole = arguments[i]; const ht = typeof hole; @@ -4299,8 +4288,15 @@ export function ssr(t) { } } else { // Group escalation: the tag (if one is being captured) went - // async mid-attrs — it latches. - if (cap) dropCap(); + // 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(""); @@ -4369,8 +4365,10 @@ 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. - dropCap(); + // 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; } } } else { @@ -4385,8 +4383,12 @@ export function ssr(t) { cap.parts.push({ f: hole }); } } else { - // The hole escalated mid-attrs: the tag latches. - if (capturing) dropCap(); + // 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/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 37d171932d..90080ea9f1 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": 72182 + "cap": "20.56 KB", + "minified": 72218 } } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 40030ff65f..e21575a2c9 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -5179,15 +5179,17 @@ module.exports = [ // document ends with `` again; the cost is the tail search and // splice in the marker helper, retained by renderToString. Accepted by // the maintainer (2026-10-13). The cap is frozen again at 20.51 KB. - // Size-Exception (#4032, 2026-10-14): 20.51 -> 20.55 KB, measured - // locally at 20,545 B against `next` @ a4b33919's 20,497 (+48 B; 35 B over - // the cap; +95 B minified, 72,087 -> 72,182). An in-tag hole that + // 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 at the three escalation - // sites, retained by renderToString. Accepted by the maintainer - // (2026-10-14) as one combined cap of about 20.58 KB with #4043 (+34 B); - // whichever of the two merges second re-measures and sets the cap. + // 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. path: "../../packages/web/dist/server.js", import: "{ renderToString }", // Ratchet (2026-10-06, seed, next @ fff1615ee, Size run 37443080193): cap