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/deferred-retry-claims.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

A server component's hole that waits on async data keeps its handler-position markers (`_s:on:click`) when a boundary outside the component resumes it. The retry renders under the component's claims arming instead of the resuming boundary's context, so the markup the client adopts carries the marker. Before, the chat example's Copy buttons shipped without it and were only bound after the end-of-response sweep re-sent the whole message list.
22 changes: 21 additions & 1 deletion packages/web/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3532,6 +3532,13 @@ function buildAsyncWrap(err, node) {
// re-suspension chain overflowed the stack (SSR stack-overflow diagnosis).
if (node.$rw) return { fn: node, p };
const owner = getOwner();
// The behavior-claims arming (`ctx.claims`) is per render CONTEXT, and a
// server component sets it on a context that is current only for its
// synchronous render. A retry is re-pulled by whatever boundary resumes
// it — on the document face, one outside the component — so it carries
// the escalation's arming forward, or the markup it finally renders
// drops every handler-position marker (`_s:on:click`).
const claims = sharedConfig.context && sharedConfig.context.claims;
// A live hole's retry chain stays mint-suppressed end to end, and a
// machinery-owned node's (`$lhSkip` — boundary outputs, slot getters)
// stays opted out: every escalation re-wrap crosses this site, so
Expand All @@ -3549,7 +3556,20 @@ function buildAsyncWrap(err, node) {
if (suppress) node.$lhSuppress = true;
return { fn: node, p };
}
const fn = () => runWithOwner(owner, node);
const fn = () => {
const ctx = sharedConfig.context;
if (!claims || !ctx || ctx.claims === claims) return runWithOwner(owner, node);
// Armed on a context DERIVED from the resuming one (prototype — every
// shared field and method reads through, as a Loading boundary's
// buffered context does), so that boundary's buffering and
// serialization stay its own.
(sharedConfig.context = Object.create(ctx)).claims = claims;
try {
return runWithOwner(owner, node);
} finally {
sharedConfig.context = ctx;
}
};
fn.$rw = true;
if (suppress) fn.$lhSuppress = true;
if (node.$lhSkip) fn.$lhSkip = true;
Expand Down
42 changes: 42 additions & 0 deletions packages/web/test/server/frame-binding-slots.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,48 @@ describe("binding slots — stream face", () => {
});

describe("binding slots — document face (t=0)", () => {
it("a handler position under a hole whose source lands later still marks in the first markup (a deferred retry keeps the component's claims)", async () => {
// The chat thread's shape: the message list reads async data, so its
// hole escalates and its first markup comes from a retry that runs long
// after the component's synchronous render returned, resumed by a
// boundary OUTSIDE the component. That retry still renders the
// component's markup, so the Copy button carries its handler marker in
// the markup the client adopts, not only in a later re-emission.
const ServerComp = (props: any) => {
const block = props.codeBlock();
const messages = createMemo(async () => {
await new Promise(r => setTimeout(r, 5));
return ["one", "two"];
});
return (
<ol>
{messages().map(m => (
<li>
{m}
<button class="copy" onClick={block.onCopy}>
Copy
</button>
</li>
))}
</ol>
);
};
const Inline = frameTransformDirectResult(ServerComp, { id: "dsdefer" }) as any;
const html = await document(() => (
<main>
<Loading fallback={<p>loading</p>}>
{Inline({ codeBlock: () => ({ onCopy: () => {} }) })}
</Loading>
</main>
));
const markup = plain(html.slice(html.indexOf("<template id=")));
expect(markup).toContain(
'<button class="copy" _s:on:click="codeBlock:onCopy" _hk>Copy</button>'
);
// Nothing changed after the first render: no hole is re-sent to add it.
expect(html).not.toContain('type:"hole"');
});

it("runs the fill at t=0 and writes each position's value beside its marker", async () => {
const ServerComp = (props: any) => (
<ul>
Expand Down
4 changes: 2 additions & 2 deletions scripts/size/floor-caps.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"minified": 3324
},
"server: renderToString (the server-render floor)": {
"cap": "20.51 KB",
"minified": 72087
"cap": "20.55 KB",
"minified": 72242
}
}
11 changes: 11 additions & 0 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -5173,6 +5173,17 @@ module.exports = [
// document ends with `</html>` 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 (#4043, 2026-10-14): 20.51 -> 20.55 KB, measured at
// 20,531 B against `next` @ d1cb8013's 20,497 (+34 B; 21 B over the cap;
// +155 B minified, 72,087 -> 72,242). A hole that escalates inside a
// server component carries the component's claims arming into its
// retry, so a boundary outside the component that resumes it still
// writes handler-position markers (`_s:on:click`); the cost is the arm
// 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.
path: "../../packages/web/dist/server.js",
import: "{ renderToString }",
// Ratchet (2026-10-06, seed, next @ fff1615ee, Size run 37443080193): cap
Expand Down
Loading