Skip to content

2.0.0-beta.19: action(): writes after an internal await escape the transaction and commit mid-flight #2913

Description

@brenelz

Version: 2.0.0-beta.19, branch next @ 66a6c13

Summary

In an action(async function* ...), any write after an internal await (as opposed to a yield) to a signal not already stamped with the transaction commits ambiently while the action is still in flight — effects observe the intermediate state. This violates the API's own documented contract ("batches every signal/store write between yields", action.ts doc block), and await fetch(...) inside an action is exactly how mutations will be written.

The leak is write-history-dependent, which makes it intermittent in practice: a signal already written before the await rejoins the transaction (via its _transition stamp), while a fresh signal leaks. Same code, different signals, different containment.

Root cause

packages/solid-signals/src/core/action.ts:111-123 — the transition context is only restored around yield boundaries (restoreTransition wraps step()); an async generator's await-continuations run as bare promise jobs with activeTransition === null. setSignal (core/core.ts:935) only rejoins the transaction when el._transition is already stamped.

Related same-family finding: calling public flush() inside a sync action body stashes the transaction and nulls activeTransition (scheduler.ts:403-434) with nothing re-establishing it until the next yield — subsequent fresh-signal writes in the same synchronous segment leak the same way.

Repro

const [$x, setX] = createSignal(0);
const [$y, setY] = createSignal(0);

const act = action(async function* () {
  setX(1);       // sync segment: correctly held by the transaction
  await gate;    // continuation escapes restoreTransition
  setY(5);       // post-await write to a fresh signal
  yield hold;    // action still in flight
  setX(2);
});

const done = act();
flush();
resolveGate();
await microtasks(); flush();

// Action is still in flight (hold unresolved):
$x(); // 0 ✓ — control: pre-await signal is held
$y(); // FAILS: 5 — leaked and committed mid-flight; expected 0

// An effect on $y observes [0, 5] mid-flight — expected [0] until settle.

Impact

HIGH — the core batching guarantee of the flagship mutation API silently doesn't hold across await, so UIs observe half-applied mutations mid-action. Fix shape: restore the transition context across await-continuations too (e.g. wrap the async generator's next() resumption the way yield boundaries already are), and re-establish it after an in-body flush().

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions