Skip to content

fix(signals): defer onSettled fallback cleanup to owner disposal (closes #2766)#2782

Open
tsushanth wants to merge 2 commits into
solidjs:nextfrom
tsushanth:fix-createReaction-zero-deps-crash
Open

fix(signals): defer onSettled fallback cleanup to owner disposal (closes #2766)#2782
tsushanth wants to merge 2 commits into
solidjs:nextfrom
tsushanth:fix-createReaction-zero-deps-crash

Conversation

@tsushanth

@tsushanth tsushanth commented Jun 13, 2026

Copy link
Copy Markdown

Summary

Closes #2766.

onSettled falls back to the global queue when the current owner is null or has CONFIG_CHILDREN_FORBIDDEN. The most common trigger for that fallback is onSettled() called from inside another onSettled() callback (the outer callback runs as a tracked effect, which sets CONFIG_CHILDREN_FORBIDDEN). The previous fallback shape:

globalQueue.enqueue(EFFECT_USER, () => {
  const cleanup = callback();
  
  cleanup?.();        // ❌ runs the cleanup eagerly in the same flush
});

invoked the returned cleanup eagerly inside the same flush, immediately tearing down helpers whose intent was "install on settle, tear down on dispose." Composable helpers using onSettled internally — subscription setup, listener install, focus management — set up and ripped themselves down in the same tick.

Capture the calling owner and, when the callback returns a function, register it via cleanup() under that owner with runWithOwner. Cleanup now fires when the captured owner is disposed, matching the contract of the non-forbidden path. Orphaned helpers (no owner at all) drop the cleanup rather than run it immediately — there is no point at which firing it would be correct.

Test plan

Added defers cleanup from nested onSettled to owner disposal (#2766) to tests/onSettled.test.ts mirroring the reporter's scenario:

function useSubscription() {
  onSettled(() => {
    log.push("subscribed");
    return () => log.push("unsubscribed");
  });
}
const dispose = createRoot(stop => {
  onSettled(() => { useSubscription(); });
  return stop;
});
flush();
expect(log).toEqual(["subscribed"]);
dispose();
expect(log).toEqual(["subscribed", "unsubscribed"]);
pnpm --filter @solidjs/signals build
cd packages/solid-signals && npx vitest run tests/onSettled.test.ts

Result: 13/13 pass with the fix; reverting signals.ts to upstream and re-running shows the new case fails (expected ["subscribed","unsubscribed"] to equal ["subscribed"]) — confirming the test isolates the regression.

 solidjs#2766)

When `onSettled` is called from a children-forbidden scope (most commonly
from inside another `onSettled` callback, which runs as a tracked
effect), the implementation fell back to enqueueing a one-shot callback
on the global queue and invoked the returned cleanup eagerly inside the
same flush:

  globalQueue.enqueue(EFFECT_USER, () => {
    const cleanup = callback();
    …
    cleanup?.();
  });

That immediately tore down helpers whose intent was "install on settle,
tear down on dispose" — composable helpers using `onSettled` internally
saw their subscription set up and ripped down in the same tick (solidjs#2766).

Capture the calling owner and, when the callback returns a function,
attach it via `cleanup()` under that owner with `runWithOwner`. The
cleanup now fires when the captured owner is disposed, matching the
contract of the non-forbidden path. Orphaned helpers (no owner at all)
drop the cleanup rather than run it immediately — there is no point at
which firing it would be correct.

Add a regression test in `tests/onSettled.test.ts` that wraps the
reporter's pattern (a nested `onSettled` inside an outer `onSettled`)
and asserts the subscription stays alive until `dispose()`.
@changeset-bot

changeset-bot Bot commented Jun 13, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 64f5446

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant