Skip to content

fix(web): a deferred hole keeps its server component's claims arming - #4043

Merged
ryansolid merged 4 commits into
nextfrom
claude/project-thread-kunkh4
Oct 14, 2026
Merged

ryansolid merged 4 commits into
nextfrom
claude/project-thread-kunkh4

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Summary

Before: the chat example's thread page shipped its Copy buttons as <button class="copy-code" type="button">, with no _s:on:click marker, so the client had nothing to bind. Copy only worked because the end-of-response sweep re-sent the whole message list over sc:live with the markers added, so every load sent the thread twice.

After: the markup the client adopts already carries _s:on:click="codeBlock:onCopy" _hk, and no hole op is sent. On a six-exchange chat thread the page drops from 20.1 KB to 13.9 KB.

The cause is that frameTransformDirectResult arms ctx.claims on a context that is current only during the component's synchronous render. The message list reads async data, so its hole escalates. The retry is then re-pulled by a boundary outside the component, under that boundary's context, which has no claims. The compiled ctx.claims ? ssrClaim(…) : "" guard therefore emitted nothing. The sweep got it right only because swept() re-runs a hole under its mint-time context. buildAsyncWrap restores the hole's owner on retry but did not restore its arming.

How: buildAsyncWrap captures the escalation context's claims. When the resuming context doesn't carry the same arming, the retry sets claims on a context derived from the resuming one, so that boundary's buffering and serialization stay its own. Restoring the old context wholesale would be wrong, because the retry may be streaming inside a different boundary.

Size: the renderToString floor grows by 155 B minified (+34 B brotli: 20,497 B to 20,531 B). That is 21 B over its frozen 20.51 KB cap, so the cap is raised to 20.55 KB. The maintainer accepted this together with #4055's growth on the same floor, under one combined cap of about 20.58 KB. Whichever of the two merges second re-measures on top of the first and sets the real number.

Size-Exception: renderToString floor 20.51 -> 20.55 KB (+34 B brotli, +155 B minified) so a deferred hole's retry keeps its server component's claims arming; accepted by the maintainer together with #4055.

How did you test this change?

  • New test in frame-binding-slots.spec.tsx with the chat shape: a server component whose list reads an async memo, inside a <Loading> outside the component. It asserts that the deferred markup has the marker and that no hole op is sent. Before the fix it failed: the template had <button class="copy"> and a hole op re-sent the list with the marker.
  • packages/web: the three Vitest configs pass, except server-functions-adapter-request.spec.tsx › "keeps a rebuild failure's own error", which also fails on next.
  • Rebuilt examples/chat and rendered a seeded thread through the built handleRequest: all 6 Copy buttons are marked in the markup, 0 are unmarked, and no hole op is sent (only the composer's slot records).
  • scripts/size: node size.mjs passes for renderToString, and check-floor-caps.mjs origin/next passes with the Size-Exception line.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SzJPKYTStjk5cEs9VDKNce

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzJPKYTStjk5cEs9VDKNce
@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1a2faa5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
board-example Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzJPKYTStjk5cEs9VDKNce
@ryansolid
ryansolid marked this pull request as ready for review October 14, 2026 02:39
@ryansolid ryansolid self-assigned this Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.47 KB 0 B 0 B −8 B 7.48 KB ✅
signals: + createStore 14.94 KB 0 B 0 B −22 B 14.94 KB ⚠️ over by 3 B, 42 B minified headroom
signals: + isPending/latest 9.75 KB 0 B 0 B −8 B 9.77 KB ✅
app: render + one signal (the simple-app floor) 9.95 KB 0 B 0 B −8 B 9.98 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB 0 B 0 B −65 B 17.93 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.54 KB 0 B 0 B −83 B 29.57 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.04 KB 0 B 0 B −22 B 13.07 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.64 KB 0 B 0 B −22 B 14.64 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.23 KB 0 B 0 B +14 B 29.24 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.16 KB 0 B 0 B −8 B 10.17 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.80 KB 0 B 0 B −99 B 25.79 KB ⚠️ over by 12 B, 119 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.69 KB 0 B 0 B −41 B 31.64 KB ⚠️ over by 54 B, 61 B minified headroom stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.49 KB 0 B 0 B 0 B 11.49 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.19 KB 0 B 0 B −1402 B 34.33 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.01 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.96 KB 0 B 0 B −1471 B 38.15 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.02 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 34.99 KB 0 B 0 B −2492 B 35.66 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.99 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.68 KB 0 B 0 B −2485 B 41.21 KB ✅ eager (counted): web.js 21.56 KB; assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.23 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.97 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.60 KB 0 B 0 B −1408 B 41.79 KB ✅ assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 9.01 KB, wire.js 0.94 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.33 KB 0 B 0 B −1402 B 47.49 KB ✅ eager (counted): client.js 27.75 KB; assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 8.97 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.53 KB +34 B (+0.2%) +155 B 0 B 20.55 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • signals: + createStore: over brotli cap by 3 B; minified 45,343 B vs 45,365 B recorded with the cap (−22 B) — 42 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 12 B; minified 80,624 B vs 80,723 B recorded with the cap (−99 B) — 119 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 54 B; minified 100,920 B vs 100,961 B recorded with the cap (−41 B) — 61 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

Copy link
Copy Markdown
Member Author

The Size check job fails at check-floor-caps on ed5e357. The cause is timing, not the size: that run read the PR body from the push event, and I added the Size-Exception: line to the body after that push. The size gate itself passes (renderToString 20.53 KB against the new 20.55 KB cap). The workflow only runs on push, not on body edits, so the floor check will pass on the next push. Once the maintainer accepts the exception, that push will change the scenarios.js comment from "Up for the maintainer's acceptance" to "Accepted".


Generated by Claude Code

@coveralls

coveralls commented Oct 14, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 38540829579

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage remained the same at 77.515%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1326
Covered Lines: 1084
Line Coverage: 81.75%
Relevant Branches: 1040
Covered Branches: 750
Branch Coverage: 72.12%
Branches in Coverage %: Yes
Coverage Strength: 27.41 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 5.02%

⚠️ 1 benchmark spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 194 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ dbmon shallow full tick 52.9 ms 50.4 ms +5.02%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-kunkh4 (1a2faa5) with next (a4b3391)

Open in CodSpeed

Copy link
Copy Markdown
Member Author

CodSpeed reports two regressions on acffd02, and I don't think they come from this PR:

  • createStore setter: delete + set one root key (−11.9%)
  • memo + sync render effect only (−7.2%)

Both benchmarks run packages/signals code. This PR changes only packages/web/src/server.ts, a web server test, a changeset and the size files, so neither benchmark executes any line it touches. CodSpeed's own report also says the comparison ran on "different runtime environments". The run on ed5e357 measures the same signals code, so it will show whether the numbers hold. If they do, they need acknowledging on CodSpeed rather than a code change.


Generated by Claude Code

… floor raise

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzJPKYTStjk5cEs9VDKNce
@ryansolid
ryansolid merged commit f048691 into next Oct 14, 2026
8 of 9 checks passed
ryansolid pushed a commit that referenced this pull request Oct 14, 2026
Re-measures the renderToString floor on top of #4043: 20,570 B
(72,373 minified), combined cap 20.58 KB as the maintainer accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRieFbP8sapvTBygyVm3mg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants