Describe the bug
In Solid 2.0.0-beta.20, a signal written in the microtask window after one action completes but while another action keeps the shared transition incomplete can become permanently frozen.
The first write never commits. More seriously, every later ambient write to the same signal also remains uncommitted because the signal keeps a _pendingValue that is no longer present in any batch or transition queue. Dev mode reports INV-7, confirming that the value can never commit.
Your Example Website or App
Focused regression test against solidjs/solid next at ec432b40:
import { action, createSignal, flush } from "../src/index.js";
import * as scheduler from "../src/core/scheduler.js";
const tick = () => Promise.resolve();
const [y, setY] = createSignal(0);
let resolveA!: () => void;
const pA = new Promise<void>(r => (resolveA = r));
let resolveB!: () => void;
const pB = new Promise<void>(r => (resolveB = r));
const A = action(async function* () {
yield pA;
});
const B = action(function* () {
yield pB;
});
const aDone = A();
const bDone = B();
flush(); // stash the shared incomplete transition
resolveA();
// Write in the window where A's done() has restored activeTransition but
// its scheduled flush has not run. The internal read only makes the timing
// deterministic; the write itself is an ordinary application write.
let wrote = false;
for (let i = 0; i < 16; i++) {
await tick();
if (!wrote && scheduler.activeTransition !== null) {
wrote = true;
setY(7);
}
}
resolveB();
await Promise.all([aDone, bDone]);
try {
flush();
} catch {
// Dev: INV-7, pending value at quiescence with no queued commit.
}
console.log(y()); // actual: 0; expected: 7
setY(9);
try {
flush();
} catch {}
console.log(y()); // actual: still 0; expected: 9
Steps to Reproduce the Bug or Issue
- Start two actions that share an incomplete transition.
- Flush so the transition is stashed.
- Complete action A while action B remains pending.
- Write a fresh signal after A's
done() restores activeTransition but before the scheduled flush.
- Complete action B and flush.
- Observe that the write never commits and later writes to the signal remain frozen.
Expected behavior
The write should either join the active transition and commit when B settles, or remain in the ambient batch and commit normally. Once the transition completes, later writes must always remain usable.
Screenshots or Videos
Not applicable.
Platform
- OS: macOS
- Runtime: Node.js 25 / Vitest 4.1.6
- Version: Solid 2.0.0-beta.20,
next at ec432b40
Additional context
The race spans packages/solid-signals/src/core/action.ts:105-110 and packages/solid-signals/src/core/scheduler.ts:426-442,545-590.
When an incomplete transition is stashed, the scheduler detaches it and installs a fresh ambient batch:
currentBatch = this._batch = createBatch();
Action done() later calls setActiveTransition(ctx) directly. Unlike globalQueue.initTransition(ctx), that does not make the transition the current batch. During the resulting microtask window, queuePendingNode() writes the fresh signal into the detached ambient currentBatch, despite activeTransition being non-null.
The next incomplete-transition stash replaces that ambient batch with another createBatch(), dropping its pending-node array. The signal's _pendingValue remains set, so later writes replace the pending value without re-queueing the node. It is permanently stranded.
This still reproduces after the beta.20 batch-state consolidation in aa397526.
Describe the bug
In Solid 2.0.0-beta.20, a signal written in the microtask window after one action completes but while another action keeps the shared transition incomplete can become permanently frozen.
The first write never commits. More seriously, every later ambient write to the same signal also remains uncommitted because the signal keeps a
_pendingValuethat is no longer present in any batch or transition queue. Dev mode reportsINV-7, confirming that the value can never commit.Your Example Website or App
Focused regression test against
solidjs/solidnextatec432b40:Steps to Reproduce the Bug or Issue
done()restoresactiveTransitionbut before the scheduled flush.Expected behavior
The write should either join the active transition and commit when B settles, or remain in the ambient batch and commit normally. Once the transition completes, later writes must always remain usable.
Screenshots or Videos
Not applicable.
Platform
nextatec432b40Additional context
The race spans
packages/solid-signals/src/core/action.ts:105-110andpackages/solid-signals/src/core/scheduler.ts:426-442,545-590.When an incomplete transition is stashed, the scheduler detaches it and installs a fresh ambient batch:
Action
done()later callssetActiveTransition(ctx)directly. UnlikeglobalQueue.initTransition(ctx), that does not make the transition the current batch. During the resulting microtask window,queuePendingNode()writes the fresh signal into the detached ambientcurrentBatch, despiteactiveTransitionbeing non-null.The next incomplete-transition stash replaces that ambient batch with another
createBatch(), dropping its pending-node array. The signal's_pendingValueremains set, so later writes replace the pending value without re-queueing the node. It is permanently stranded.This still reproduces after the beta.20 batch-state consolidation in
aa397526.