Skip to content

Commit 918cc4d

Browse files
committed
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.
1 parent a0524c0 commit 918cc4d

2 files changed

Lines changed: 46 additions & 4 deletions

File tree

packages/solid/src/reactive/signal.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,12 +1471,21 @@ function createComputation<Next, Init = unknown>(
14711471
const [track, trigger] = createSignal<void>(undefined, { equals: false });
14721472
const ordinary = ExternalSourceConfig.factory(c.fn, trigger);
14731473
onCleanup(() => ordinary.dispose());
1474+
let inTransition: ExternalSource | undefined;
14741475
const triggerInTransition: () => void = () =>
1475-
startTransition(trigger).then(() => inTransition.dispose());
1476-
const inTransition = ExternalSourceConfig.factory(c.fn, triggerInTransition);
1476+
startTransition(trigger).then(() => {
1477+
if (inTransition) {
1478+
inTransition.dispose();
1479+
inTransition = undefined;
1480+
}
1481+
});
14771482
c.fn = x => {
14781483
track();
1479-
return Transition && Transition.running ? inTransition.track(x) : ordinary.track(x);
1484+
if (Transition && Transition.running) {
1485+
if (!inTransition) inTransition = ExternalSourceConfig!.factory(c.fn!, triggerInTransition);
1486+
return inTransition.track(x);
1487+
}
1488+
return ordinary.track(x);
14801489
};
14811490
}
14821491

packages/solid/test/external-source.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2-
import { createRoot, createMemo, untrack, enableExternalSource } from "../src/index.js";
2+
import { createRoot, createMemo, createSignal, untrack, enableExternalSource, startTransition } from "../src/index.js";
3+
import { getSuspenseContext } from "../src/reactive/signal.js";
34

45
import "./MessageChannel";
56

@@ -87,6 +88,38 @@ describe("external source", () => {
8788
});
8889
});
8990

91+
92+
it("should not throw when rerunning external source in a new transition after disposal", async () => {
93+
// Initialize SuspenseContext so startTransition creates a real Transition
94+
getSuspenseContext();
95+
96+
await createRoot(async dispose => {
97+
const e = new ExternalSource(0);
98+
const [signal, setSignal] = createSignal(0);
99+
const memo = createMemo(() => {
100+
return e.get() + signal();
101+
});
102+
expect(memo()).toBe(0);
103+
104+
// First transition: triggers inTransition creation and subsequent disposal
105+
await startTransition(() => {
106+
setSignal(1);
107+
});
108+
109+
// Wait for transition to complete and inTransition to be disposed
110+
await new Promise(r => setTimeout(r, 50));
111+
112+
// Second transition: should lazily recreate inTransition, not throw on disposed one
113+
await expect(
114+
startTransition(() => {
115+
setSignal(2);
116+
})
117+
).resolves.not.toThrow();
118+
119+
dispose();
120+
});
121+
});
122+
90123
afterEach(() => {
91124
vi.resetModules();
92125
});

0 commit comments

Comments
 (0)