From 51d47794fb9f160ef72ddc3e4affa130c64dd573 Mon Sep 17 00:00:00 2001 From: Evgenii Razinkov Date: Fri, 29 May 2026 18:00:28 -0400 Subject: [PATCH] fix: release lazy() resource and suspense contexts on dispose Nested lazy() components leaked the previous tree across navigation. lazy() cached its createResource accessor in a module-scoped variable, pinning the resource's reactive graph for the app lifetime, and createResource never released its suspense contexts on disposal. With stacked lazy() boundaries that kept disposed component trees (and their detached DOM) reachable, so they were never garbage collected. lazy() now clears the module-scoped accessor on cleanup (the import promise stays cached, so no refetch), and createResource clears its suspense contexts and pending transition promise when its owner is disposed. --- .changeset/fix-lazy-resource-leak.md | 5 +++++ packages/solid/src/reactive/signal.ts | 8 ++++++++ packages/solid/src/render/component.ts | 6 ++++-- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-lazy-resource-leak.md diff --git a/.changeset/fix-lazy-resource-leak.md b/.changeset/fix-lazy-resource-leak.md new file mode 100644 index 000000000..8e128d07c --- /dev/null +++ b/.changeset/fix-lazy-resource-leak.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +Fix memory leak with nested `lazy()` components. `lazy()` cached its `createResource` accessor in a module-scoped variable and `createResource` never released its Suspense contexts on disposal, so a disposed component tree (and its detached DOM) stayed reachable across navigations when `lazy()` boundaries were nested. The module-pinned accessor is now released on cleanup, and the resource clears its Suspense contexts and pending promise on disposal. diff --git a/packages/solid/src/reactive/signal.ts b/packages/solid/src/reactive/signal.ts index d47844c01..f4f0309ab 100644 --- a/packages/solid/src/reactive/signal.ts +++ b/packages/solid/src/reactive/signal.ts @@ -633,6 +633,14 @@ export function createResource( resolved ? "ready" : "unresolved" ); + if (Owner) + onCleanup(() => { + for (const c of contexts.keys()) c.decrement!(); + contexts.clear(); + if (Transition && pr) Transition.promises.delete(pr); + pr = null; + }); + if (sharedConfig.context) { id = sharedConfig.getNextContextId(); if (options.ssrLoadFrom === "initial") initP = options.initialValue as T; diff --git a/packages/solid/src/render/component.ts b/packages/solid/src/render/component.ts index afd807eff..5775162f8 100644 --- a/packages/solid/src/render/component.ts +++ b/packages/solid/src/render/component.ts @@ -3,6 +3,7 @@ import { createSignal, createResource, createMemo, + onCleanup, devComponent, $PROXY, SUPPORTS_PROXY, @@ -356,7 +357,7 @@ export function splitProps< export function lazy>( fn: () => Promise<{ default: T }> ): T & { preload: () => Promise<{ default: T }> } { - let comp: () => T | undefined; + let comp: (() => T | undefined) | undefined; let p: Promise<{ default: T }> | undefined; const wrap: T & { preload?: () => void } = ((props: any) => { const ctx = sharedConfig.context; @@ -374,10 +375,11 @@ export function lazy>( } else if (!comp) { const [s] = createResource(() => (p || (p = fn())).then(mod => mod.default)); comp = s; + onCleanup(() => (comp = undefined)); } let Comp: T | undefined; return createMemo(() => - (Comp = comp()) + (Comp = comp?.()) ? untrack(() => { if (IS_DEV) Object.assign(Comp!, { [$DEVCOMP]: true }); if (!ctx || sharedConfig.done) return Comp!(props);