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 => (
+
}>
+ {Inline({ codeBlock: () => ({ onCopy: () => {} }) })}
+
+
+ ));
+ const markup = plain(html.slice(html.indexOf("Copy'
+ );
+ // 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) => (
From ed5e357c134916ef416b840cb392a0171a06b2f7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 14 Oct 2026 02:39:08 +0000
Subject: [PATCH 2/3] chore(size): raise the renderToString floor for retry
claims arming
A deferred retry now carries its server component's claims arming, so
buildAsyncWrap's retry wrapper grows renderToString by 34 B brotli
(155 B minified).
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01SzJPKYTStjk5cEs9VDKNce
---
scripts/size/floor-caps.json | 4 ++--
scripts/size/scenarios.js | 9 +++++++++
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json
index 81ff3e70e9..71827e81e2 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": 72242
}
}
diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js
index 181956c814..093ed27563 100644
--- a/scripts/size/scenarios.js
+++ b/scripts/size/scenarios.js
@@ -5149,6 +5149,15 @@ module.exports = [
// document ends with `