fix(signals): clear a node's transition stamp when its pending value commits - #3143
Conversation
🦋 Changeset detectedLatest commit: 007779d The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 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 |
…mits A node's _transition survived the drain that committed it, so it kept pointing at a transition that later finished. setSignal re-enters that stamp before it discovers a write changes nothing, and a Loading boundary rewrites the same flag on every pass of a drain, so a finished transition was re-armed forever and flush() never ended. Dev threw at 100000 passes; production runs the same loop with no counter, so it hung: a frozen tab on the data grid. commitPendingNodes now clears the stamp on the node it just committed. That is the same clearing reassignPendingTransition performs at completion, extended to nodes a drain removes first. Derived from the real source rather than the bundle, unlike the patch in 25c28e9 that this replaces. Solid's own suite passes with and without it, 114 files and 1432 tests. Over the browser crawl, scoring runaways, RangeError and every page error: unpatched, 5 of 10 runs met the condition and all 5 span; patched, 12 of 12 clean and the condition never formed. The counter that records it is observe-only, so zero means the stale stamp no longer exists rather than that its symptom is masked. Upstream as solidjs/solid#3143, which closes solidjs/solid#3140.
|
Verified and merging — thank you for the follow-through from the report to the patch. Your placement (the commit loop) and coverage are exactly right; I confirmed |
…uard (#3140) Companion to #3143 (stamps cleared when pending values commit): a dead _transition reference can still reach initTransition from outside the commit path — merged _done forwarding chains, async settles racing completion — and setSignal re-opens a node's stamped transaction before the value-equal bail, so re-activating the corpse spun the drain loop (dev threw the loop guard, production hung the tab). initTransition now refuses a transaction whose chased _done chain ends in true, as a bare return: redirecting to a fresh batch would re-arm the loop with a new identity each pass (measured by the reporter). The dev loop guard now reports what kept the loop alive — scheduled work vs an active transition, done-state, queue counts, last staged node. The corpse signature reads 'done=true, pending=0'. Dev-only, tree-shaken from prod. White-box pins for both layers; one size budget ratcheted 26.1 -> 26.15 KB (~25 B across this and #3141). Co-authored-by: Cursor <cursoragent@cursor.com>
…uard (#3140) Companion to #3143 (stamps cleared when pending values commit): a dead _transition reference can still reach initTransition from outside the commit path — merged _done forwarding chains, async settles racing completion — and setSignal re-opens a node's stamped transaction before the value-equal bail, so re-activating the corpse spun the drain loop (dev threw the loop guard, production hung the tab). initTransition now refuses a transaction whose chased _done chain ends in true, as a bare return: redirecting to a fresh batch would re-arm the loop with a new identity each pass (measured by the reporter). The dev loop guard now reports what kept the loop alive — scheduled work vs an active transition, done-state, queue counts, last staged node. The corpse signature reads 'done=true, pending=0'. Dev-only, tree-shaken from prod. White-box pins for both layers; one size budget ratcheted 26.1 -> 26.15 KB (~25 B across this and #3141). Co-authored-by: Cursor <cursoragent@cursor.com>
Closes #3140.
The bug
_transitionis cleared in exactly two places:optimistic.ts, for nodes in_optimisticNodes, and one async settle inasync.ts. Everything else relies onreassignPendingTransition, which the completing branch runs overbatch._pendingNodes.commitPendingNodesdrains that list without clearing anything:and
commitPendingNodenever touches_transition. So a node committed by an earlier drain leaves the list still stamped. When the transition completes,reassignPendingTransition(batch._pendingNodes)walks a list that no longer holds it, clears nothing, andtransitionCompletethen sets_done = true. The node is left pointing at a finished transition.From there
flush()cannot end.setSignalre-entersel._transitionbefore it reaches its ownvalueChangedcheck, andCollectionQueue._checkSourceswrites the same loading-boundary flag on every pass of a drain. A write that changes nothing re-arms a transition that can never complete again:activeTransition, so it callsglobalQueue.flush()transitionCompletereturns true on its first line,_donebeing alreadytrue; the transition completes andactiveTransitionbecomes nullfinalizePureQueuereachescheckBoundaryChildren, thenCollectionQueue._checkSources, which writesfalseoverfalsesetSignalre-enters the finished transition before discovering the write is a no-opNothing recomputes in that cycle. Sampled from inside the drain loop,
dirtyQueueis empty,scheduledis false, both effect queues are empty, and there are no pending nodes, async reporters or lanes. Dev throws at 100,000 passes. The production scheduler runs the same loop with no counter, so it hangs instead.There is a second face to it. In the completing branch the fresh ambient batch keeps the dead transition's containers by reference:
so once a finished transition is re-armed,
initTransition's adoption pass iteratesbatch._pendingNodeswhile pushing intoactiveTransition._pendingNodes, the same array, untilRangeError: Invalid array lengthinqueuePendingNode.mergeTransitionStatealready guards this aliasing for_optimisticNodesand_affectsNodeswith move-not-copy; the pending list reaches it another way.The change
Clear the stamp where the node leaves the transaction. This is the same clearing
reassignPendingTransitionperforms at completion, extended to nodes a drain removes first.Evidence
packages/signals: 114 files, 1432 tests, identical results with and without the change, so it breaks nothing currently covered.prettier --checkclean on the touched file. Thetypecheckfailures onnextare pre-existing and in test files this PR does not touch.RangeErrorand total page errors:RangeErrorThe middle column is the direct readout. I left a counter on
setSignalthat records a write meeting a finished stamp, without changing behaviour. Unpatched it fires in half of runs and every one of those spins; patched it never fires at all, which is what you would expect if the stamp is now cleared at the right moment rather than tolerated later.What I could not do
I could not reduce this to a unit test. I tried six shapes in your harness, a loading boundary over one async memo with a churning query, two memos settling out of order,
refresh()on a memo under a boundary, an action writing a memo's input, and a four-memo arrangement mirroring the real screen. None produced a stale stamp. Whatever orders a commit before its transition completes needs a deeper boundary tree than I could construct by hand, and I would rather say so than ship a test that passes for the wrong reason.If you would rather fix it at a different layer, two nearby options address different parts of the same story: clearing inside
commitPendingNodeitself, or making the fresh ambient batch copy its containers instead of aliasing them. Happy to rework this, and happy to run any variant against the same crawl and report the same counters.