Skip to content

fix(signals): latest() and isPending() in a hydration claim read the server's snapshot (#4570) - #4581

Merged
ryansolid merged 6 commits into
nextfrom
claude/project-thread-x1ore2-4570
Nov 1, 2026
Merged

ryansolid merged 6 commits into
nextfrom
claude/project-thread-x1ore2-4570

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #4570.

Before: a client write during the hydration window can re-arm a <Loading on> nested in a streamed <Loading> while the server's answer is still out. When that answer streamed in, the hydrated page showed the inner fallback beside the server's answer (F1 a1=10). The fallback was claimed against server markup that never contained it, so dev logged "Hydration key miss" and the <p>F1</p> was created detached.

After: the boundary resumes on the server's frame, La1=10 a1=10, and holds it until the client's own question lands (La1=11 a1=11). No fallback is shown, nothing is claimed that the server didn't render, and nothing the server answered is asked again.

Rule.

  • D-16: the re-arm's data (a1) is also read outside the inner boundary, so the re-arm's fallback is never shown.
  • F-2: the catch-up holds the content until the async it needs has answered.
  • F-1: no frame shows latest(a1)'s world beside a1()'s.

This is the same contract as the write-before-resume pin: a boundary resumes against the server snapshot, then catches up.

Cause.

  • During a claim, the resumed content runs in a snapshot scope, and read() serves each reader the value the server rendered with.
  • latest() and isPending() read through the verdict window (verdictValue), which returns before that serve. So latest(a1) saw the client's flight a1:1, which has no value yet, and threw.
  • The throw put the freshly mounted inner boundary in its fallback inside the claim window. With a plain a1() read the same app hydrates cleanly.

How.

  • verdictValue now serves the snapshot to a reader in a snapshot scope, exactly as read() does.
  • It marks the reader REACTIVE_SNAPSHOT_STALE when the live value differs, so the reader replays at the scope's release.
  • isPending answers for the server's frame: nothing in flight.
  • The shadow slot table already has the snapshot row for windows, and the shadow expectation now passes it.

Size and perf. The code is in the verdict engine (verdict.ts). Apps that don't use latest or isPending pay 0 B, including hello world and the core floor. Rows that install the engine grow by the one branch; CI's size report has the numbers. The branch runs only while hydration capture is active and the reader is in a snapshot scope, so nothing changes outside hydration.

Tests.

  • packages/web/test/hydration/hydration-write-rearm-4570.spec.tsx, with its harness and server generator, replays the real streamed chunks in two shapes:
    • shared is the issue. It failed with F1a1=10 plus the key miss before the fix.
    • own is a re-arm whose data nothing outside reads. There the fallback shows now (D-15) and is created fresh. It already passed and is kept as a pin.
  • packages/signals/tests/latest-snapshot-scope-4570.test.ts: in a snapshot scope, latest(x), x() and isPending(x) agree on the snapshot until release. It failed before as [1, 5, false].
  • Run locally: the whole hydrate-config web suite (141 files pass), the snapshot and latest signals tests, and the new specs under both compilers. CI runs the rest.

Generated by Claude Code

…server's snapshot (#4570)

