fix(signals): isPending companions keep their pending override while the source is in flight (closes #2843)#2845
Conversation
🦋 Changeset detectedLatest commit: 55019b4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 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 |
d4325ca to
3f45037
Compare
3f45037 to
55019b4
Compare
|
Thanks for this — the diagnosis of the revert/re-arm cycle was right, and your regression tests were genuinely useful: we ran them against the companion redesign that landed on We're not taking the |
Closes #2843
Summary
createEffect(() => isPending(() => latest(asyncMemo)), …)— the canonical stale-while-revalidate watcher — sends the scheduler into an infinite loop on the first source write after the async memo settles:Potential Infinite Loop Detectedin dev after a visible stutter, and an unbounded hot spin in prod, where the iteration guard doesn't exist. One refetch click is enough. The same loop reproduces in the store-leaf shape from #2831 (isPending(() => latest(() => store.value))over a refetching derived store) and with two user effects watching the same memo. As a side effect of the same defect, the pending flag never turnstrueat any point.Root cause
Traced by instrumenting each edge (per-pass counts during one spin in parentheses):
isPending's probe subscribes the user effect to the source's_pendingSignal.latest()shadow computed recomputes (×100k), hitsNotReadyError, and lane bookkeeping callsupdatePendingSignal→setSignal(pendingSignal, true)(×100k).optimisticSignal, so the write registers with the ambient transition — which completes within the same pass, andresolveOptimisticNodesreverts the override back tofalse, re-notifying subscribers.false(instrumented: 100,004 consecutivefalse → truewrites), which is also whyisPendingnever readstrue.The pending flag's
trueis written into a transition that reverts it while the async it describes is still in flight — the revert is the re-notification that sustains the loop.Fix
The transition-scoped write itself appears intentional — a variant that commits the companion write directly (bypassing the optimistic machinery) also fixes the loop but breaks three behaviors pinned in
latest-isPending-consistency.test.ts. So only the revert timing changes: companion signals now carry a revert-deferral policy, andresolveOptimisticNodesconsults it before reverting.getPendingSignalattachessig._deferRevert: the source is resolved at check time (parent/firewall forlatest()shadows — a shadow can be held by a wider transition after its own source resolved, so its own state is the wrong thing to consult; and firewall/parent links can be established after the signal first exists) and must be non-disposed and strictly in-flight (STATUS_PENDING && !STATUS_UNINITIALIZED).resolveOptimisticNodesdefers nodes whose policy holds: clears their stale_transitionpointer (a later companion write would otherwiseinitTransition()a completed transition back to life —_done === trueis not followed bycurrentTransition), parks them alias-safely inglobalQueue._optimisticNodes(initTransitionadopts-and-aliases that array, so the park must not push into the array being iterated, and repeated adopt/defer cycles must not accumulate duplicates — verified: the array is back to length 0 after every one of 40 park/settle cycles), and lets them revert through the normal path once the source settles. A source disposed mid-flight falls through to the existing revert-to-false safety net.Checking the resolved companion source rather than the shadow's transition-held state is also what makes the flag clear when the memo's own async resolves while a sibling still holds the transition.
An alternative shape would scope the companion write to the source's transition at write time instead of deferring the revert — happy to rework in that direction if preferred.
Performance
For apps not using
isPending, the new code is unreachable by construction:resolveOptimisticNodesloops over empty arrays and the policy closure only allocates whenisPendingfirst touches a source. Measured write/flush paths at parity (±0.2%, overlapping spreads across isolated runs). A 60-cycle refetch soak with mid-flight unrelated flushes (the old kill condition, repeatedly) stays clean, and the GC suite shows no delta versus pristine.Tests
Seven tests in
tests/isPending-companion-retention.test.ts, six failing red-first on currentnext(the seventh — directisPending(x)— is the deliberate control pinning that the bug waslatest-wrap-specific):falseFull solid-signals suite: the diff versus pristine is exactly the intended flips (the loop variants plus the two pending-flag behaviors), nothing else;
latest-isPending-consistency.test.ts(#2831) passes throughout; solid and @solidjs/web suites (client, hydration, and server configs) rebuilt against the fixed package and re-run at their baselines.Full disclosure: I wrote this with the help of an AI assistant (Claude Fable 5) and reviewed every line before pushing. All new tests were written first and confirmed failing on the unfixed code, and the root cause was established by instrumenting the running scheduler rather than inferred.