#18781·svelte

Memory leak: reconnecting a derived after nested state replacement retains unmounted components

Author: kmplngjCreated Sep 6, 2026Updated Sep 6, 2026

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:

  1. Creates a plain marker object, passes it as a prop, and retains only a WeakRef to it outside the cycle.
  2. Mounts the component and calls flushSync().
  3. Sets shared.visible = false and flushes.
  4. Replaces shared.data = { value: id } and flushes.
  5. Sets shared.visible = true and flushes.
  6. 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.