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 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(); });