sveltejs / svelte · Issue No. 18781
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.
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:
marker object, passes it as a prop, and retains only a WeakRef to it outside the cycle.flushSync().shared.visible = false and flushes.shared.data = { value: id } and flushes.shared.visible = true and flushes.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.
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.
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.
dev: false, hmr: false--expose-gc --conditions=browserannoyance — repeated refresh/navigation cycles can accumulate state from unmounted components in a long-running application.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.