Skip to content

fix(signals): a re-pass equal to a staged first value is not a change (#4540) - #4555

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

ryansolid merged 4 commits into
nextfrom
claude/project-thread-x1ore2-4540

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Before: a non-keyed <Show> whose condition read an async memo or async projection remounted its child on every write to the memo's source, even though the condition stayed truthy. When the child wrote that source from a render effect, it remounted forever (the issue's 20-mount guard). The same shape over a sync memo or projection mounted once.

After: the child mounts once in all four shapes from the issue: sync memo, sync projection, async memo and async projection.

The condition's first value is staged in the flush where the async answer lands. A node with a staged first value stays STATUS_UNINITIALIZED until that flush commits, and recompute treated every re-pass of an uninitialized node as a change. The child is mounted inside that same flush, and its render effect's synchronous first run writes the source. That re-ran the memo, then the condition, which stayed true but was still uninitialized, so its truthiness comparator was skipped. That re-ran Show's value memo, which remounted the child, whose effect wrote again. The commit never came.

How: the uninitialized shortcut in recompute's equality gate now applies only when nothing is staged. When a first value is already staged, the gate compares against it, the same as for any staged node (INV-11: compare against the slot the pass publishes to). A node that went pending without a value still counts its first answer as a change. One unstaged local now serves the compare value, the uninitialized term and the effect gate, which used to read _pendingValue separately.

Fixes #4540.

Rules: I checked F-1–F-12 and D-8, D-13 and D-27, and no rule makes the remount correct. A condition whose comparator calls two values equal is not a change, whether its value is committed or staged, and the async and sync shapes must render alike. Ryan's standing rulings (no try/catch of reactive reads, no reactive work after await in an action, no refetch on hydration) don't apply: the repro does none of those.

Tests (on the branch merged with next at 62bf4c4):

  • New packages/web/test/show-async-condition-remount-4540.spec.tsx: the issue's four shapes. The two async rows fail on next (the child hits the remount guard) and pass with the fix.
  • signals: 431 files / 6192 tests pass (21 expected fail, 2 skipped).
  • web: the 4540 spec plus the neighbouring Show and held-mount specs (3698, show-in-for, 4542, 4543) pass; pnpm --filter @solidjs/web test-types passes.
  • tsc -p tsconfig.build.json passes on signals.

Size vs next at 62bf4c4, built locally with size.mjs and checked with gate.mjs:

  • −4 B minified on every row that carries the core (−2 B on observe + attribution engine). The rows without the core are unchanged.
  • Hello world (compiled floor): 27,304 B minified, 9,978 B brotli, under its 9.98 KB cap.
  • Core floor: 19,941 B minified, 7,419 B brotli (+1 B brotli).
  • Render + one signal: 26,772 B minified, 9,797 B brotli (+9 B brotli), under its 9.80 KB cap.
  • The rows still red (CSR observe tier, live SC, compiled live SC) are also red on next alone. This PR takes CSR and live + router back under the gate.
  • Nothing changes in scripts/size.

Generated by Claude Code

…#4540)

A node whose first value is staged in the running flush stays
STATUS_UNINITIALIZED until the commit, and recompute counted every
re-pass of it as a change. A non-keyed Show whose condition first
answers in a flush (an async memo or projection landing) re-ran its
value memo on each re-pass and remounted its child; a child writing the
source from its render effect's synchronous first run looped.

The uninitialized test now applies only when nothing is staged: with a
staged first value, the equality gate compares against it (INV-11), as
for any staged node.

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 31, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 070ea13

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

@github-actions

github-actions Bot commented Oct 31, 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.42 KB +1 B (+0.0%) −4 B +18 B 7.43 KB ✅
signals: + createStore 15.00 KB +17 B (+0.1%) −4 B −18 B 14.99 KB ⚠️ over by 9 B, 38 B minified headroom
signals: + isPending/latest 11.39 KB +4 B (+0.0%) −4 B +41 B 11.40 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.05 KB +4 B (+0.0%) −4 B +20 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.80 KB +9 B (+0.1%) −4 B +18 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.75 KB +4 B (+0.0%) −4 B +29 B 18.80 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.58 KB 0 B −4 B −14 B 32.51 KB ⚠️ over by 68.00000000000364 B, 34 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.46 KB −25 B (−0.2%) −4 B +29 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.22 KB −14 B (−0.1%) −4 B +29 B 15.20 KB ❌ over by 18 B lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.79 KB +4 B (+0.0%) −2 B +31 B 29.80 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.98 KB −1 B (−0.0%) −4 B +20 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.37 KB +47 B (+0.2%) −4 B −6 B 26.36 KB ⚠️ over by 12 B, 26 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.56 KB +18 B (+0.1%) −4 B +29 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.74 KB −27 B (−0.1%) −4 B +29 B 33.81 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.20 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.16 KB −1 B (−0.0%) −4 B +51 B 39.10 KB ❌ over by 63 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.19 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.70 KB +13 B (+0.0%) −4 B +28 B 35.74 KB ✅ 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.10 KB −36 B (−0.1%) −4 B +52 B 43.06 KB ❌ over by 42 B eager (counted): web.js 23.03 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.19 KB −34 B (−0.1%) −4 B +7 B 41.25 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.67 KB −58 B (−0.1%) −4 B +52 B 48.69 KB ✅ eager (counted): client.js 28.15 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.15 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)

  • signals: + createStore: over brotli cap by 9 B; minified 43,981 B vs 43,999 B recorded with the cap (−18 B) — 38 B of the 20 B minified allowance left; −4 B minified over this PR's base
  • app: hydrating + every store primitive family: over brotli cap by 68.00000000000364 B; minified 98,692 B vs 98,706 B recorded with the cap (−14 B) — 34 B of the 20 B minified allowance left; −4 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,121 B vs 80,127 B recorded with the cap (−6 B) — 26 B of the 20 B minified allowance left; −4 B minified over this PR's base

