Skip to content

2.0.0-beta.20: post-action completion race permanently freezes fresh signals #2916

Description

@brenelz

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

  1. Start two actions that share an incomplete transition.
  2. Flush so the transition is stashed.
  3. Complete action A while action B remains pending.
  4. Write a fresh signal after A's done() restores activeTransition but before the scheduled flush.
  5. Complete action B and flush.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions