From 918cc4d01778d81b6cbc5aba31eab4dd6fe0fc2a Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Wed, 11 Mar 2026 21:25:07 -0700 Subject: [PATCH 1/4] fix: lazily create inTransition external source to prevent use-after-dispose The inTransition external source was eagerly created and disposed after each transition resolved. When a subsequent transition started, the disposed source was still referenced, causing errors when .track() was called on it. This change makes inTransition lazily initialized on first use during a transition and properly re-created after disposal. Fixes #2275. --- packages/solid/src/reactive/signal.ts | 15 +++++++-- packages/solid/test/external-source.spec.ts | 35 ++++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/solid/src/reactive/signal.ts b/packages/solid/src/reactive/signal.ts index ddce76160..2525b3fa0 100644 --- a/packages/solid/src/reactive/signal.ts +++ b/packages/solid/src/reactive/signal.ts @@ -1471,12 +1471,21 @@ function createComputation( const [track, trigger] = createSignal(undefined, { equals: false }); const ordinary = ExternalSourceConfig.factory(c.fn, trigger); onCleanup(() => ordinary.dispose()); + let inTransition: ExternalSource | undefined; const triggerInTransition: () => void = () => - startTransition(trigger).then(() => inTransition.dispose()); - const inTransition = ExternalSourceConfig.factory(c.fn, triggerInTransition); + startTransition(trigger).then(() => { + if (inTransition) { + inTransition.dispose(); + inTransition = undefined; + } + }); c.fn = x => { track(); - return Transition && Transition.running ? inTransition.track(x) : ordinary.track(x); + if (Transition && Transition.running) { + if (!inTransition) inTransition = ExternalSourceConfig!.factory(c.fn!, triggerInTransition); + return inTransition.track(x); + } + return ordinary.track(x); }; } diff --git a/packages/solid/test/external-source.spec.ts b/packages/solid/test/external-source.spec.ts index 10208a424..9f229db6e 100644 --- a/packages/solid/test/external-source.spec.ts +++ b/packages/solid/test/external-source.spec.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createRoot, createMemo, untrack, enableExternalSource } from "../src/index.js"; +import { createRoot, createMemo, createSignal, untrack, enableExternalSource, startTransition } from "../src/index.js"; +import { getSuspenseContext } from "../src/reactive/signal.js"; import "./MessageChannel"; @@ -87,6 +88,38 @@ describe("external source", () => { }); }); + + it("should not throw when rerunning external source in a new transition after disposal", async () => { + // Initialize SuspenseContext so startTransition creates a real Transition + getSuspenseContext(); + + await createRoot(async dispose => { + const e = new ExternalSource(0); + const [signal, setSignal] = createSignal(0); + const memo = createMemo(() => { + return e.get() + signal(); + }); + expect(memo()).toBe(0); + + // First transition: triggers inTransition creation and subsequent disposal + await startTransition(() => { + setSignal(1); + }); + + // Wait for transition to complete and inTransition to be disposed + await new Promise(r => setTimeout(r, 50)); + + // Second transition: should lazily recreate inTransition, not throw on disposed one + await expect( + startTransition(() => { + setSignal(2); + }) + ).resolves.not.toThrow(); + + dispose(); + }); + }); + afterEach(() => { vi.resetModules(); }); From da37e2d1bf3202d83b6845aa5499a05e7ed7c5c3 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Wed, 11 Mar 2026 21:25:07 -0700 Subject: [PATCH 2/4] add changeset for external source transition fix --- .changeset/fix-external-source-transition.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-external-source-transition.md diff --git a/.changeset/fix-external-source-transition.md b/.changeset/fix-external-source-transition.md new file mode 100644 index 000000000..241d0022b --- /dev/null +++ b/.changeset/fix-external-source-transition.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +fix: lazily create inTransition external source to prevent use-after-dispose From 4310b86aea116d9e95b6dd9d51915884ab76500a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 24 Mar 2026 13:12:38 -0700 Subject: [PATCH 3/4] Fix lazy external source recreation Preserve the original computation function when lazily creating the transition-scoped external source and make the regression test cleanup deterministic. Made-with: Cursor --- packages/solid/src/reactive/signal.ts | 5 +++-- packages/solid/test/external-source.spec.ts | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/solid/src/reactive/signal.ts b/packages/solid/src/reactive/signal.ts index 2525b3fa0..065958f12 100644 --- a/packages/solid/src/reactive/signal.ts +++ b/packages/solid/src/reactive/signal.ts @@ -1468,8 +1468,9 @@ function createComputation( if (IS_DEV && options && options.name) c.name = options.name; if (ExternalSourceConfig && c.fn) { + const sourceFn = c.fn; const [track, trigger] = createSignal(undefined, { equals: false }); - const ordinary = ExternalSourceConfig.factory(c.fn, trigger); + const ordinary = ExternalSourceConfig.factory(sourceFn, trigger); onCleanup(() => ordinary.dispose()); let inTransition: ExternalSource | undefined; const triggerInTransition: () => void = () => @@ -1482,7 +1483,7 @@ function createComputation( c.fn = x => { track(); if (Transition && Transition.running) { - if (!inTransition) inTransition = ExternalSourceConfig!.factory(c.fn!, triggerInTransition); + if (!inTransition) inTransition = ExternalSourceConfig!.factory(sourceFn, triggerInTransition); return inTransition.track(x); } return ordinary.track(x); diff --git a/packages/solid/test/external-source.spec.ts b/packages/solid/test/external-source.spec.ts index 9f229db6e..ecb4643d0 100644 --- a/packages/solid/test/external-source.spec.ts +++ b/packages/solid/test/external-source.spec.ts @@ -57,7 +57,9 @@ describe("external source", () => { } }, dispose: () => { - sources.get(trigger)!.forEach(x => x.removeListener(trigger)); + const trackedSources = sources.get(trigger); + if (!trackedSources) return; + trackedSources.forEach(x => x.removeListener(trigger)); sources.delete(trigger); } }; @@ -106,8 +108,8 @@ describe("external source", () => { setSignal(1); }); - // Wait for transition to complete and inTransition to be disposed - await new Promise(r => setTimeout(r, 50)); + // Allow the transition-scoped external source to dispose itself. + await Promise.resolve(); // Second transition: should lazily recreate inTransition, not throw on disposed one await expect( From 611ef46518775a89a236e04d2e10549649d0741c Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 24 Mar 2026 13:13:06 -0700 Subject: [PATCH 4/4] Format external source follow-up changes Include the formatting updates applied by the pre-commit hook so the PR branch is clean before pushing. Made-with: Cursor --- packages/solid/src/reactive/signal.ts | 3 ++- packages/solid/test/external-source.spec.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/solid/src/reactive/signal.ts b/packages/solid/src/reactive/signal.ts index 065958f12..4db14b218 100644 --- a/packages/solid/src/reactive/signal.ts +++ b/packages/solid/src/reactive/signal.ts @@ -1483,7 +1483,8 @@ function createComputation( c.fn = x => { track(); if (Transition && Transition.running) { - if (!inTransition) inTransition = ExternalSourceConfig!.factory(sourceFn, triggerInTransition); + if (!inTransition) + inTransition = ExternalSourceConfig!.factory(sourceFn, triggerInTransition); return inTransition.track(x); } return ordinary.track(x); diff --git a/packages/solid/test/external-source.spec.ts b/packages/solid/test/external-source.spec.ts index ecb4643d0..47f407f15 100644 --- a/packages/solid/test/external-source.spec.ts +++ b/packages/solid/test/external-source.spec.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createRoot, createMemo, createSignal, untrack, enableExternalSource, startTransition } from "../src/index.js"; +import { + createRoot, + createMemo, + createSignal, + untrack, + enableExternalSource, + startTransition +} from "../src/index.js"; import { getSuspenseContext } from "../src/reactive/signal.js"; import "./MessageChannel"; @@ -90,7 +97,6 @@ describe("external source", () => { }); }); - it("should not throw when rerunning external source in a new transition after disposal", async () => { // Initialize SuspenseContext so startTransition creates a real Transition getSuspenseContext();