Repository navigation
fix(signals): a released memo's queued retry runs before the release (#4474) - #4535
Conversation
🦋 Changeset detectedLatest commit: c28ff03 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 |
42d0075 to
4c4d527
Compare
…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>
4c4d527 to
4e6cacd
Compare
Size (brotli, eager entry chunk)
|
Coverage Report for CI Build 41273697845Coverage remained the same at 78.134%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
… on hello world) (#4560) * perf(size): share the dependent walks, dep clearing and event-walk hops The three async dependent walks (settle, errored settle, release) share one visited-set walker; clearDeps reuses trimStaleDeps; the delegated event walk shares its parent hop and owner-boundary test. Drops checks already implied by pendingSource, and escalate's prod-dead default. Cold paths only; no behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * perf(size): fold the trailing hold guard into one expression Same truth table as the if: a non-effect holds, an effect holds unless freshHook answers CAUGHT_REARMED; freshHook is still read once. About -1 B minified on the boundary rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * fix(signals): the shared settle walk keeps a diamond join's early return walkDependents skipped a node an earlier branch had walked before calling the settle callback, so a diamond's join no longer took the settle walk's early-return branch (a verdict reader's re-run, a staged error's mark) on its second arrival. The callback now sees every arrival with a `seen` flag; the release and errored walks ignore revisits as before. Pinned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * perf(size): notifyStatus writes the status flags once; setPendingError picks one error notifyStatus's pending-source branch computed the same flags the general formula gives a pending status; both branches now share it, the order of writes otherwise unchanged (neither pending-source helper reads the flags). setPendingError chooses the given, current or a fresh NotReadyError in one expression — the same error lands in every case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * perf(signals): settle allocates the lane queues only with the lane seam Without the lane engine installed the lanes pair stayed empty every flush; it is now created only when the seam runs, and the merge below reads it optionally. Same queues in the same order either way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * perf(size): flatten shares its thunk walk flatten and flattenArray share isThunk/unwrap/skipped: the same checks in the same order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * perf(size): one helper queues an effect's owed run commitPendingNode's born-held effect and notifyEffectStatus's errored user effect both mark the run owed and queue the bound runner; they now share queueEffectRun (the runner is the same function either way). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * perf(size): template and render each read a value once template's create reads the content's first child once; render calls the app once before choosing the document or the insert path (the error hook is set before it either way, as before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * chore: changeset covers the added savings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * test(signals): the diamond pin links #4562 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * revert: flatten keeps next's thunk walk Reverts fdb6140. Review measured it as worse on brotli (hello world -5 B, render+one -12, CSR -15, compiled CSR -33 without it, for +62 B minified), and flatten runs per row. flatten.ts is now identical to next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --------- Co-authored-by: Claude <noreply@anthropic.com>
…4474 scripts/size takes next's side; the ledger is re-recorded from CI in the next commit. 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
|
Merged The "Cap bumps" table and the
c28ff03 is the batch re-record for #4547, #4554, #4558, #4555, #4560 and this PR. Five rows were over their brotli caps while their minified sizes sat below the recorded ones: isPending/latest, hydrating + every store, CSR observe tier, compiled live SC, and live + router. For each of those rows the cap goes to measured + 10 B and the recorded minified drops to measured. The merge's Generated by Claude Code |
|
CodSpeed's −5.47% on "memo + sync render effect only (reference)" at c28ff03 is mostly noise; CodSpeed flagged it as run on different hardware. Callgrind measures this branch at +0.29% instructions per mount, which is under the 0.5% we treat as real. How I measured: prod bundles built from
The +0.29% is the extra release-flag capture and the tail check in Generated by Claude Code |
Fixes #4474
Cause
The memo in #4474 was never lazy — it was eager, but unowned, so it sits on the observation lifecycle (
CONFIG_AUTO_DISPOSE). Blocked on an uninitialized async source at creation, it is re-enqueued by the source's landing like any dependent: the settle walk (settlePendingSource.settle,async.ts) sees_blocked, callsenqueueSub(node)to queue the retry — and, finding the node unobserved, pushes it ontoreleased. Post-walkreleaseIfSettledUnobserved→unobserved→deleteFromHeappulled that retry back out of the heap. The memo never computed the landing. When a later flush re-asked the source and revived the node,commitPendingNodeclearedSTATUS_UNINITIALIZEDon a node that had never produced a value, solatest()returnedundefinedbesideisPending() === true— the screen pinned inlatest-unread-identity-memo-4474.test.ts.Fix
Do not let the release delete the retry the walk queued — run it, then release.
constants.ts: one bit,REACTIVE_RELEASE_AFTER = 1 << 22.async.tsreleaseIfSettledUnobserved: a node that is alreadyREACTIVE_IN_HEAPis marked instead of released (node._flags |= REACTIVE_RELEASE_AFTER); a node not in the heap is released as before.core.tsrecompute: the bit is captured before the head wipe and, at the tail (after themissedWakeblock),releaseIfSettledUnobserved(el)runs — the node is released now if it is still unobserved and settled; one that went pending is an observer's again (its landing releases it); one re-queued is released at that pass's tail.Variant U1 from the brief. U2 (
dormantNodes-based) was built and rejected: it brokedormant-memo-chain-3554.test.ts(per-nodedormantNodes.deleteagainst nodes re-added mid-sweep) and measured +63 B minified vs U1's +48. No change to the wake side, no new arms inread/serve/verdictValue, no inline recompute.Rules
RULES-DERIVED.mdupdated: fixed 2026-10-31 at the release; "lazy memo" wording changed to "a memo on the observation lifecycle (unowned, orlazy: true)"; the parked wake attempt (fix/core-batch-4474-4417,ed85ca2a3) kept as one sentence of history.RULES-INDEX.mdregenerated (no diff — no new IDs).latestof it returns the landing's value, the plain read throws while pending.released-memo-retry-runs-4474.test.tspins exactly one run / one fetch at the landing, zero runs on a later source write while released, then revival on read.isPendingmemo reads inconsistently in a transition) #3078 preserved:prepareComputed's read-revive is untouched; the pre-heapsweepDormantstill reclaims (does not recompute) a dormant memo merely dirtied in the same tick — pinned in the new test file.Tests
Flipped pins:
packages/signals/tests/latest-unread-identity-memo-4474.test.ts— allit.failsrows →it(the "in a root" rows unchanged).packages/web/test/latest-unread-identity-memo-4474.spec.tsx— cherry-picked fromdocs/park-4474-4417(docs(signals): #4474 cause corrected, #4417 rulings reaffirmed — recorded, #4417 parked #4530, unmerged) andtest.fails→test.New pins (
packages/signals/tests/released-memo-retry-runs-4474.test.ts):isPendingmemo reads inconsistently in a transition) #3078) and it readsl:e1 p:truethenl:e2 p:false;isPendingmemo reads inconsistently in a transition) #3078).Suites: full signals, solid, web client + server + hydrate,
pnpm types— all green.Generator
Replayed with
zz-trace-ids.spec.ts(scratch, removed) base vs head, 284 ids in ~2 min: attempt-4's 91501 #15992; 7 #6390, #21333, #15285, #22337, #38671, #42673, #20240;comm -13 _b7 _cb7(seed 7) andcomm -13 _b3289 _cb3289(seed 3289). 10 differ, all law improvements, zero new laws or errors, zero memo-twin disagreements.""→<span data-a1=vs0:1 ></span>— now equals the owned twin's base screen (the parked [2.0 / next] A keyed Show on latest() of a staged write mounts its child twice when the flush parks (regressed by #4393) #4417 / S-46 empty-slot shape, D-27; previously masked by latest() of an unread identity memo returns undefined when first read in the flush that re-asks its source #4474).vm2:11 lm2:11 … ps0:true, matches twin (attempt-4's noted case). 7 #21333 steps 5–9:vm2:5, matches twin.lm1:2→"", matches owned/identity twins; finallm1:4correct.Regression gate
regression.spec.ts(seed 3289, 1500 cases vs baseline): pass, no regressions.Size
Measured locally (darwin) with
scripts/sizeovernext @ e44f82445(rebased; the base now includes #4494's store-field mangling), brotli / minified, bytes:Every floor pays the same +46–57 B minified (bit + ternary + capture + tail call); brotli deltas range −6…+112 by scenario (the live SC page's +112 is brotli context noise over a 53 B minified change). Within the brief's budget (≤ 55 B brotli on core floor and hello world). The gate (
gate.mjs, head vs base) passes locally;+ createStoreandhydrating + every store primitive familyare over their brotli caps on darwin but within the minified allowance (both were already over onnexthere — darwin/linux brotli skew) and are not bumped.Cap bumps
Minimum the measured deltas require — each is a scenario over its brotli cap whose minified grew past the 20 B allowance. Cap = measured + 10 B rounded up to 0.01 KB; recorded minified = measured; dated ledger line on each.
floor-caps.json, frozen)floor-caps.json, frozen)The brief pre-approved one cap bump; eight are needed (the set shifted with the rebase: hydrating + stores, compiled hydrating and base + router no longer need one; observe + attribution and compiled live SC do). Flagged for the maintainer's call.
Size-Exception: core floor 7.43 → 7.45 KB and live SC page 39.10 → 39.18 KB — #4474 correctness (D-22), +48 B minified on every floor.
Public API changes
None.
REACTIVE_RELEASE_AFTERis an internal flag bit; no export, option, prop, signature or diagnostic changed.Changed pins
packages/signals/tests/autodispose-pending-release.test.ts([signals 2.0 beta.25] Autodispose leak: derivatively-pending lazy memo is never released after losing its last subscriber (recomputes forever) #2934):derivedRunsafter the upstream resolves1 → 2(and the later counts shift by one). The landing's settle walk had already queued the blocked node's retry; that one pass now runs, then the node is released at the pass's tail. The contract pinned — no perpetual recompute, released when pending clears — holds.packages/signals/tests/attribution-waterfall-eval.test.ts"one scope reading two async sources serializes them": therightfixture is nowlazy: true. An eager unowned memo overrootnow re-runs atroot's landing besideleft(parallel, no waterfall — the ruled behaviour), so the fixture needed a memo that genuinely never runs until read to keep demonstrating the finding. Verified the amended fixture passes on base as well.Notes
next @ e44f82445after docs(signals): #4474 cause corrected, #4417 rulings reaffirmed — recorded, #4417 parked #4530 merged; the D-22 conflict was resolved by taking this PR's "Fixed 2026-10-31 at the release" Status paragraph and dropping docs(signals): #4474 cause corrected, #4417 rulings reaffirmed — recorded, #4417 parked #4530's "Open (… fix in progress)" paragraph. docs(signals): #4474 cause corrected, #4417 rulings reaffirmed — recorded, #4417 parked #4530's web spec is the one copy on the branch, with the verdict-only row flipped.