❌ Fails the gate

  • app: CSR, observe tier (same app on the observe artifacts): over brotli cap by 18 B; minified 42,030 B vs 42,001 B recorded with the cap (+29 B) — 9 B past the 20 B minified allowance; −4 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 server components (base + live/GET + action + isPending/latest): over brotli cap by 63 B; minified 118,191 B vs 118,140 B recorded with the cap (+51 B) — 31 B past the 20 B minified allowance; −4 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 42 B; minified 126,345 B vs 126,293 B recorded with the cap (+52 B) — 32 B past the 20 B minified allowance; −4 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).

@codspeed

codspeed Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 1 benchmark is 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

✅ 200 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing claude/project-thread-x1ore2-4540 (070ea13) with next (62bf4c4)

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

The first cut read `_pendingValue` again at the top of recompute and cost
+8 B minified on every core row. The equality gate already reads it for
compareValue and the effect gate reads it a third time; one `unstaged`
local now serves all three, and the uninitialized term checks it at the
gate. Core rows are back to +0 B minified, and the gate does one fewer
property load per pass.

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

ryansolid commented Oct 31, 2026 •

Copy link
Copy Markdown
Member Author

As of 070ea13 (branch merged with next at 62bf4c4), the size report shows −4 B minified against this PR's base on every row that carries the core. Hello world (compiled floor) and the core rows pass.

The three rows still red are CSR observe tier, live SC and compiled live SC. They are drift already on next: each one is also red on next alone, and this PR makes each 4 B smaller.

The job failure on 4fa4146 was @solidjs/signals#test. next was red on the same task at 65da5de, without this PR. job is green on e8d6c49 and on 070ea13.


Generated by Claude Code

@coveralls

coveralls commented Oct 31, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41252702751

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: 30.03 hits per line

💛 - Coveralls

`unstaged && wasUninitialized && !isEffect`, with wasUninitialized kept as
the raw status bit, minifies 4 B smaller than the boolean-coerced form and
keeps the brotli rows under their caps. The `!!` moves to the observe-only
devChanged assignment, which needs a boolean. Same logic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@ryansolid
ryansolid merged commit 404d06a into next Nov 1, 2026
8 of 9 checks passed
ryansolid pushed a commit that referenced this pull request Nov 1, 2026
Batch re-record for #4547, #4554, #4558, #4555, #4560 and #4535, from CI's
head size job 124750323866 on 0f5daf3. Five rows were over their brotli
caps with minified below the recorded size (#4560's savings): caps raised
to measured + 10 B rounded up to 0.01 KB, recorded minified lowered to
measured. The frozen floors are unchanged; page: live server components
stays over its brotli cap within the minified allowance (-80 B).
Hello world 9,954 B / 9.98 KB, render + one signal 9,780 B / 9.80 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
ryansolid added a commit that referenced this pull request Nov 1, 2026
…4474) (#4535)

* fix(signals): a released memo's queued retry runs before the release (#4474)

An unowned (or lazy: true) memo blocked on an uninitialized async source is
re-enqueued by the source's landing like any dependent; the settle walk then
found it unobserved and released it, and the release pulled the retry back
out of the heap. The memo never computed the landing and woke later with no
value: latest() returned undefined beside isPending() === true.

The release now waits for the pass the walk queued (REACTIVE_RELEASE_AFTER):
releaseIfSettledUnobserved marks a node already in the heap instead of
releasing it, and recompute's tail releases it after the retry if it is still
unobserved and settled. One retry, then the release (#2934); reads revive and
the pre-heap sweep still reclaims a dormant memo dirtied in the same tick
(#3078). Laziness is never observable (D-22).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(size): batch ledger re-record over next @ 62745f6 with #4535

Batch re-record for #4547, #4554, #4558, #4555, #4560 and #4535, from CI's
head size job 124750323866 on 0f5daf3. Five rows were over their brotli
caps with minified below the recorded size (#4560's savings): caps raised
to measured + 10 B rounded up to 0.01 KB, recorded minified lowered to
measured. The frozen floors are unchanged; page: live server components
stays over its brotli cap within the minified allowance (-80 B).
Hello world 9,954 B / 9.98 KB, render + one signal 9,780 B / 9.80 KB.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude <noreply@anthropic.com>
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