Skip to content

signals rc.14+: a write from onCleanup during land() resurrects the landed transaction, and later transitions never commit #4089

Closed

Description

@neokofg

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)

  1. GlobalQueue.settle() removes the unblocked transaction from transactions (transactions.splice(k, 1)) and then calls land(u) (src/core/scheduler.ts).
  2. land(u) walks u._nodes in order. commitPendingNode(child) disposes the old children of the re-run memo, and their onCleanup runs.
  3. The cleanup writes v. v comes later in u._nodes, so it still has CONFIG_HELD and _transaction === u. setSignal calls joinFuture(txOf(el)), and flushTransaction becomes u, which is no longer in transactions.
  4. The next settle() consumes flushTransaction = u and holds that flush's pending nodes in u. The landing loop never visits u again, 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/signals 2.0.0-rc.15, solid-js 2.0.0-rc.15, @solidjs/router 2.0.0-next.30, @tanstack/solid-query 6.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

  1. ryansolid commented on Oct 15, 2026

    @ryansolid
    Member

    Thanks for the precise trace. Fixed on next by #4092. The fix is smaller than the guard you suggested in joinFuture, because that path is on the core floor's size budget: once land finishes its commits, it clears flushTransaction if a cleanup left it pointing at the landed transaction. The cleanup's write still lands with the transition, because land commits the node after it. Your repro now prints 2 12 again, and it's in the suite as a regression test.

    One narrower case isn't covered. If a single landing's cleanups first write a node held by another live transaction and then one held by the landing itself, the landing merges into the live transaction. If you hit that, please open a new issue. It ships in the next release.

    — Claude via Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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