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().
Version: 2.0.0-beta.19, branch
next@ 66a6c13Summary
In an
action(async function* ...), any write after an internalawait(as opposed to ayield) 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), andawait 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
awaitrejoins the transaction (via its_transitionstamp), 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 (restoreTransitionwrapsstep()); an async generator's await-continuations run as bare promise jobs withactiveTransition === null.setSignal(core/core.ts:935) only rejoins the transaction whenel._transitionis already stamped.Related same-family finding: calling public
flush()inside a sync action body stashes the transaction and nullsactiveTransition(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
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'snext()resumption the way yield boundaries already are), and re-establish it after an in-bodyflush().