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 ( +