Memory leak: reconnecting a derived after nested state replacement retains unmounted components
Describe the bug
A component-owned $derived remains reachable after unmount() when its last reader is hidden, a nested reactive object is replaced while hidden, and the reader is shown again.
This reproduces on Svelte 5.57.0 using only public Svelte APIs. It does not require SvelteKit, Vite, HMR, browser extensions, exceptions, or imports from svelte/internal.
AI disclosure: This report and minimal reproduction were prepared by OpenAI Codex (AI) at my request. The tests described below were actually executed locally. We hope the concrete reproduction and control cases are still helpful to the Svelte maintainers.
Reproduction
Complete runnable reproduction and raw results.
git clone https://gist.github.com/22ab718261121f8afebe0a9750ff3672.git svelte-reconnect-repro
cd svelte-reconnect-repro
npm install --ignore-scripts
npm test -- replace
The two application files are:
shared.svelte.js
export const shared = $state({
visible: true,
data: { value: 0 },
locale: 'en'
});
Retained.svelte
<script>
import { shared } from './shared.svelte.js';
let { marker } = $props();
const label = $derived(`${shared.data.value}:${shared.locale}:${marker.id}`);
</script>
<section>
{#if shared.visible}
<span>{label}</span>
{:else}
<span>Loading</span>
{/if}
</section>
The runner compiles these files using svelte/compiler with dev: false and hmr: false. For each of 30 component instances it:
- Creates a plain
markerobject, passes it as a prop, and retains only aWeakRefto it outside the cycle. - Mounts the component and calls
flushSync(). - Sets
shared.visible = falseand flushes. - Replaces
shared.data = { value: id }and flushes. - Sets
shared.visible = trueand flushes. - Awaits
unmount(instance).
After all cycles it crosses task boundaries and forces GC ten times. The marker references are checked only afterward.
Expected: All 30 markers are collectible after unmount.
Actual: All 30 survive, although the document contains zero element children. The runner intentionally exits with code 1 when markers remain.
Logs and controls
Five separate processes per mode, 30 instances per process:
| Mode | Operation while reader is hidden | Retained markers in the five runs |
|---|---|---|
replace |
Replace nested object | 30, 30, 30, 30, 30 |
mount-only |
No hide/show or data change | 0, 0, 0, 0, 0 |
toggle |
Hide/show without changing data | 0, 0, 0, 0, 0 |
mutate |
Mutate shared.data.value in place |
0, 0, 0, 0, 0 |
{"svelte":"5.57.0","mode":"replace","cycles":30,"retained":30,"remainingElements":0}
Run the controls with npm test -- mount-only, npm test -- toggle, and npm test -- mutate. Each exits successfully.
Suspected runtime cause
The retaining path appears to be a long-lived source's reactions array → orphan derived → component closure/props.
In get(), a previously evaluated, disconnected derived is marked CONNECTED before update_derived(). If the dependency list changes, update_dependencies() already adds it to dependencies' reactions arrays. The subsequent reconnect() adds it again. Later remove_reaction() removes only one occurrence, leaving an entry even though the derived has no remaining readers.
As a diagnostic experiment only, preventing duplicate insertion in reconnect() changed the public-API reproduction from 30 retained markers to 0. The published reproduction uses the unmodified release. I am not proposing that this guard is necessarily the correct or complete production fix.
Related: #18623 and its open PR #18709 also concern retention after unmount, but that proposed change targets last_scheduled_effect. This example needs nested dependency replacement and points to source.reactions instead; please consolidate if the maintainers consider it the same underlying issue. The exception-cleanup fix #18703 shipped in 5.57.0 does not eliminate this reproduction.
System Info
- Svelte: 5.57.0, exact package version in the reproduction
- Node.js: 26.8.1
- jsdom: 30.0.1
- OS: macOS 26.6.2, arm64
- Compiler: client output,
dev: false,hmr: false - Node flags:
--expose-gc --conditions=browser
Severity
annoyance — repeated refresh/navigation cycles can accumulate state from unmounted components in a long-running application.
Source: sveltejs/svelte