Skip to content

[Solid 2 / RC9] Root disposal during a pending action permanently skips memo and child-effect cleanup #3561

Description

@rvlzzr

Describe the bug

Disposing a root while an action is pending skips cleanup callbacks belonging to the previous memo branch:

  • Its onCleanup callback does not run.
  • Its child effect’s returned cleanup does not run.
  • Neither callback runs when the action subsequently settles.

Your Example Website or App

see below

Steps to Reproduce the Bug or Issue

In an empty directory:

pnpm add @solidjs/signals@2.0.0-rc.9
node --conditions=development repro.mjs

repro.mjs:

import {
  action,
  createEffect,
  createMemo,
  createRoot,
  createSignal,
  flush,
  onCleanup,
} from "@solidjs/signals";

let visible;
let setVisible;
let dispose;

let frameCleanups = 0;
let childCleanups = 0;

const pending = Promise.withResolvers();

createRoot((release) => {
  dispose = release;
  [visible, setVisible] = createSignal(true);

  const branch = createMemo(() => {
    if (!visible()) return false;

    onCleanup(() => frameCleanups++);

    createEffect(
      () => visible(),
      () => () => childCleanups++,
    );

    return true;
  });

  // Keep the branch observed.
  createEffect(branch, () => {});
});
flush();

const change = action(function* () {
  setVisible(false);
  yield pending.promise;
});

const changed = change();
flush();

console.log("pending:", { frameCleanups, childCleanups });

dispose();
flush();

console.log("disposed:", { frameCleanups, childCleanups });

pending.resolve();
await changed;
flush();

console.log("settled:", { frameCleanups, childCleanups });

Expected behavior

Actual result

pending:  { frameCleanups: 0, childCleanups: 0 }
disposed: { frameCleanups: 0, childCleanups: 0 }
settled:  { frameCleanups: 0, childCleanups: 0 }

Expected result

Both callbacks run exactly once when the root is disposed. Subsequent action settlement does not invoke them again:

pending:  { frameCleanups: 0, childCleanups: 0 }
disposed: { frameCleanups: 1, childCleanups: 1 }
settled:  { frameCleanups: 1, childCleanups: 1 }

Retaining the old branch while the action is pending is expected. The problem is that its cleanup is permanently omitted after the root is disposed, including after the action finishes.

Screenshots or Videos

No response

Platform

Additional context

Control cases

The following variants were tested against the unpatched GitHub build:

Variant Result
Dispose while the action is pending, then settle it Both cleanup counts remain 0
Move the signal outside the root Same failure
Use an async generator with await pending.promise; yield; Same failure
Update the signal without an action, then flush and dispose Both callbacks run exactly once
Settle and flush the action before disposing Both callbacks run exactly once

The memo and child effect are created under the root. The child effect uses Solid 2’s two-function API and returns its cleanup from the effect callback. All flush() calls occur outside the action body.

Cleanup contract

The custom primitives guide states that:

  • onCleanup attaches to the current owner and runs when that owner is disposed.
  • An effect’s returned cleanup runs before its next execution and on disposal.
  • createRoot provides an owner and disposer for standalone reactive integrations and tests.

The source documentation for createRoot likewise says its disposer tears down the signals, memos, effects, and cleanup callbacks registered inside the root.

Source investigation

The relevant paths appear to be:

  1. Recomputing under a pending action parks the previous memo frame’s children and cleanup in _pendingFirstChild and _pendingDisposal.
  2. Root disposal marks the memo disposed but traverses only its current children and cleanup.
  3. Action settlement subsequently attempts to retire the parked frame.
  4. disposeChildren returns immediately because the memo is already marked REACTIVE_DISPOSED.

See packages/signals/src/core/owner.ts.

Relationship to #3543 / #3547

The tested GitHub commit includes #3547’s fix preserving REACTIVE_ZOMBIE through recomputation. This root-disposal reproduction still fails with that fix present.

This report concerns permanently missed cleanup callbacks, rather than the observer accumulation described in #3543.

No activity

Activity on this issue will appear here.

Activity

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