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.
This commit is contained in:
LukeParkerDev
2026-04-17 11:57:56 +10:00
parent d04d13ea22
commit 33f5b80235
4 changed files with 32 additions and 10 deletions

View File

@@ -232,10 +232,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>()
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)

View File

@@ -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)

View File

@@ -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"])
})

View File

@@ -89,10 +89,21 @@ export function createScopedCache<T>(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<T>]> = []
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 {