From 33f5b80235246e770edbf397eff6a2fbcb7d7d45 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:57:56 +1000 Subject: [PATCH] fix: defer reactive root disposal in cache cleanups Same nested-dispose-in-onCleanup bug as 7f36ac2481 but in three more places: TerminalProvider.disposeAll, PromptProvider.disposeAll, and scoped-cache.clear() (covers viewCache.clear and comments cache.clear). All of them synchronously call createRoot dispose() on cached entries inside onCleanup, which during a server switch nests into the outer cleanNode cascade and throws TypeError at chunk-*.js:992. Snapshot the pending disposers, clear the cache synchronously, and fire the disposers on a microtask so the outer cleanup finishes first. --- packages/app/src/context/prompt.tsx | 9 ++++++--- packages/app/src/context/terminal.tsx | 11 ++++++++--- packages/app/src/utils/scoped-cache.test.ts | 5 ++++- packages/app/src/utils/scoped-cache.ts | 17 ++++++++++++++--- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 9b666e5e75..0d1cee7107 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -232,10 +232,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const cache = new Map() const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } + // Defer the dispose calls to a microtask; synchronous nested dispose + // inside a parent onCleanup corrupts solid-js's in-flight cleanNode + // traversal during mass remounts (see context/terminal.tsx for the + // same pattern). + const pending = Array.from(cache.values(), (entry) => entry.dispose) cache.clear() + if (pending.length) queueMicrotask(() => pending.forEach((d) => d())) } onCleanup(disposeAll) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 31d2d6e04c..482f55c716 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -364,10 +364,15 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont onCleanup(() => caches.delete(cache)) const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } + // Snapshot disposers, then defer them to a microtask. When this runs + // from onCleanup during a parent remount (e.g. switching servers), + // calling dispose() synchronously starts a nested cleanNode cascade on + // a sibling root while the outer cascade is mid-traversal, corrupting + // solid-js's graph walk state and throwing `Cannot read properties of + // null (reading '1')` at chunk-*.js:992. + const pending = Array.from(cache.values(), (entry) => entry.dispose) cache.clear() + if (pending.length) queueMicrotask(() => pending.forEach((d) => d())) } onCleanup(disposeAll) diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts index 0c6189dafe..26821134c8 100644 --- a/packages/app/src/utils/scoped-cache.test.ts +++ b/packages/app/src/utils/scoped-cache.test.ts @@ -24,7 +24,7 @@ describe("createScopedCache", () => { expect(disposed).toEqual(["b"]) }) - test("disposes entries on delete and clear", () => { + test("disposes entries on delete and clear", async () => { const disposed: string[] = [] const cache = createScopedCache((key) => ({ key }), { dispose: (value) => disposed.push(value.key), @@ -39,6 +39,9 @@ describe("createScopedCache", () => { cache.clear() expect(cache.peek("b")).toBeUndefined() + // clear() defers dispose to a microtask to avoid nested cleanNode cascades + // when called from inside an onCleanup; flush the queue before asserting. + await Promise.resolve() expect(disposed).toEqual(["a", "b"]) }) diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts index 224c363c1e..7044cdf03c 100644 --- a/packages/app/src/utils/scoped-cache.ts +++ b/packages/app/src/utils/scoped-cache.ts @@ -89,10 +89,21 @@ export function createScopedCache(createValue: (key: string) => T, options: S } const clear = () => { - for (const [key, entry] of store) { - dispose(key, entry) - } + // Defer dispose() calls to a microtask. When clear() runs inside an + // onCleanup during a parent remount (e.g. context/file.tsx and + // context/comments.tsx both do this), synchronous dispose on cached + // createRoot entries starts a nested cleanNode cascade while the outer + // cascade is mid-traversal, corrupting solid-js's graph walk state and + // throwing `Cannot read properties of null (reading '1')` at + // chunk-*.js:992. Deferring lets the outer cleanup finish first. + const pending: Array<[string, Entry]> = [] + for (const entry of store) pending.push(entry) store.clear() + if (pending.length && options.dispose) { + queueMicrotask(() => { + for (const [key, entry] of pending) dispose(key, entry) + }) + } } return {