Repository navigation
signals rc.14+: a write from onCleanup during land() resurrects the landed transaction, and later transitions never commit #4089
Description
Describe the bug
Since @solidjs/signals 2.0.0-rc.14, a signal write from an onCleanup that runs while a transition lands can bring the landed transaction back. After that, every later transition parks in the dead transaction and never commits.
In an app with @solidjs/router this shows up as a navigation that never finishes: the URL does not change, isRouting stays true, and the outlet stays busy. The trigger is common: an unmounting useQuery from @tanstack/solid-query runs its cleanup (observer.destroy() → QueryCache.notify), and a sibling query on the same key writes its version signal from onCacheEvent.
Versions: @solidjs/signals 2.0.0-rc.14 and 2.0.0-rc.15 are affected (solid-js 2.0.0-rc.15). 2.0.0-rc.13 is not. Bisected to rc.14.
Your Example Website or App
Self-contained reproduction below (plain @solidjs/signals, no DOM, runs in Node).
Steps to Reproduce the Bug or Issue
import { createRoot, createSignal, createMemo, createRenderEffect, onCleanup, flush } from "@solidjs/signals";
const tick = () => new Promise((r) => setTimeout(r, 0));
let release;
const [s, setS] = createSignal(0);
const [v, setV] = createSignal(0, { ownedWrite: true });
createRoot(() => {
const child = createMemo(() => { const k = s(); createRoot(() => onCleanup(() => setV((x) => x + 1))); return k; });
const flight = createMemo(async () => { const k = s(); v(); if (k > 0) await new Promise((r) => (release = r)); return k; });
createRenderEffect(() => [child(), flight()], () => {});
});
const drain = async () => { for (let i = 0; i < 6; i++) { release?.(); await tick(); flush(); await tick(); } };
flush();
setS(1); flush(); await tick(); // transition T parks on `flight`
setV(10); flush(); await tick(); // v joins T (flight reads it)
await drain(); // T lands
setS(2); flush(); await drain(); // next transition
console.log(s(), v());@solidjs/signals |
output (prod and dev builds) |
|---|---|
| 2.0.0-rc.13 | 2 12 |
| 2.0.0-rc.14 | 1 11 |
| 2.0.0-rc.15 | 1 11 |
| 2.0.0-rc.15 + the change below | 2 12 |
Without the onCleanup write, every version prints 2 10.
Expected behavior
2 12, as in rc.13: the second transition commits s = 2, and later writes are not held forever.
Mechanism (as far as we traced it)
GlobalQueue.settle()removes the unblocked transaction fromtransactions(transactions.splice(k, 1)) and then callsland(u)(src/core/scheduler.ts).land(u)walksu._nodesin order.commitPendingNode(child)disposes the old children of the re-run memo, and theironCleanupruns.- The cleanup writes
v.vcomes later inu._nodes, so it still hasCONFIG_HELDand_transaction === u.setSignalcallsjoinFuture(txOf(el)), andflushTransactionbecomesu, which is no longer intransactions. - The next
settle()consumesflushTransaction = uand holds that flush's pending nodes inu. The landing loop never visitsuagain, so those nodes stay held forever.
A side effect: if flushTransaction is already non-null at that point, merge(u, f) runs transactions.splice(transactions.indexOf(u), 1) with index -1 and drops the last live transaction instead.
Possible fix
A write to a node whose transaction has already left transactions belongs to that landing. The same land() loop commits the node later and picks up the new _pendingValue. So joinFuture could skip re-joining it:
export function joinFuture(t: Transaction | null): void {
if (t !== null && !resolveTx(t)._lane && liveTx(t) === null) {
if (!globalQueue._running) schedule();
return;
}
// ...unchanged
}With this change, the reproduction above matches rc.13 in prod and dev builds, and the variant without the cleanup write still prints 2 10. We have not run the signals test suite against it, so treat it as a pointer rather than a vetted patch.
Platform
- Node v25.8.2 on macOS (the reproduction above); the router hang also shows up in Chrome
@solidjs/signals2.0.0-rc.15,solid-js2.0.0-rc.15,@solidjs/router2.0.0-next.30,@tanstack/solid-query6.0.0-rc.5
Additional context
Related: a cleanup that throws inside land() aborts it halfway. land() has no try, and runDisposal intentionally has no try/finally. A throwing cleanup therefore leaves the rest of u._nodes held on a transaction that has already left transactions, and the frame freezes. Since rc.14 this is easier to hit: an untracked read of a node born held throws NotReadyError (A19 exception 1, serve in core.ts). @tanstack/solid-query's onCacheEvent does such a read (untrack(defaultedOptions)) from a cleanup. We will file that part with TanStack separately once we have a standalone reproduction.
Activity
fix(signals): a cleanup write during a landing no longer strands late…
fix(signals): a cleanup write during a landing no longer strands late…
ryansolid commentedon Oct 15, 2026
Generated by Claude Code