Skip to content

fix(signals): a derived store over latest() never serves its seed after it first resolves (#4355) - #4369

Merged
ryansolid merged 2 commits into
nextfrom
claude/project-thread-x1ore2-4355
Oct 28, 2026
Merged

ryansolid merged 2 commits into
nextfrom
claude/project-thread-x1ore2-4355

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #4355

Summary

Before. Take a derived createStore(fn) whose derive reads an async memo through latest(). It resolved, but the first memo to read it after the landing got the store's seed for one pass. A render effect behind that memo committed undefined and then Hartford. A second derived store copying state.value did the same thing. A render effect reading the store directly saw only Hartford. So putting a memo between the store and the hole changed what reached the screen. In the report's app, name.trim() threw on that pass.

After. The reader sees what the derive wrote, { name: "Hartford" }, on its first pass. The effect commits only Hartford, whether it reads through a memo, through a second derived store, or directly (D-30, A25: a derived store's seed is never observable. D-3: grouping doesn't change the result).

How

  • Because the derive reads latest(), it is a verdict reader. A memo that reads the store becomes one too, through the store's status pull of the derive (F-6, [2.0 rc.15 + next] Held navigation never publishes delayed pending feedback #4050).
  • In the landing flush, the derive's first write is a staging and the derive is still STATUS_UNINITIALIZED. Core serves a verdict reader the screen for a staged signal (A10, stagedScreen). For a store leaf, the screen is the committed backing, which is the seed.
  • A memo born staged has the matching guard in core serve: no committed value, so the staging is its only value. Store leaves take the signal fast path, which has no status to check, so they missed that guard.

The fix is in the store layer only. nodeValue already served a projection's unheld staging to a handler read (context === null). It now also serves it while the family's derive has never committed:

target.fam !== null &&
(context === null || target.fam.node?._statusFlags! & STATUS_UNINITIALIZED)

Nothing changes in core: no new exports, effects or computations.

How did you test this change?

  • packages/signals/tests/derived-store-latest-seed-4355.test.ts: a latest() derive and a plain derive, each with three readers (memo, second derived store, direct render effect). Each case expects the effect to commit only "Hartford". On next the memo and store readers fail with [undefined, "Hartford"]. All 6 pass with the fix.
  • packages/web/test/derived-store-latest-seed-4355.spec.tsx: a <Loading> hole that renders state.value!.name.trim(), once through a memo and once directly. On next the memo form halts with TypeError: Cannot read properties of undefined (reading 'name'). Both pass with the fix.
  • Suites, after a clean turbo run build --force:
    • signals: 5,820 passed (RULES-INDEX.md regenerated for the new A25 citation in src/)
    • solid: 906 passed
    • web default: 1,506 passed
    • web hydrate: 674 passed
    • web server: 1,886 passed, 1 failed: server-functions-adapter-request.spec.tsx "keeps a rebuild failure's own error…". Node 22 accepts the test's fake AbortSignal in new Request. That test doesn't touch stores or signals, so this change can't affect it.
  • Types: test-types passes for solid and web.

Size

Measured locally (linux) with scripts/size, head against next @ 6b7e34a. Both trees had a clean build. node gate.mjs head.json base.json gives: every scenario passes, with none newly over its cap. The 11 brotli warnings are already there on next locally, with +0 B minified. Only scenarios that carry the store engine move. Every frozen floor is unchanged, including the compiled hello-world floor at 9,933 B.

scenario brotli base → head minified base → head
signals: + createStore 14,910 → 14,916 (+6) 43,883 → 43,902 (+19)
app: hydrating + every store primitive family 31,716 → 31,743 (+27) 96,944 → 96,963 (+19)
app: compiled CSR (todo app, store) 26,174 → 26,178 (+4) 79,875 → 79,894 (+19)
app: compiled hydrating (todo app, store) 32,369 → 32,359 (−10) 100,487 → 100,506 (+19)
app: compiled floor (hello world) 9,933 → 9,933 27,335 → 27,335
every other scenario, all frozen floors unchanged unchanged

There are ledger lines in scripts/size/scenarios.js for the four scenarios that grew. No cap changes. The store-family app's recorded minified goes from 96,944 to 96,963 B. It was recorded at next's exact size, and this change is within the 20 B allowance. floor-caps.json is untouched.


Generated by Claude Code

…er it first resolves (#4355)

A derived store whose derive reads an async memo through `latest()`
resolved, but a memo reading it saw the seed for one pass: a render effect
behind that memo committed `undefined`, then the value. A render effect
reading the store directly saw only the value.

Cause: the derive reads `latest()`, so it is a verdict reader, and a memo
that reads it through the store's status pull becomes one too (F-6, #4050).
In the landing flush the derive's first write is a staging and the derive is
still uninitialized. Core serves a verdict reader the screen for a staged
signal (A10, `stagedScreen`), and a store leaf's screen is the committed
backing, which is the seed. The seed is never observable (A25, D-30). A
memo born staged has the same guard in core `serve`, but store leaves take
the signal fast path, which has no status to check.

Fix: the store's `nodeValue` serves the leaf's unheld staging while the
family's derive has never committed (STATUS_UNINITIALIZED), as it already
does for a handler read. Store engine only: +19 B minified on the four
store scenarios, every floor unchanged.

Tests: packages/signals/tests/derived-store-latest-seed-4355.test.ts (memo,
second derived store and direct readers, latest and plain derives) and
packages/web/test/derived-store-latest-seed-4355.spec.tsx (a hole through a
memo and a direct hole). Size ledger lines added; RULES-INDEX regenerated.

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

changeset-bot Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4d17c17

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
solid-js Patch
test-integration Patch
@solidjs/web Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler 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

@github-actions

github-actions Bot commented Oct 28, 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.41 KB 0 B 0 B −166 B 7.44 KB ✅
signals: + createStore 14.92 KB 0 B +19 B −1414 B 15.04 KB ✅
signals: + isPending/latest 10.38 KB 0 B 0 B −141 B 10.38 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB 0 B 0 B −170 B 8.05 KB ✅
app: render + one signal (the simple-app floor) 9.77 KB 0 B 0 B −226 B 9.75 KB ⚠️ over by 19 B, 246 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.64 KB 0 B 0 B −246 B 18.49 KB ⚠️ over by 149 B, 266 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 31.78 KB +60 B (+0.2%) +19 B 0 B 31.80 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.41 KB 0 B 0 B −258 B 13.40 KB ⚠️ over by 14 B, 278 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.13 KB 0 B 0 B −266 B 15.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.68 KB 0 B 0 B −265 B 29.70 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.93 KB 0 B 0 B −225 B 9.93 KB ⚠️ over by 3 B, 245 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.18 KB −42 B (−0.2%) +19 B −594 B 26.27 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.34 KB +25 B (+0.1%) +19 B −524 B 32.25 KB ⚠️ over by 88 B, 544 B minified headroom stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.76 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.57 KB 0 B 0 B −184 B 33.37 KB ⚠️ over by 199 B, 204 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.01 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.98 KB 0 B 0 B −84 B 37.75 KB ⚠️ over by 228 B, 104 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.00 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.55 KB 0 B 0 B −173 B 35.43 KB ⚠️ over by 122 B, 193 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 8.94 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.87 KB 0 B 0 B −40 B 41.56 KB ⚠️ over by 310 B, 60 B minified headroom eager (counted): web.js 22.37 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.04 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.00 KB 0 B 0 B −184 B 40.81 KB ⚠️ over by 188 B, 204 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.03 KB, serverForms.js 3.59 KB, trace.js 8.98 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.40 KB 0 B 0 B −62 B 47.16 KB ⚠️ over by 238 B, 82 B minified headroom eager (counted): client.js 27.45 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.00 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B −69 B 1.34 KB ✅
server: renderToString (the server-render floor) 22.79 KB 0 B 0 B 0 B 22.80 KB ✅

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

  • app: render + one signal (the simple-app floor): over brotli cap by 19 B; minified 26,803 B vs 27,029 B recorded with the cap (−226 B) — 246 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 149 B; minified 53,374 B vs 53,620 B recorded with the cap (−246 B) — 266 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR with Show/For/Loading/Errored/lazy: over brotli cap by 14 B; minified 36,949 B vs 37,207 B recorded with the cap (−258 B) — 278 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 3 B; minified 27,335 B vs 27,560 B recorded with the cap (−225 B) — 245 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 88 B; minified 100,520 B vs 101,044 B recorded with the cap (−524 B) — 544 B of the 20 B minified allowance left; +19 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 199 B; minified 101,375 B vs 101,559 B recorded with the cap (−184 B) — 204 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 228 B; minified 115,133 B vs 115,217 B recorded with the cap (−84 B) — 104 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 122 B; minified 107,745 B vs 107,918 B recorded with the cap (−173 B) — 193 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 310 B; minified 123,176 B vs 123,216 B recorded with the cap (−40 B) — 60 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 188 B; minified 125,203 B vs 125,387 B recorded with the cap (−184 B) — 204 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 238 B; minified 140,418 B vs 140,480 B recorded with the cap (−62 B) — 82 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).

@coveralls

coveralls commented Oct 28, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40731526454

Coverage remained the same at 78.651%

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: 1352
Covered Lines: 1122
Line Coverage: 82.99%
Relevant Branches: 1079
Covered Branches: 790
Branch Coverage: 73.22%
Branches in Coverage %: Yes
Coverage Strength: 29.0 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 8.41%

2 warnings

⚠️ 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

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 197 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 31.1 ms 28.7 ms +8.41%

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-x1ore2-4355 (4d17c17) with next (5f60713)

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

…-x1ore2-4355

# Conflicts:
#	scripts/size/scenarios.js
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