From acffd02df0063acd243c183f6af104a32370aed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 02:37:19 +0000 Subject: [PATCH 1/3] fix(web): a deferred hole keeps its server component's claims arming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server component arms handler-position markers (`ctx.claims`) on a context that is current only for its synchronous render. A hole that escalates there is retried by whatever boundary resumes it — on the document face, often one outside the component — so its final markup rendered without the arming and dropped every `_s:on:click`. The chat thread's Copy buttons shipped unmarked and were bound only because the end-of-response sweep, which re-runs under the mint-time context, re-sent the whole message list with the markers. The retry wrapper now carries the escalation's claims and arms them on a context derived from the resuming one, so that boundary's buffering and serialization stay its own. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SzJPKYTStjk5cEs9VDKNce --- .changeset/deferred-retry-claims.md | 5 +++ packages/web/src/server.ts | 22 +++++++++- .../test/server/frame-binding-slots.spec.tsx | 42 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 .changeset/deferred-retry-claims.md diff --git a/.changeset/deferred-retry-claims.md b/.changeset/deferred-retry-claims.md new file mode 100644 index 0000000000..ef343c0e1e --- /dev/null +++ b/.changeset/deferred-retry-claims.md @@ -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. diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 65d2b91c48..2136d32ba5 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -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 @@ -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; diff --git a/packages/web/test/server/frame-binding-slots.spec.tsx b/packages/web/test/server/frame-binding-slots.spec.tsx index 855f1a7a5f..91358d8575 100644 --- a/packages/web/test/server/frame-binding-slots.spec.tsx +++ b/packages/web/test/server/frame-binding-slots.spec.tsx @@ -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 ( +
    + {messages().map(m => ( +
  1. + {m} + +
  2. + ))} +
+ ); + }; + const Inline = frameTransformDirectResult(ServerComp, { id: "dsdefer" }) as any; + const html = await document(() => ( +
+ loading

}> + {Inline({ codeBlock: () => ({ onCopy: () => {} }) })} +
+
+ )); + const markup = plain(html.slice(html.indexOf("