A derived read via untrack() inside an effect leaves its child deriveds connected forever (memory leak: unmounted components pinned through page.data)
Describe the bug
A $derived re-evaluated from an untracked read inside a reactive effect (untrack(() => d) in a $effect, or any read inside onMount, which untracks its callback) connects its child deriveds without ever registering itself on them. The child ends up CONNECTED, registered on its own dependencies (all the way up to a long-lived source), with reactions === null — and nothing ever removes that registration: the disconnect cascade in remove_reaction only runs when a derived's last reaction is removed, and this one never had a reaction. When the component unmounts, its effects deregister themselves, the zombie derived stays, and the source pins the derived → its ctx (the component context) → the component's detached DOM. Forever.
Cause, in get() (packages/svelte/src/internal/client/runtime.js):
var should_connect =
(derived.f & CONNECTED) === 0 &&
!untracking &&
active_reaction !== null &&
(is_updating_effect || (active_reaction.f & CONNECTED) !== 0);is_updating_effect is still true while update_derived(parent) runs from the untracked read, but active_reaction is now the parent derived, which is not connected (it was read under untrack). So the child connects and registers on its deps (update_dependencies / reconnect), while the parent's own update_dependencies skips registering on the child because the parent lacks CONNECTED.
Effects always carry CONNECTED, and a derived pulled in by a connected reader is flagged CONNECTED in get() before it updates — so (active_reaction.f & CONNECTED) !== 0 already covers every case where connecting is correct. The is_updating_effect || term only adds this leaking case (and the is_dirty dependency-walk variant, which heals itself via reconnect when a tracked reader follows, but leaks when none does).
Real-world impact: in a SvelteKit e-commerce SPA, every product page visited was retained through page.data → route data derived → component deriveds (heap-snapshot dominator + a "deriveds with reactions === null still registered on a dependency" scan); DOM nodes grew 18k → 80k over a 24-page browse after GC, ~4 MB per visit, ending in renderer OOM crashes on mobile. Related family: #18420, #18501 / #18517 (branch/root effects), #18781.
Reproduction
Signals-level, no DOM (run with node from any project that has svelte installed):
import * as $ from 'svelte/internal/client'
const { state, derived, get, set, untrack, effect, effect_root, flush } = $
const source = state({ n: 1, items: [1] }) // e.g. page.data
const data = derived(() => get(source))
const items = derived(() => get(data).items)
const count = derived(() => Math.max(1, get(items).length)) // ← becomes the zombie
const snapshot = derived(() => ({ n: get(data).n, c: get(count) }))
const show = state(true), ticks = state(0)
let seen
const destroy = effect_root(() => {
effect(() => { if (get(show)) effect(() => { void get(snapshot).c }) }) // template reader ({#if})
effect(() => { void get(ticks); seen = untrack(() => get(snapshot)) }) // untracked read
})
flush()
set(show, false); flush()
console.log('after last tracked reader left:', source.reactions) // null ✓
set(source, { n: 2, items: [1, 2] }); flush()
set(ticks, 1); flush()
console.log('after untracked re-read:', source.reactions?.length, count.reactions, items.reactions?.includes(count))
// stock 5.57.0 / main: 1 null true ← count is registered on items with no reactions of its own
destroy(); flush()
console.log('after unmount:', source.reactions?.length) // stock: 1 — leaked; fixed: undefinedComponent shape that triggers it in practice:
<script>
const snapshot = $derived({ n: data.n, c: count }) // chain rooted in page.data
$effect(() => { void ticks; const s = untrack(() => snapshot); /* … */ })
onMount(() => { void imgQuery /* any derived read here is untracked */ })
</script>
{#if show}{snapshot.c}{/if}Fix
PR to follow — drop is_updating_effect || from should_connect (connect only when the reader is connected); is_updating_effect then has no remaining readers and is removed. Adds a signals test for the scenario (fails on main, passes with the change); the rest of the suite is green.
System Info
svelte 5.57.0 (also reproduces on main @ 6eb720a); SvelteKit 2.70.2; Chrome 152; Node 24.
Severity
blocking an upgrade — production renderer OOMs after long browsing sessions until the runtime is patched (pnpm patch).
Source: sveltejs/svelte