A client write during the hydration window, while a streamed boundary is
still out, re-arms a <Loading on> nested in it. When the server's answer
streams in, the boundary resumes in a snapshot scope: a plain read is
served the value the server rendered with, but latest() and isPending()
read through the verdict window, which did not serve it. latest(a1) beside
a1() saw the client's flight (a1:1, no value yet), threw, and put the inner
boundary in a fallback the server never rendered, claimed against nothing:
"Hydration key miss", a detached <p>F1</p>, and F1 beside the snapshot's
a1=10 (F-1; D-16 says that re-arm's fallback never shows).

verdictValue now serves the snapshot to a reader in a snapshot scope, as
read() does, marking it stale so it replays at the scope's release. The
code is in the verdict engine: apps without latest/isPending pay nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…indow (#4570)

The verdict window's copy of read()'s snapshot serve cost the live pages
125-159 B minified. Both sites now call one helper, servedSnapshot, behind
the same inline guard, so builds that never hydrate still fold both away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…paring (#4570)

A verdict reader served the server's snapshot in a hydration claim is
marked stale unconditionally; read() already compares before re-running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE

Copy link
Copy Markdown
Member Author

The red check job on a6d4129 is the size gate, and only the three live pages fail it: live SC is +94 B minified, compiled live SC +152 B and live+router +203 B over base. That growth comes from this PR's fix, and it's waiting on a maintainer decision. If it's accepted, the caps get raised in the batch ledger re-record, not in this PR (fix PRs don't edit scripts/size). Every other row is flat or smaller, and the targeted signals specs pass.


Generated by Claude Code

@changeset-bot

changeset-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b61bf62

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

This PR includes changesets to release 15 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example 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

…hout comparing (#4570)"

The unconditional re-run makes a verdict reader in a claim emit twice at
release (create-deferred-hydration.spec.ts, D-40). Keep the compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
@github-actions

github-actions Bot commented Nov 1, 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.43 KB 0 B 0 B −108 B 7.43 KB ✅
signals: + createStore 15.00 KB 0 B 0 B 0 B 15.01 KB ✅
signals: + isPending/latest 11.44 KB 0 B 0 B +31 B 11.44 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B −101 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.80 KB 0 B 0 B −167 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.80 KB 0 B 0 B −203 B 18.80 KB ⚠️ over by 2 B, 223 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.65 KB 0 B 0 B −67 B 32.68 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.50 KB 0 B 0 B 0 B 13.52 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.25 KB 0 B 0 B 0 B 15.26 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.84 KB 0 B 0 B −157 B 29.88 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.97 KB 0 B 0 B −204 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.28 KB 0 B 0 B −215 B 26.36 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.56 KB 0 B 0 B −244 B 32.61 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.77 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.84 KB 0 B 0 B −211 B 33.81 KB ⚠️ over by 25 B, 231 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.21 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.26 KB +28 B (+0.1%) +125 B +62 B 39.10 KB ❌ over by 160 B assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.18 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.74 KB 0 B 0 B −240 B 35.74 KB ⚠️ over by 3 B, 260 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.18 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 43.22 KB +22 B (+0.1%) +153 B +152 B 43.19 KB ❌ over by 28 B eager (counted): web.js 23.05 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.07 KB, sc-comments.js 0.19 KB, trace.js 9.21 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.16 KB 0 B 0 B −11 B 41.33 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 9.14 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.88 KB +118 B (+0.2%) +150 B +203 B 48.82 KB ❌ over by 62 B eager (counted): client.js 28.23 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.30 KB, trace.js 9.14 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B 0 B 1.29 KB ✅
server: renderToString (the server-render floor) 22.85 KB 0 B 0 B 0 B 22.87 KB ✅

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

  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 2 B; minified 53,414 B vs 53,617 B recorded with the cap (−203 B) — 223 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 25 B; minified 101,537 B vs 101,748 B recorded with the cap (−211 B) — 231 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 3 B; minified 107,876 B vs 108,116 B recorded with the cap (−240 B) — 260 B of the 20 B minified allowance left; +0 B minified over this PR's base

❌ Fails the gate

  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 160 B; minified 118,202 B vs 118,140 B recorded with the cap (+62 B) — 42 B past the 20 B minified allowance; +125 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 28 B; minified 126,393 B vs 126,241 B recorded with the cap (+152 B) — 132 B past the 20 B minified allowance; +153 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 62 B; minified 143,716 B vs 143,513 B recorded with the cap (+203 B) — 183 B past the 20 B minified allowance; +150 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)

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).

@coveralls

coveralls commented Nov 1, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41293580675

Coverage remained the same at 78.134%

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: 1367
Covered Lines: 1129
Line Coverage: 82.59%
Relevant Branches: 1098
Covered Branches: 797
Branch Coverage: 72.59%
Branches in Coverage %: Yes
Coverage Strength: 29.96 hits per line

💛 - Coveralls
@codspeed

codspeed Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 1.02%

1 warning

⚠️ 3 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 198 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ arrow in template 10.9 ms 12.3 ms -11.64%
⚡ memo + sync render effect only (reference) 32 ms 28.8 ms +10.88%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-x1ore2-4570 (b61bf62) with next (9034647)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

Copy link
Copy Markdown
Member Author

The red CodSpeed check on b61bf62 is the "arrow in template" SSR bench (−11.6%). It's a known noisy bench in this repo, and it swings like this on PRs that don't touch the server render path. This PR only changes the client hydration verdict path, and every signals bench is untouched or improved, so I'm not chasing it.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants