From c580b381f78ebf8d92bf04ca999bfc2b28516ead Mon Sep 17 00:00:00 2001 From: "svelte-triage-bot[bot]" <316883489+svelte-triage-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:36:21 +0200 Subject: [PATCH] fix: prevent duplicate derived subscriptions on reconnect (#18899) - A dirty disconnected derived registered newly reached dependencies during evaluation, then `reconnect()` registered them again and retained disposed graphs. - The connection model from [#17105](https://github.com/sveltejs/svelte/pull/17105) and reconnection added in [#17362](https://github.com/sveltejs/svelte/pull/17362) restore skipped old dependencies; recursive unfreezing from [#17682](https://github.com/sveltejs/svelte/pull/17682) requires preserving that lifecycle. - Made reconnection idempotent instead of skipping or moving it, preserving unchanged subscriptions and frozen-effect ordering. - Covered direct and nested derived graphs, existing and newly reached dependencies, and complete teardown across client rendering modes. Fixes https://github.com/sveltejs/svelte/issues/18898 --------- Co-authored-by: svelte-triage-bot --- .changeset/neat-derived-reconnections.md | 5 ++ .../svelte/src/internal/client/runtime.js | 8 ++- packages/svelte/tests/signals/test.ts | 69 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 .changeset/neat-derived-reconnections.md diff --git a/.changeset/neat-derived-reconnections.md b/.changeset/neat-derived-reconnections.md new file mode 100644 index 0000000000..fb6888de67 --- /dev/null +++ b/.changeset/neat-derived-reconnections.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent duplicate subscriptions when reconnecting deriveds diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 31d6ebc9ae..f09aaf34ee 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -719,7 +719,13 @@ function reconnect(derived) { if (derived.deps === null) return; for (const dep of derived.deps) { - (dep.reactions ??= []).push(derived); + var reactions = dep.reactions; + + if (reactions === null) { + dep.reactions = [derived]; + } else if (!includes.call(reactions, derived)) { + reactions.push(derived); + } if ((dep.f & DERIVED) !== 0 && (dep.f & CONNECTED) === 0) { unfreeze_derived_effects(/** @type {Derived} */ (dep)); diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts index afc866951f..2d2d653c03 100644 --- a/packages/svelte/tests/signals/test.ts +++ b/packages/svelte/tests/signals/test.ts @@ -1578,6 +1578,75 @@ describe('signals', () => { }; }); + test('reconnecting deriveds does not duplicate subscriptions', () => { + const enabled = state(false); + const shared = state(1); + let value!: Derived; + + const destroy_owner = effect_root(() => { + value = derived(() => ($.get(enabled) ? $.get(shared) : 0)); + $.untrack(() => $.get(value)); + }); + + const nested_enabled = state(false); + const nested_shared = state(1); + let inner!: Derived; + let outer!: Derived; + + const destroy_nested_owner = effect_root(() => { + inner = derived(() => ($.get(nested_enabled) ? $.get(nested_shared) : 0)); + outer = derived(() => $.get(inner)); + $.untrack(() => $.get(outer)); + }); + + return () => { + flushSync(() => set(enabled, true)); + const destroy_reader = effect_root(() => { + render_effect(() => { + $.get(value); + }); + }); + + const enabled_subscriptions = enabled.reactions?.length; + const shared_subscriptions = shared.reactions?.length; + const enabled_reaction = enabled.reactions?.[0]; + const shared_reaction = shared.reactions?.[0]; + + destroy_reader(); + destroy_owner(); + + assert.equal(enabled_subscriptions, 1); + assert.equal(shared_subscriptions, 1); + assert.equal(enabled_reaction, shared_reaction); + assert.equal(enabled.reactions, null); + assert.equal(shared.reactions, null); + + flushSync(() => set(nested_enabled, true)); + const destroy_nested_reader = effect_root(() => { + render_effect(() => { + $.get(outer); + }); + }); + + const nested_enabled_subscriptions = nested_enabled.reactions?.length; + const nested_shared_subscriptions = nested_shared.reactions?.length; + const nested_enabled_reaction = nested_enabled.reactions?.[0] as Derived | undefined; + const nested_shared_reaction = nested_shared.reactions?.[0]; + const inner_subscriptions = nested_enabled_reaction?.reactions?.length; + + destroy_nested_reader(); + destroy_nested_owner(); + + assert.equal(nested_enabled_subscriptions, 1); + assert.equal(nested_shared_subscriptions, 1); + assert.equal(inner_subscriptions, 1); + assert.equal(nested_enabled_reaction, nested_shared_reaction); + assert.equal(nested_enabled.reactions, null); + assert.equal(nested_shared.reactions, null); + assert.equal(nested_enabled_reaction?.reactions, null); + }; + }); + // https://github.com/sveltejs/svelte/issues/18414 test('a reaction that throws after first-reading a fresh derived does not leak it', () => { const src = state(0);