diff --git a/.changeset/tidy-frogs-tease.md b/.changeset/tidy-frogs-tease.md new file mode 100644 index 0000000000..b67f3a127d --- /dev/null +++ b/.changeset/tidy-frogs-tease.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: do not connect dependencies of an unconnected derived during init-time reads diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 227203523e..55a43ec2f8 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -660,12 +660,19 @@ export function get(signal) { } // connect disconnected deriveds if we are reading them inside an effect, - // or inside another derived that is already connected + // or inside another derived that is already connected. A derived reader + // that is itself unconnected must not connect its dependencies, even + // while an effect update is in progress (e.g. an init-time prop read + // evaluating a parent prop-expression memo): the dependency would be + // subscribed into the graph while nothing subscribes to the reader, so + // no destroy cascade could ever reach it (#18420) var should_connect = (derived.f & CONNECTED) === 0 && !untracking && active_reaction !== null && - (is_updating_effect || (active_reaction.f & CONNECTED) !== 0); + ((active_reaction.f & DERIVED) !== 0 + ? (active_reaction.f & CONNECTED) !== 0 + : is_updating_effect || (active_reaction.f & CONNECTED) !== 0); var is_new = (derived.f & REACTION_RAN) === 0; diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts index 927ce2e665..6f0ad54dab 100644 --- a/packages/svelte/tests/signals/test.ts +++ b/packages/svelte/tests/signals/test.ts @@ -3,6 +3,7 @@ import { flushSync } from '../../src/index-client'; import * as $ from '../../src/internal/client/runtime'; import { push, pop } from '../../src/internal/client/context'; import { + branch, effect, effect_root, render_effect, @@ -1503,4 +1504,52 @@ describe('signals', () => { assert.deepEqual(log, ['inner destroyed', 'inner destroyed']); }; }); + + // https://github.com/sveltejs/svelte/issues/18420 + test('init-time read of an unconnected derived does not zombie-connect its dependencies', () => { + const s = state(0); + const inner = derived(() => $.get(s)); + const memo = derived(() => $.get(inner)); + + return () => { + // mirrors a component's init-time prop read (e.g. a prop default): + // a branch effect body runs with `active_reaction === null` while an + // effect update is in progress, and the read evaluates the parent's + // prop-expression memo unconnected + const destroy = effect_root(() => { + branch(() => { + assert.equal($.get(memo), 0); + }); + }); + + destroy(); + + // `inner` must not stay subscribed to `s` — nothing holds a + // reference to `inner` that a destroy cascade could reach + assert.equal(s.reactions, null); + assert.equal(inner.reactions, null); + }; + }); + + // https://github.com/sveltejs/svelte/issues/18420 + test('connected read of the same derived chain still cleans up on destroy', () => { + const s = state(0); + const inner = derived(() => $.get(s)); + const memo = derived(() => $.get(inner)); + + return () => { + const destroy = effect_root(() => { + branch(() => { + render_effect(() => { + assert.equal($.get(memo), 0); + }); + }); + }); + + flushSync(); + destroy(); + + assert.equal(s.reactions, null); + }; + }); });