Repository navigation
fix(signals): a re-pass equal to a staged first value is not a change (#4540) - #4555
Conversation
…#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 detectedLatest commit: 070ea13 The changes in this PR will be included in the next version bump. This PR includes changesets to release 15 packages
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 |
Size (brotli, eager entry chunk)
|
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
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
|
As of 070ea13 (branch merged with The three rows still red are CSR observe tier, live SC and compiled live SC. They are drift already on The Generated by Claude Code |
Coverage Report for CI Build 41252702751Coverage remained the same at 78.134%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - 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
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
…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>
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_UNINITIALIZEDuntil that flush commits, andrecomputetreated 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 stayedtruebut 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. Oneunstagedlocal now serves the compare value, the uninitialized term and the effect gate, which used to read_pendingValueseparately.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
awaitin an action, no refetch on hydration) don't apply: the repro does none of those.Tests (on the branch merged with
nextat 62bf4c4):packages/web/test/show-async-condition-remount-4540.spec.tsx: the issue's four shapes. The two async rows fail onnext(the child hits the remount guard) and pass with the fix.pnpm --filter @solidjs/web test-typespasses.tsc -p tsconfig.build.jsonpasses on signals.Size vs
nextat 62bf4c4, built locally withsize.mjsand checked withgate.mjs:nextalone. This PR takes CSR and live + router back under the gate.scripts/size.Generated by Claude Code