Skip to content

fix(signals): clear a node's transition stamp when its pending value commits - #3143

Merged
ryansolid merged 3 commits into
solidjs:nextfrom
snowfluke:fix/clear-transition-stamp-on-commit
Aug 31, 2026
Merged

fix(signals): clear a node's transition stamp when its pending value commits#3143
ryansolid merged 3 commits into
solidjs:nextfrom
snowfluke:fix/clear-transition-stamp-on-commit

Conversation

@snowfluke

Copy link
Copy Markdown

Closes #3140.

The bug

_transition is cleared in exactly two places: optimistic.ts, for nodes in _optimisticNodes, and one async settle in async.ts. Everything else relies on reassignPendingTransition, which the completing branch runs over batch._pendingNodes.

commitPendingNodes drains that list without clearing anything:

function commitPendingNodes() {
  const pendingNodes = currentBatch._pendingNodes;
  for (let i = 0; i < pendingNodes.length; i++) {
    commitPendingNode(pendingNodes[i]);
  }
  pendingNodes.length = 0;

and commitPendingNode never 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, and transitionComplete then sets _done = true. The node is left pointing at a finished transition.

From there flush() cannot end. setSignal re-enters el._transition before it reaches its own valueChanged check, and CollectionQueue._checkSources writes 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:

  1. the drain loop sees activeTransition, so it calls globalQueue.flush()
  2. transitionComplete returns true on its first line, _done being already true; the transition completes and activeTransition becomes null
  3. still inside that flush, finalizePureQueue reaches checkBoundaryChildren, then CollectionQueue._checkSources, which writes false over false
  4. setSignal re-enters the finished transition before discovering the write is a no-op
  5. the loop condition is true again, back to 2

Nothing recomputes in that cycle. Sampled from inside the drain loop, dirtyQueue is empty, scheduled is 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:

const fresh = createBatch();
fresh._pendingNodes = batch._pendingNodes;

so once a finished transition is re-armed, initTransition's adoption pass iterates batch._pendingNodes while pushing into activeTransition._pendingNodes, the same array, until RangeError: Invalid array length in queuePendingNode. mergeTransitionState already guards this aliasing for _optimisticNodes and _affectsNodes with 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 reassignPendingTransition performs 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 --check clean on the touched file. The typecheck failures on next are pre-existing and in test files this PR does not touch.
  • Measured in a real app, an internal tool with a data grid holding several async memos under a loading boundary. Its browser crawl visits about thirty screens; I mirrored this one line into published rc.4 and ran the crawl repeatedly, scoring runaways, RangeError and total page errors:
Build Runs meeting the condition Runaways RangeError Page errors
rc.4 5 of 10 5 of 5 0 many
rc.4 + this change 0 of 12 0 0 0

The middle column is the direct readout. I left a counter on setSignal that 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 commitPendingNode itself, 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.

@changeset-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 007779d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
solid-js Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/html Patch
@solidjs/h Patch
@solidjs/universal Patch
@solidjs/element Patch
@solidjs/diagnostics 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

snowfluke added a commit to PT-Perkasa-Pilar-Utama/testate that referenced this pull request Aug 31, 2026
…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.
@ryansolid

Copy link
Copy Markdown
Member

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 commitPendingNode has no other callers, so this closes the leak at the one funnel every commit passes through. Complementary hardening rides in #3148 on top of this: initTransition refusing a chased-dead transaction (covers stamps that survive via non-commit paths — merged _done chains, settles racing completion), the white-box regression pins for both layers, and the loop-guard attribution you asked for in the issue (your case would print done=true, pending=0).

@ryansolid
ryansolid merged commit 28a1eaf into solidjs:next Aug 31, 2026
2 checks passed
ryansolid added a commit that referenced this pull request Aug 31, 2026
…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>
ryansolid added a commit that referenced this pull request Aug 31, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants