From 42f5d10a42d8c6a0ac5f99f17f3805ea3048b2c0 Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 29 Jun 2025 15:49:15 +0100 Subject: [PATCH 01/25] Store deriveds old value before updating them for consistency with directly assigned sources when reading in teardown functions --- .../internal/client/reactivity/deriveds.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index e9cea0df3e64..ee2bf3ed2f81 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -15,7 +15,7 @@ import { import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; import { destroy_effect } from './effects.js'; -import { inspect_effects, set_inspect_effects } from './sources.js'; +import { inspect_effects, old_values, set_inspect_effects } from './sources.js'; import { get_stack } from '../dev/tracing.js'; import { tracing_mode_flag } from '../../flags/index.js'; import { component_context } from '../context.js'; @@ -175,6 +175,23 @@ export function update_derived(derived) { var value = execute_derived(derived); if (!derived.equals(value)) { + // store old value before updating + // so that user_effect teardown functions + // can access the previous value. + // this is needed because derived updates happen early during + // effects dependency resolution (before cleanup), + // unlike direct state/derived updates, and this + // can happen also during template_effect execution + // that also happens before user_effect teardown. + // + // store old value only if not inside a teardown function + // because in legacy mode, bindable props are deriveds + // and they are executed during teardown. + if (!is_destroying_effect) { + var old_value = derived.v; + old_values.set(derived, old_value); + } + derived.v = value; derived.wv = increment_write_version(); } From 1fc9f0801c1ec583da9c69986925887377b8fffa Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 29 Jun 2025 16:45:35 +0100 Subject: [PATCH 02/25] Add tests for derived old values in teardown functions --- .../derived-cleanup-old-value/_config.js | 39 +++++++++++++++++++ .../derived-cleanup-old-value/main.svelte | 17 ++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/_config.js b/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/_config.js new file mode 100644 index 000000000000..4810ba6fb985 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/_config.js @@ -0,0 +1,39 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, logs, target }) { + /** @type {HTMLButtonElement | null} */ + const increment_btn = target.querySelector('#increment'); + /** @type {HTMLButtonElement | null} */ + const overwrite_btn = target.querySelector('#overwrite'); + + // Initial state: count=1, derived_value=1 + + // Click to increment count: count=2, derived_value=4 + flushSync(() => { + increment_btn?.click(); + }); + + // Click to increment count: count=3, derived_value=9 + flushSync(() => { + increment_btn?.click(); + }); + + // Click to overwrite derived_value: count=3, derived_value=7 + flushSync(() => { + overwrite_btn?.click(); + }); + + // Should log old value during cleanup (4) and new value during setup (9) + assert.deepEqual(logs, [ + '$effect: 1', + '$effect teardown: 1', + '$effect: 4', + '$effect teardown: 4', + '$effect: 9', + '$effect teardown: 9', + '$effect: 7' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/main.svelte new file mode 100644 index 000000000000..6cef12b1e345 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/main.svelte @@ -0,0 +1,17 @@ + + + + + +

count: {count}

+

derived_value: {derived_value}

From 9d9b64b94eb4a1b700d15da9fa08e793ed6ded06 Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 29 Jun 2025 16:46:58 +0100 Subject: [PATCH 03/25] Add tests for props old values in onDestroy hooks to prevent regressions --- .../Component.svelte | 11 ++++ .../_config.js | 63 +++++++++++++++++++ .../main.svelte | 52 +++++++++++++++ .../Component.svelte | 14 +++++ .../_config.js | 63 +++++++++++++++++++ .../main.svelte | 52 +++++++++++++++ .../Component.svelte | 11 ++++ .../_config.js | 49 +++++++++++++++ .../main.svelte | 41 ++++++++++++ 9 files changed, 356 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/Component.svelte b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/Component.svelte new file mode 100644 index 000000000000..40d6b1c2fbb4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/Component.svelte @@ -0,0 +1,11 @@ + + +

Count: {count}

diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/_config.js b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/_config.js new file mode 100644 index 000000000000..07ea5af153a6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/_config.js @@ -0,0 +1,63 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, logs, target }) { + /** @type {HTMLButtonElement | null} */ + const increment_btn = target.querySelector('#increment'); + /** @type {HTMLButtonElement | null} */ + const toggle_btn = target.querySelector('#toggle'); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 0'); + } + + // Click increment: count=1 + flushSync(() => { + increment_btn?.click(); + }); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 1'); + } + + // Click increment again: count=2, components with count < 2 should unmount and log old values + flushSync(() => { + increment_btn?.click(); + }); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 2'); + } + + // Toggle show to hide components that depend on show + flushSync(() => { + toggle_btn?.click(); + }); + + // Should log old values during cleanup from the six components guarded by `count < 2`: + // 1. Component with bind: "1 true" + // 2. Component with spread: "1 true" + // 3. Component with normal props: "1 true" + // 4. Runes dynamic component with bind: "1 true" + // 5. Runes dynamic component with spread: "1 true" + // 6. Runes dynamic component with normal props: "1 true" + // Then from the four components guarded by `show`: + // 7. Component with bind (show): "2 true" (old value of checked) + // 8. Runes dynamic component with bind (show): "2 true" + // 9. Runes dynamic component with spread (show): "2 true" + // 10. Runes dynamic component with normal props (show): "2 true" + assert.deepEqual(logs, [ + '1 true', + '1 true', + '1 true', + '1 true', + '1 true', + '1 true', + '2 true', + '2 true', + '2 true', + '2 true' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/main.svelte b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/main.svelte new file mode 100644 index 000000000000..592c46ff429f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/main.svelte @@ -0,0 +1,52 @@ + + + + + + +{#if count < 2} + +{/if} + + +{#if count < 2} + +{/if} + + +{#if count < 2} + +{/if} + + +{#if show} + +{/if} + + + + + + + + + + + + + + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/Component.svelte b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/Component.svelte new file mode 100644 index 000000000000..cc47017946ee --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/Component.svelte @@ -0,0 +1,14 @@ + + +

Count: {count}

+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/_config.js b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/_config.js new file mode 100644 index 000000000000..07ea5af153a6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/_config.js @@ -0,0 +1,63 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, logs, target }) { + /** @type {HTMLButtonElement | null} */ + const increment_btn = target.querySelector('#increment'); + /** @type {HTMLButtonElement | null} */ + const toggle_btn = target.querySelector('#toggle'); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 0'); + } + + // Click increment: count=1 + flushSync(() => { + increment_btn?.click(); + }); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 1'); + } + + // Click increment again: count=2, components with count < 2 should unmount and log old values + flushSync(() => { + increment_btn?.click(); + }); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 2'); + } + + // Toggle show to hide components that depend on show + flushSync(() => { + toggle_btn?.click(); + }); + + // Should log old values during cleanup from the six components guarded by `count < 2`: + // 1. Component with bind: "1 true" + // 2. Component with spread: "1 true" + // 3. Component with normal props: "1 true" + // 4. Runes dynamic component with bind: "1 true" + // 5. Runes dynamic component with spread: "1 true" + // 6. Runes dynamic component with normal props: "1 true" + // Then from the four components guarded by `show`: + // 7. Component with bind (show): "2 true" (old value of checked) + // 8. Runes dynamic component with bind (show): "2 true" + // 9. Runes dynamic component with spread (show): "2 true" + // 10. Runes dynamic component with normal props (show): "2 true" + assert.deepEqual(logs, [ + '1 true', + '1 true', + '1 true', + '1 true', + '1 true', + '1 true', + '2 true', + '2 true', + '2 true', + '2 true' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/main.svelte b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/main.svelte new file mode 100644 index 000000000000..592c46ff429f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/main.svelte @@ -0,0 +1,52 @@ + + + + + + +{#if count < 2} + +{/if} + + +{#if count < 2} + +{/if} + + +{#if count < 2} + +{/if} + + +{#if show} + +{/if} + + + + + + + + + + + + + + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/Component.svelte b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/Component.svelte new file mode 100644 index 000000000000..1a95f1edf55c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/Component.svelte @@ -0,0 +1,11 @@ + + +

Count: {count}

diff --git a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/_config.js b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/_config.js new file mode 100644 index 000000000000..3d73bfd1a90c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/_config.js @@ -0,0 +1,49 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, logs, target }) { + /** @type {HTMLButtonElement | null} */ + const increment_btn = target.querySelector('#increment'); + /** @type {HTMLButtonElement | null} */ + const toggle_btn = target.querySelector('#toggle'); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 0'); + } + + // Click increment: count=1 + flushSync(() => { + increment_btn?.click(); + }); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 1'); + } + + // Click increment again: count=2, components with count < 2 should unmount and log old values + flushSync(() => { + increment_btn?.click(); + }); + + for (const p of target.querySelectorAll('p')) { + assert.equal(p.innerHTML, 'Count: 2'); + } + + // Toggle show to hide components that depend on show + flushSync(() => { + toggle_btn?.click(); + }); + + // Should log old values during cleanup from the four components guarded by `count < 2`: + // 1. Component with normal props: "1 true" + // 2. Component with spread: "1 true" + // 3. Runes dynamic component with normal props: "1 true" + // 4. Runes dynamic component with spread: "1 true" + // Then from the three components guarded by `show`: + // 5. Component with normal props (show): "2 true" (old value of checked) + // 6. Runes dynamic component with normal props (show): "2 true" + // 7. Runes dynamic component with spread (show): "2 true" + assert.deepEqual(logs, ['1 true', '1 true', '1 true', '1 true', '2 true', '2 true', '2 true']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/main.svelte new file mode 100644 index 000000000000..8bc6abfa4d6b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/main.svelte @@ -0,0 +1,41 @@ + + + + + + +{#if count < 2} + +{/if} + + +{#if count < 2} + +{/if} + + +{#if show} + +{/if} + + + + + + + + + + + + From 5ed74b24bad2272f94169178db357f52d3e30d5d Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 29 Jun 2025 16:47:33 +0100 Subject: [PATCH 04/25] Add changeset --- .changeset/light-rivers-jump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/light-rivers-jump.md diff --git a/.changeset/light-rivers-jump.md b/.changeset/light-rivers-jump.md new file mode 100644 index 000000000000..9258401e8a86 --- /dev/null +++ b/.changeset/light-rivers-jump.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +Store deriveds old value before updating them for consistency with directly assigned sources when reading in teardown functions From b213de2caf344daa7d377fd1a5ee723565635804 Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 29 Jun 2025 17:34:55 +0100 Subject: [PATCH 05/25] Update comment --- .../svelte/src/internal/client/reactivity/deriveds.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index ee2bf3ed2f81..5e4e08cc6537 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -185,8 +185,12 @@ export function update_derived(derived) { // that also happens before user_effect teardown. // // store old value only if not inside a teardown function - // because in legacy mode, bindable props are deriveds - // and they are executed during teardown. + // because we only need to save the old values before + // the cleanup is triggered othewise accessing + // a derived during cleanup will return the incorrect + // value in case the derived wasn't in the deps of the effect, + // or the teardown was executed because the component was + // destroyed. if (!is_destroying_effect) { var old_value = derived.v; old_values.set(derived, old_value); From 562c9e3c7beb1912b31cca8e513306b7a33a0f03 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 19:57:45 -0400 Subject: [PATCH 06/25] remove extraneous tests - these pass with or without the src change --- .../Component.svelte | 11 ---- .../_config.js | 63 ------------------- .../main.svelte | 52 --------------- .../Component.svelte | 14 ----- .../_config.js | 63 ------------------- .../main.svelte | 52 --------------- .../Component.svelte | 11 ---- .../_config.js | 49 --------------- .../main.svelte | 41 ------------ 9 files changed, 356 deletions(-) delete mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/Component.svelte delete mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/_config.js delete mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/main.svelte delete mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/Component.svelte delete mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/_config.js delete mode 100644 packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/main.svelte delete mode 100644 packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/Component.svelte delete mode 100644 packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/_config.js delete mode 100644 packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/Component.svelte b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/Component.svelte deleted file mode 100644 index 40d6b1c2fbb4..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/Component.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - -

Count: {count}

diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/_config.js b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/_config.js deleted file mode 100644 index 07ea5af153a6..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/_config.js +++ /dev/null @@ -1,63 +0,0 @@ -import { flushSync } from 'svelte'; -import { test } from '../../test'; - -export default test({ - async test({ assert, logs, target }) { - /** @type {HTMLButtonElement | null} */ - const increment_btn = target.querySelector('#increment'); - /** @type {HTMLButtonElement | null} */ - const toggle_btn = target.querySelector('#toggle'); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 0'); - } - - // Click increment: count=1 - flushSync(() => { - increment_btn?.click(); - }); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 1'); - } - - // Click increment again: count=2, components with count < 2 should unmount and log old values - flushSync(() => { - increment_btn?.click(); - }); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 2'); - } - - // Toggle show to hide components that depend on show - flushSync(() => { - toggle_btn?.click(); - }); - - // Should log old values during cleanup from the six components guarded by `count < 2`: - // 1. Component with bind: "1 true" - // 2. Component with spread: "1 true" - // 3. Component with normal props: "1 true" - // 4. Runes dynamic component with bind: "1 true" - // 5. Runes dynamic component with spread: "1 true" - // 6. Runes dynamic component with normal props: "1 true" - // Then from the four components guarded by `show`: - // 7. Component with bind (show): "2 true" (old value of checked) - // 8. Runes dynamic component with bind (show): "2 true" - // 9. Runes dynamic component with spread (show): "2 true" - // 10. Runes dynamic component with normal props (show): "2 true" - assert.deepEqual(logs, [ - '1 true', - '1 true', - '1 true', - '1 true', - '1 true', - '1 true', - '2 true', - '2 true', - '2 true', - '2 true' - ]); - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/main.svelte b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/main.svelte deleted file mode 100644 index 592c46ff429f..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-no-setter/main.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - -{#if count < 2} - -{/if} - - -{#if count < 2} - -{/if} - - -{#if count < 2} - -{/if} - - -{#if show} - -{/if} - - - - - - - - - - - - - - - - - - diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/Component.svelte b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/Component.svelte deleted file mode 100644 index cc47017946ee..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/Component.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -

Count: {count}

- - - diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/_config.js b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/_config.js deleted file mode 100644 index 07ea5af153a6..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/_config.js +++ /dev/null @@ -1,63 +0,0 @@ -import { flushSync } from 'svelte'; -import { test } from '../../test'; - -export default test({ - async test({ assert, logs, target }) { - /** @type {HTMLButtonElement | null} */ - const increment_btn = target.querySelector('#increment'); - /** @type {HTMLButtonElement | null} */ - const toggle_btn = target.querySelector('#toggle'); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 0'); - } - - // Click increment: count=1 - flushSync(() => { - increment_btn?.click(); - }); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 1'); - } - - // Click increment again: count=2, components with count < 2 should unmount and log old values - flushSync(() => { - increment_btn?.click(); - }); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 2'); - } - - // Toggle show to hide components that depend on show - flushSync(() => { - toggle_btn?.click(); - }); - - // Should log old values during cleanup from the six components guarded by `count < 2`: - // 1. Component with bind: "1 true" - // 2. Component with spread: "1 true" - // 3. Component with normal props: "1 true" - // 4. Runes dynamic component with bind: "1 true" - // 5. Runes dynamic component with spread: "1 true" - // 6. Runes dynamic component with normal props: "1 true" - // Then from the four components guarded by `show`: - // 7. Component with bind (show): "2 true" (old value of checked) - // 8. Runes dynamic component with bind (show): "2 true" - // 9. Runes dynamic component with spread (show): "2 true" - // 10. Runes dynamic component with normal props (show): "2 true" - assert.deepEqual(logs, [ - '1 true', - '1 true', - '1 true', - '1 true', - '1 true', - '1 true', - '2 true', - '2 true', - '2 true', - '2 true' - ]); - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/main.svelte b/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/main.svelte deleted file mode 100644 index 592c46ff429f..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/bindable-props-cleanup-with-setter/main.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - -{#if count < 2} - -{/if} - - -{#if count < 2} - -{/if} - - -{#if count < 2} - -{/if} - - -{#if show} - -{/if} - - - - - - - - - - - - - - - - - - diff --git a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/Component.svelte b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/Component.svelte deleted file mode 100644 index 1a95f1edf55c..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/Component.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - -

Count: {count}

diff --git a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/_config.js b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/_config.js deleted file mode 100644 index 3d73bfd1a90c..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/_config.js +++ /dev/null @@ -1,49 +0,0 @@ -import { flushSync } from 'svelte'; -import { test } from '../../test'; - -export default test({ - async test({ assert, logs, target }) { - /** @type {HTMLButtonElement | null} */ - const increment_btn = target.querySelector('#increment'); - /** @type {HTMLButtonElement | null} */ - const toggle_btn = target.querySelector('#toggle'); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 0'); - } - - // Click increment: count=1 - flushSync(() => { - increment_btn?.click(); - }); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 1'); - } - - // Click increment again: count=2, components with count < 2 should unmount and log old values - flushSync(() => { - increment_btn?.click(); - }); - - for (const p of target.querySelectorAll('p')) { - assert.equal(p.innerHTML, 'Count: 2'); - } - - // Toggle show to hide components that depend on show - flushSync(() => { - toggle_btn?.click(); - }); - - // Should log old values during cleanup from the four components guarded by `count < 2`: - // 1. Component with normal props: "1 true" - // 2. Component with spread: "1 true" - // 3. Runes dynamic component with normal props: "1 true" - // 4. Runes dynamic component with spread: "1 true" - // Then from the three components guarded by `show`: - // 5. Component with normal props (show): "2 true" (old value of checked) - // 6. Runes dynamic component with normal props (show): "2 true" - // 7. Runes dynamic component with spread (show): "2 true" - assert.deepEqual(logs, ['1 true', '1 true', '1 true', '1 true', '2 true', '2 true', '2 true']); - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/main.svelte deleted file mode 100644 index 8bc6abfa4d6b..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/regular-props-cleanup-old-value/main.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - -{#if count < 2} - -{/if} - - -{#if count < 2} - -{/if} - - -{#if show} - -{/if} - - - - - - - - - - - - From 85268bc147341dce4ea60cb2cb08be403efad30c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 19:59:46 -0400 Subject: [PATCH 07/25] revert --- .../internal/client/reactivity/deriveds.js | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 5e4e08cc6537..e9cea0df3e64 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -15,7 +15,7 @@ import { import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; import { destroy_effect } from './effects.js'; -import { inspect_effects, old_values, set_inspect_effects } from './sources.js'; +import { inspect_effects, set_inspect_effects } from './sources.js'; import { get_stack } from '../dev/tracing.js'; import { tracing_mode_flag } from '../../flags/index.js'; import { component_context } from '../context.js'; @@ -175,27 +175,6 @@ export function update_derived(derived) { var value = execute_derived(derived); if (!derived.equals(value)) { - // store old value before updating - // so that user_effect teardown functions - // can access the previous value. - // this is needed because derived updates happen early during - // effects dependency resolution (before cleanup), - // unlike direct state/derived updates, and this - // can happen also during template_effect execution - // that also happens before user_effect teardown. - // - // store old value only if not inside a teardown function - // because we only need to save the old values before - // the cleanup is triggered othewise accessing - // a derived during cleanup will return the incorrect - // value in case the derived wasn't in the deps of the effect, - // or the teardown was executed because the component was - // destroyed. - if (!is_destroying_effect) { - var old_value = derived.v; - old_values.set(derived, old_value); - } - derived.v = value; derived.wv = increment_write_version(); } From 0ad95885f397953b263b7906660ce16535cfc263 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 20:41:25 -0400 Subject: [PATCH 08/25] WIP --- .../svelte/src/internal/client/runtime.js | 19 +++++++-- .../derived-cleanup-old-value/_config.js | 39 ++++++------------- .../derived-cleanup-old-value/main.svelte | 15 ++++--- 3 files changed, 35 insertions(+), 38 deletions(-) diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index d057bfdf0d9d..a8dc6904d82d 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -26,7 +26,7 @@ import { } from './constants.js'; import { flush_tasks } from './dom/task.js'; import { internal_set, old_values } from './reactivity/sources.js'; -import { destroy_derived_effects, update_derived } from './reactivity/deriveds.js'; +import { destroy_derived_effects, execute_derived, update_derived } from './reactivity/deriveds.js'; import * as e from './errors.js'; import { tracing_mode_flag } from '../flags/index.js'; @@ -764,7 +764,7 @@ export function get(signal) { } } - if (is_derived) { + if (is_derived && !is_destroying_effect) { derived = /** @type {Derived} */ (signal); if (check_dirtiness(derived)) { @@ -805,8 +805,19 @@ export function get(signal) { } } - if (is_destroying_effect && old_values.has(signal)) { - return old_values.get(signal); + if (is_destroying_effect) { + if (old_values.has(signal)) { + return old_values.get(signal); + } + + if (is_derived) { + derived = /** @type {Derived} */ (signal); + + var value = execute_derived(derived); + old_values.set(derived, value); + + return value; + } } return signal.v; diff --git a/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/_config.js b/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/_config.js index 4810ba6fb985..694dccdcf8a0 100644 --- a/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/_config.js @@ -3,37 +3,20 @@ import { test } from '../../test'; export default test({ async test({ assert, logs, target }) { - /** @type {HTMLButtonElement | null} */ - const increment_btn = target.querySelector('#increment'); - /** @type {HTMLButtonElement | null} */ - const overwrite_btn = target.querySelector('#overwrite'); + const [increment] = target.querySelectorAll('button'); - // Initial state: count=1, derived_value=1 + flushSync(() => increment.click()); + flushSync(() => increment.click()); + flushSync(() => increment.click()); - // Click to increment count: count=2, derived_value=4 - flushSync(() => { - increment_btn?.click(); - }); - - // Click to increment count: count=3, derived_value=9 - flushSync(() => { - increment_btn?.click(); - }); - - // Click to overwrite derived_value: count=3, derived_value=7 - flushSync(() => { - overwrite_btn?.click(); - }); - - // Should log old value during cleanup (4) and new value during setup (9) assert.deepEqual(logs, [ - '$effect: 1', - '$effect teardown: 1', - '$effect: 4', - '$effect teardown: 4', - '$effect: 9', - '$effect teardown: 9', - '$effect: 7' + 'count: 1', + 'squared: 1', + 'count: 2', + 'squared: 4', + 'count: 3', + 'squared: 9', + 'count: 4' ]); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/main.svelte index 6cef12b1e345..a4c58a8e991c 100644 --- a/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/derived-cleanup-old-value/main.svelte @@ -1,17 +1,20 @@ - - +

count: {count}

-

derived_value: {derived_value}

+ +{#if count % 2 === 0} +

squared: {squared}

+{/if} From 8a6d5ed34b3532062592bfb303e21aae3356dfcc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 21:23:25 -0400 Subject: [PATCH 09/25] simplify props --- .../src/internal/client/reactivity/props.js | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index f3111361c051..03f4938fbe2d 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -362,52 +362,30 @@ export function prop(props, key, flags, fallback) { // hard mode. this is where it gets ugly — the value in the child should // synchronize with the parent, but it should also be possible to temporarily // set the value to something else locally. - var from_child = false; - var was_from_child = false; // The derived returns the current value. The underlying mutable // source is written to from various places to persist this value. - var inner_current_value = mutable_source(prop_value); - var current_value = derived(() => { - var parent_value = getter(); - var child_value = get(inner_current_value); - - if (from_child) { - from_child = false; - was_from_child = true; - return child_value; - } - - was_from_child = false; - return (inner_current_value.v = parent_value); - }); + var current_value = (immutable ? derived : derived_safe_equal)(getter); // Ensure we eagerly capture the initial value if it's bindable if (bindable) { get(current_value); } - if (!immutable) current_value.equals = safe_equals; - return function (/** @type {any} */ value, /** @type {boolean} */ mutation) { // legacy nonsense — need to ensure the source is invalidated when necessary // also needed for when handling inspect logic so we can inspect the correct source signal if (captured_signals !== null) { - // set this so that we don't reset to the parent value if `d` - // is invalidated because of `invalidate_inner_signals` (rather - // than because the parent or child value changed) - from_child = was_from_child; // invoke getters so that signals are picked up by `invalidate_inner_signals` getter(); - get(inner_current_value); + get(current_value); } if (arguments.length > 0) { const new_value = mutation ? get(current_value) : runes && bindable ? proxy(value) : value; if (!current_value.equals(new_value)) { - from_child = true; - set(inner_current_value, new_value); + set(current_value, new_value); // To ensure the fallback value is consistent when used with proxies, we // update the local fallback_value, but only if the fallback is actively used if (fallback_used && fallback_value !== undefined) { From e0ebcc8b5d4368917d34b9062d1a30d62282f820 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 21:33:04 -0400 Subject: [PATCH 10/25] simplify --- .../svelte/src/internal/client/reactivity/props.js | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 03f4938fbe2d..1d27c2fb0f1a 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -373,35 +373,25 @@ export function prop(props, key, flags, fallback) { } return function (/** @type {any} */ value, /** @type {boolean} */ mutation) { - // legacy nonsense — need to ensure the source is invalidated when necessary - // also needed for when handling inspect logic so we can inspect the correct source signal - if (captured_signals !== null) { - // invoke getters so that signals are picked up by `invalidate_inner_signals` - getter(); - get(current_value); - } - if (arguments.length > 0) { const new_value = mutation ? get(current_value) : runes && bindable ? proxy(value) : value; if (!current_value.equals(new_value)) { set(current_value, new_value); + // To ensure the fallback value is consistent when used with proxies, we // update the local fallback_value, but only if the fallback is actively used if (fallback_used && fallback_value !== undefined) { fallback_value = new_value; } - if (has_destroyed_component_ctx(current_value)) { - return value; - } - untrack(() => get(current_value)); // force a synchronisation immediately } return value; } + // TODO is this still necessary post-#16263? if (has_destroyed_component_ctx(current_value)) { return current_value.v; } From 9506889f80358f9c52dc8e4ae3b8981b0f3b5534 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 21:41:54 -0400 Subject: [PATCH 11/25] tweak --- .../src/internal/client/reactivity/props.js | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 1d27c2fb0f1a..37e53b5a6dc1 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -277,11 +277,17 @@ export function prop(props, key, flags, fallback) { // or `createClassComponent(Component, props)` var is_entry_props = STATE_SYMBOL in props || LEGACY_PROPS in props; - var setter = - (bindable && - (get_descriptor(props, key)?.set ?? - (is_entry_props && key in props && ((v) => (props[key] = v))))) || - undefined; + /** @type {((v: V) => void) | undefined} */ + var setter; + + /** @type {() => V} */ + var getter; + + if (bindable) { + setter = + get_descriptor(props, key)?.set ?? + (is_entry_props && key in props ? (v) => (props[key] = v) : undefined); + } var fallback_value = /** @type {V} */ (fallback); var fallback_dirty = true; @@ -307,11 +313,9 @@ export function prop(props, key, flags, fallback) { } prop_value = get_fallback(); - if (setter) setter(prop_value); + setter?.(prop_value); } - /** @type {() => V} */ - var getter; if (runes) { getter = () => { var value = /** @type {V} */ (props[key]); From 2a16f17de717e6ad6859d2e35d8786f43762adb7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 21:48:35 -0400 Subject: [PATCH 12/25] reorder a bit --- .../src/internal/client/reactivity/props.js | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 37e53b5a6dc1..101e8f2dd242 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -264,18 +264,24 @@ export function prop(props, key, flags, fallback) { var runes = !legacy_mode_flag || (flags & PROPS_IS_RUNES) !== 0; var bindable = (flags & PROPS_IS_BINDABLE) !== 0; var lazy = (flags & PROPS_IS_LAZY_INITIAL) !== 0; - var is_store_sub = false; - var prop_value; - if (bindable) { - [prop_value, is_store_sub] = capture_store_binding(() => /** @type {V} */ (props[key])); - } else { - prop_value = /** @type {V} */ (props[key]); - } + var fallback_value = /** @type {V} */ (fallback); + var fallback_dirty = true; + var fallback_used = false; + + var get_fallback = () => { + fallback_used = true; - // Can be the case when someone does `mount(Component, props)` with `let props = $state({...})` - // or `createClassComponent(Component, props)` - var is_entry_props = STATE_SYMBOL in props || LEGACY_PROPS in props; + if (fallback_dirty) { + fallback_dirty = false; + + fallback_value = lazy + ? untrack(/** @type {() => V} */ (fallback)) + : /** @type {V} */ (fallback); + } + + return fallback_value; + }; /** @type {((v: V) => void) | undefined} */ var setter; @@ -284,36 +290,31 @@ export function prop(props, key, flags, fallback) { var getter; if (bindable) { + // Can be the case when someone does `mount(Component, props)` with `let props = $state({...})` + // or `createClassComponent(Component, props)` + var is_entry_props = STATE_SYMBOL in props || LEGACY_PROPS in props; + setter = get_descriptor(props, key)?.set ?? (is_entry_props && key in props ? (v) => (props[key] = v) : undefined); } - var fallback_value = /** @type {V} */ (fallback); - var fallback_dirty = true; - var fallback_used = false; + var initial_value; + var is_store_sub = false; - var get_fallback = () => { - fallback_used = true; - if (fallback_dirty) { - fallback_dirty = false; - if (lazy) { - fallback_value = untrack(/** @type {() => V} */ (fallback)); - } else { - fallback_value = /** @type {V} */ (fallback); - } - } + if (bindable) { + [initial_value, is_store_sub] = capture_store_binding(() => /** @type {V} */ (props[key])); + } else { + initial_value = /** @type {V} */ (props[key]); + } - return fallback_value; - }; + if (initial_value === undefined && fallback !== undefined) { + initial_value = get_fallback(); - if (prop_value === undefined && fallback !== undefined) { - if (setter && runes) { - e.props_invalid_value(key); + if (setter) { + if (runes) e.props_invalid_value(key); + setter(initial_value); } - - prop_value = get_fallback(); - setter?.(prop_value); } if (runes) { From b141f60a7f45770939011605115ec41516dfdb58 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 21:53:24 -0400 Subject: [PATCH 13/25] simplify --- packages/svelte/src/internal/client/constants.js | 2 -- .../src/internal/client/reactivity/props.js | 15 ++++----------- packages/svelte/src/internal/client/runtime.js | 13 +------------ 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index dd3d1b2df636..cd5e0d224444 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -15,8 +15,6 @@ export const DESTROYED = 1 << 14; export const EFFECT_RAN = 1 << 15; /** 'Transparent' effects do not create a transition boundary */ export const EFFECT_TRANSPARENT = 1 << 16; -/** Svelte 4 legacy mode props need to be handled with deriveds and be recognized elsewhere, hence the dedicated flag */ -export const LEGACY_DERIVED_PROP = 1 << 17; export const INSPECT_EFFECT = 1 << 18; export const HEAD_EFFECT = 1 << 19; export const EFFECT_HAS_DERIVED = 1 << 20; diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 101e8f2dd242..42a905f6682f 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -8,12 +8,11 @@ import { PROPS_IS_UPDATED } from '../../../constants.js'; import { get_descriptor, is_function } from '../../shared/utils.js'; -import { mutable_source, set, source, update } from './sources.js'; +import { set, source, update } from './sources.js'; import { derived, derived_safe_equal } from './deriveds.js'; -import { get, captured_signals, untrack } from '../runtime.js'; -import { safe_equals } from './equality.js'; +import { get, untrack } from '../runtime.js'; import * as e from '../errors.js'; -import { LEGACY_DERIVED_PROP, LEGACY_PROPS, STATE_SYMBOL } from '#client/constants'; +import { LEGACY_PROPS, STATE_SYMBOL } from '#client/constants'; import { proxy } from '../proxy.js'; import { capture_store_binding } from './store.js'; import { legacy_mode_flag } from '../../flags/index.js'; @@ -326,14 +325,8 @@ export function prop(props, key, flags, fallback) { return value; }; } else { - // Svelte 4 did not trigger updates when a primitive value was updated to the same value. - // Replicate that behavior through using a derived - var derived_getter = (immutable ? derived : derived_safe_equal)( - () => /** @type {V} */ (props[key]) - ); - derived_getter.f |= LEGACY_DERIVED_PROP; getter = () => { - var value = get(derived_getter); + var value = /** @type {V} */ (props[key]); if (value !== undefined) fallback_value = /** @type {V} */ (undefined); return value === undefined ? fallback_value : value; }; diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 3e70537d7cbb..5a798ba3e996 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -20,7 +20,6 @@ import { STATE_SYMBOL, BLOCK_EFFECT, ROOT_EFFECT, - LEGACY_DERIVED_PROP, DISCONNECTED, EFFECT_IS_UPDATING, STALE_REACTION @@ -863,17 +862,7 @@ export function invalidate_inner_signals(fn) { var captured = capture_signals(() => untrack(fn)); for (var signal of captured) { - // Go one level up because derived signals created as part of props in legacy mode - if ((signal.f & LEGACY_DERIVED_PROP) !== 0) { - for (const dep of /** @type {Derived} */ (signal).deps || []) { - if ((dep.f & DERIVED) === 0) { - // Use internal_set instead of set here and below to avoid mutation validation - internal_set(dep, dep.v); - } - } - } else { - internal_set(signal, signal.v); - } + internal_set(signal, signal.v); } } From ab1ea276259c83335bc30a4bc94e30393a21ca26 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 21:54:31 -0400 Subject: [PATCH 14/25] unused --- packages/svelte/src/internal/client/reactivity/props.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 42a905f6682f..919ad37f35fa 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -382,8 +382,6 @@ export function prop(props, key, flags, fallback) { if (fallback_used && fallback_value !== undefined) { fallback_value = new_value; } - - untrack(() => get(current_value)); // force a synchronisation immediately } return value; From bdd5897e1346992ff47c4e19596e79da1afa2651 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 21:58:58 -0400 Subject: [PATCH 15/25] more --- .../svelte/src/internal/client/reactivity/props.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 919ad37f35fa..f09883f5b4e7 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -374,14 +374,12 @@ export function prop(props, key, flags, fallback) { if (arguments.length > 0) { const new_value = mutation ? get(current_value) : runes && bindable ? proxy(value) : value; - if (!current_value.equals(new_value)) { - set(current_value, new_value); + set(current_value, new_value); - // To ensure the fallback value is consistent when used with proxies, we - // update the local fallback_value, but only if the fallback is actively used - if (fallback_used && fallback_value !== undefined) { - fallback_value = new_value; - } + // To ensure the fallback value is consistent when used with proxies, we + // update the local fallback_value, but only if the fallback is actively used + if (fallback_used && fallback_value !== undefined) { + fallback_value = new_value; } return value; From 125388655952f7f0d7bce3793c4a5ad9b57bfaa0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 22:08:23 -0400 Subject: [PATCH 16/25] more --- .../src/internal/client/reactivity/props.js | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index f09883f5b4e7..9420b8e94fef 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -285,9 +285,6 @@ export function prop(props, key, flags, fallback) { /** @type {((v: V) => void) | undefined} */ var setter; - /** @type {() => V} */ - var getter; - if (bindable) { // Can be the case when someone does `mount(Component, props)` with `let props = $state({...})` // or `createClassComponent(Component, props)` @@ -316,6 +313,9 @@ export function prop(props, key, flags, fallback) { } } + /** @type {() => V} */ + var getter; + if (runes) { getter = () => { var value = /** @type {V} */ (props[key]); @@ -332,15 +332,16 @@ export function prop(props, key, flags, fallback) { }; } - // easy mode — prop is never written to - if ((flags & PROPS_IS_UPDATED) === 0 && runes) { + // prop is never written to — we only need a getter + if (runes && (flags & PROPS_IS_UPDATED) === 0) { return getter; } - // intermediate mode — prop is written to, but the parent component had - // `bind:foo` which means we can just call `$$props.foo = value` directly + // prop is written to, but the parent component had `bind:foo` which + // means we can just call `$$props.foo = value` directly if (setter) { var legacy_parent = props.$$legacy; + return function (/** @type {any} */ value, /** @type {boolean} */ mutation) { if (arguments.length > 0) { // We don't want to notify if the value was mutated and the parent is in runes mode. @@ -350,31 +351,26 @@ export function prop(props, key, flags, fallback) { if (!runes || !mutation || legacy_parent || is_store_sub) { /** @type {Function} */ (setter)(mutation ? getter() : value); } + return value; - } else { - return getter(); } + + return getter(); }; } - // hard mode. this is where it gets ugly — the value in the child should - // synchronize with the parent, but it should also be possible to temporarily - // set the value to something else locally. + // prop is written to, but there's no binding, which means we + // create a derived that we can write to locally + var d = (immutable ? derived : derived_safe_equal)(getter); - // The derived returns the current value. The underlying mutable - // source is written to from various places to persist this value. - var current_value = (immutable ? derived : derived_safe_equal)(getter); - - // Ensure we eagerly capture the initial value if it's bindable - if (bindable) { - get(current_value); - } + // Capture the initial value if it's bindable + if (bindable) get(d); return function (/** @type {any} */ value, /** @type {boolean} */ mutation) { if (arguments.length > 0) { - const new_value = mutation ? get(current_value) : runes && bindable ? proxy(value) : value; + const new_value = mutation ? get(d) : runes && bindable ? proxy(value) : value; - set(current_value, new_value); + set(d, new_value); // To ensure the fallback value is consistent when used with proxies, we // update the local fallback_value, but only if the fallback is actively used @@ -386,10 +382,10 @@ export function prop(props, key, flags, fallback) { } // TODO is this still necessary post-#16263? - if (has_destroyed_component_ctx(current_value)) { - return current_value.v; + if (has_destroyed_component_ctx(d)) { + return d.v; } - return get(current_value); + return get(d); }; } From c9cf7e3044a01545acb603e2c42d00441a67ccd2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 22:10:12 -0400 Subject: [PATCH 17/25] tweak --- packages/svelte/src/internal/client/reactivity/props.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 9420b8e94fef..de64bd7806e1 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -259,7 +259,6 @@ function has_destroyed_component_ctx(current_value) { * @returns {(() => V | ((arg: V) => V) | ((arg: V, mutation: boolean) => V))} */ export function prop(props, key, flags, fallback) { - var immutable = (flags & PROPS_IS_IMMUTABLE) !== 0; var runes = !legacy_mode_flag || (flags & PROPS_IS_RUNES) !== 0; var bindable = (flags & PROPS_IS_BINDABLE) !== 0; var lazy = (flags & PROPS_IS_LAZY_INITIAL) !== 0; @@ -361,7 +360,7 @@ export function prop(props, key, flags, fallback) { // prop is written to, but there's no binding, which means we // create a derived that we can write to locally - var d = (immutable ? derived : derived_safe_equal)(getter); + var d = ((flags & PROPS_IS_IMMUTABLE) !== 0 ? derived : derived_safe_equal)(getter); // Capture the initial value if it's bindable if (bindable) get(d); From 73d66a8dbf83d45a617251e1fa599ace329b8ce2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 22:14:20 -0400 Subject: [PATCH 18/25] also appears to be unnecessary --- packages/svelte/src/internal/client/reactivity/props.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index de64bd7806e1..a7f9daf34c2c 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -265,11 +265,8 @@ export function prop(props, key, flags, fallback) { var fallback_value = /** @type {V} */ (fallback); var fallback_dirty = true; - var fallback_used = false; var get_fallback = () => { - fallback_used = true; - if (fallback_dirty) { fallback_dirty = false; @@ -320,7 +317,6 @@ export function prop(props, key, flags, fallback) { var value = /** @type {V} */ (props[key]); if (value === undefined) return get_fallback(); fallback_dirty = true; - fallback_used = false; return value; }; } else { @@ -371,9 +367,7 @@ export function prop(props, key, flags, fallback) { set(d, new_value); - // To ensure the fallback value is consistent when used with proxies, we - // update the local fallback_value, but only if the fallback is actively used - if (fallback_used && fallback_value !== undefined) { + if (fallback_value !== undefined) { fallback_value = new_value; } From dfbce4dd61aa86c556977a2e27c25a310d9b9fca Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 30 Jun 2025 22:16:12 -0400 Subject: [PATCH 19/25] changeset --- .changeset/new-trees-behave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/new-trees-behave.md diff --git a/.changeset/new-trees-behave.md b/.changeset/new-trees-behave.md new file mode 100644 index 000000000000..d5fab30f3ea2 --- /dev/null +++ b/.changeset/new-trees-behave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: simplify props From 884d4319d866bed78d52bf8694fd534f7ab26958 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Jul 2025 07:50:11 -0400 Subject: [PATCH 20/25] fix --- packages/svelte/src/internal/client/runtime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 4fa40bd7831b..c217ac272285 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -818,7 +818,7 @@ export function get(signal) { if (is_derived) { derived = /** @type {Derived} */ (signal); - var value = execute_derived(derived); + var value = (derived.f & CLEAN) !== 0 ? execute_derived(derived) : derived.v; old_values.set(derived, value); return value; From 809693c62a9a351d55ea14ad2d26637c69418cb8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Jul 2025 14:35:12 -0400 Subject: [PATCH 21/25] Update .changeset/light-rivers-jump.md --- .changeset/light-rivers-jump.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/light-rivers-jump.md b/.changeset/light-rivers-jump.md index 9258401e8a86..2454d5715602 100644 --- a/.changeset/light-rivers-jump.md +++ b/.changeset/light-rivers-jump.md @@ -2,4 +2,4 @@ 'svelte': patch --- -Store deriveds old value before updating them for consistency with directly assigned sources when reading in teardown functions +fix: re-evaluate derived props during teardown From f32818cfedef4f9b1744175027941e6aac46026b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Jul 2025 15:25:00 -0400 Subject: [PATCH 22/25] easier debugging --- packages/svelte/src/internal/client/reactivity/deriveds.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index d3123d24a1ca..37e8643e1618 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -19,6 +19,7 @@ import { inspect_effects, set_inspect_effects } from './sources.js'; import { get_stack } from '../dev/tracing.js'; import { tracing_mode_flag } from '../../flags/index.js'; import { component_context } from '../context.js'; +import { UNINITIALIZED } from '../../../constants.js'; /** * @template V @@ -51,7 +52,7 @@ export function derived(fn) { fn, reactions: null, rv: 0, - v: /** @type {V} */ (null), + v: /** @type {V} */ (UNINITIALIZED), wv: 0, parent: parent_derived ?? active_effect, ac: null From 4a898981e132af6f14e414853215cfa75e0664bd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Jul 2025 16:07:37 -0400 Subject: [PATCH 23/25] fix --- .../src/internal/client/reactivity/props.js | 4 ++- .../svelte/src/internal/client/runtime.js | 27 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index bd0cc314cf49..ee07a342a8df 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -1,3 +1,4 @@ +/** @import { ComponentContext } from '#client' */ /** @import { Derived, Source } from './types.js' */ import { DEV } from 'esm-env'; import { @@ -5,7 +6,8 @@ import { PROPS_IS_IMMUTABLE, PROPS_IS_LAZY_INITIAL, PROPS_IS_RUNES, - PROPS_IS_UPDATED + PROPS_IS_UPDATED, + UNINITIALIZED } from '../../../constants.js'; import { get_descriptor, is_function } from '../../shared/utils.js'; import { set, source, update } from './sources.js'; diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index c217ac272285..b9a7d9c8a436 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -40,6 +40,7 @@ import { } from './context.js'; import { handle_error, invoke_error_boundary } from './error-handling.js'; import { snapshot } from '../shared/clone.js'; +import { UNINITIALIZED } from '../../constants.js'; let is_flushing = false; @@ -818,7 +819,13 @@ export function get(signal) { if (is_derived) { derived = /** @type {Derived} */ (signal); - var value = (derived.f & CLEAN) !== 0 ? execute_derived(derived) : derived.v; + var value = derived.v; + + // if the derived is dirty, or depends on the values that just changed, re-execute + if ((derived.f & CLEAN) !== 0 || depends_on_old_values(derived)) { + value = execute_derived(derived); + } + old_values.set(derived, value); return value; @@ -828,6 +835,24 @@ export function get(signal) { return signal.v; } +/** @param {Derived} derived */ +function depends_on_old_values(derived) { + if (derived.v === UNINITIALIZED) return true; // we don't know, so assume the worst + if (derived.deps === null) return false; + + for (const dep of derived.deps) { + if (old_values.has(dep)) { + return true; + } + + if ((dep.f & DERIVED) !== 0 && depends_on_old_values(/** @type {Derived} */ (dep))) { + return true; + } + } + + return false; +} + /** * Like `get`, but checks for `undefined`. Used for `var` declarations because they can be accessed before being declared * @template V From 05636d309b7bc891affd8c4011982ed9d8167e63 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Jul 2025 22:15:40 -0400 Subject: [PATCH 24/25] WIP --- .../src/internal/client/reactivity/props.js | 15 ++++++++++++--- .../effect-teardown-derived/Component.svelte | 15 +++++++++++++++ .../samples/effect-teardown-derived/_config.js | 17 +++++++++++++++++ .../samples/effect-teardown-derived/main.svelte | 13 +++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index ee07a342a8df..33f0e67a6d1d 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -12,7 +12,7 @@ import { import { get_descriptor, is_function } from '../../shared/utils.js'; import { set, source, update } from './sources.js'; import { derived, derived_safe_equal } from './deriveds.js'; -import { get, untrack } from '../runtime.js'; +import { get, is_destroying_effect, untrack } from '../runtime.js'; import * as e from '../errors.js'; import { LEGACY_PROPS, STATE_SYMBOL } from '#client/constants'; import { proxy } from '../proxy.js'; @@ -366,7 +366,12 @@ export function prop(props, key, flags, fallback) { // prop is written to, but there's no binding, which means we // create a derived that we can write to locally - var d = ((flags & PROPS_IS_IMMUTABLE) !== 0 ? derived : derived_safe_equal)(getter); + var overridden = false; + + var d = ((flags & PROPS_IS_IMMUTABLE) !== 0 ? derived : derived_safe_equal)(() => { + overridden = false; + return getter(); + }); // Capture the initial value if it's bindable if (bindable) get(d); @@ -376,6 +381,7 @@ export function prop(props, key, flags, fallback) { const new_value = mutation ? get(d) : runes && bindable ? proxy(value) : value; set(d, new_value); + overridden = true; if (fallback_value !== undefined) { fallback_value = new_value; @@ -384,7 +390,10 @@ export function prop(props, key, flags, fallback) { return value; } - if (has_destroyed_component_ctx(d)) { + // special case — avoid recalculating the derived if + // we're in a teardown function and the prop + // was overridden locally + if (is_destroying_effect && overridden) { return d.v; } diff --git a/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/Component.svelte b/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/Component.svelte new file mode 100644 index 000000000000..3c863c7fc94c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/Component.svelte @@ -0,0 +1,15 @@ + + +

{count}

+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/_config.js new file mode 100644 index 000000000000..1f6e5d1f7f96 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/_config.js @@ -0,0 +1,17 @@ +import { test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + async test({ assert, target, logs }) { + const [increment, toggle] = target.querySelectorAll('button'); + + flushSync(() => toggle.click()); + assert.deepEqual(logs, [0, 'hello']); + + flushSync(() => toggle.click()); + flushSync(() => increment.click()); + flushSync(() => increment.click()); + + assert.deepEqual(logs, [0, 'hello', 1, 'hello']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/main.svelte new file mode 100644 index 000000000000..1a97d3e760d6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-teardown-derived/main.svelte @@ -0,0 +1,13 @@ + + + + + +{#if count < 2 && message === 'hello'} + +{/if} From c8ab9e610465efa6fa0b2b234c4bf4ceeeed7fd2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Jul 2025 22:39:25 -0400 Subject: [PATCH 25/25] fix --- .../src/internal/client/reactivity/props.js | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 33f0e67a6d1d..4771e786520f 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -1,5 +1,5 @@ /** @import { ComponentContext } from '#client' */ -/** @import { Derived, Source } from './types.js' */ +/** @import { Derived, Effect, Source } from './types.js' */ import { DEV } from 'esm-env'; import { PROPS_IS_BINDABLE, @@ -12,9 +12,15 @@ import { import { get_descriptor, is_function } from '../../shared/utils.js'; import { set, source, update } from './sources.js'; import { derived, derived_safe_equal } from './deriveds.js'; -import { get, is_destroying_effect, untrack } from '../runtime.js'; +import { + active_effect, + get, + is_destroying_effect, + set_active_effect, + untrack +} from '../runtime.js'; import * as e from '../errors.js'; -import { LEGACY_PROPS, STATE_SYMBOL } from '#client/constants'; +import { DESTROYED, LEGACY_PROPS, STATE_SYMBOL } from '#client/constants'; import { proxy } from '../proxy.js'; import { capture_store_binding } from './store.js'; import { legacy_mode_flag } from '../../flags/index.js'; @@ -94,7 +100,7 @@ export function rest_props(props, exclude, name) { /** * The proxy handler for legacy $$restProps and $$props - * @type {ProxyHandler<{ props: Record, exclude: Array, special: Record unknown>, version: Source }>}} + * @type {ProxyHandler<{ props: Record, exclude: Array, special: Record unknown>, version: Source, parent_effect: Effect }>}} */ const legacy_rest_props_handler = { get(target, key) { @@ -104,17 +110,25 @@ const legacy_rest_props_handler = { }, set(target, key, value) { if (!(key in target.special)) { - // Handle props that can temporarily get out of sync with the parent - /** @type {Record unknown>} */ - target.special[key] = prop( - { - get [key]() { - return target.props[key]; - } - }, - /** @type {string} */ (key), - PROPS_IS_UPDATED - ); + var previous_effect = active_effect; + + try { + set_active_effect(target.parent_effect); + + // Handle props that can temporarily get out of sync with the parent + /** @type {Record unknown>} */ + target.special[key] = prop( + { + get [key]() { + return target.props[key]; + } + }, + /** @type {string} */ (key), + PROPS_IS_UPDATED + ); + } finally { + set_active_effect(previous_effect); + } } target.special[key](value); @@ -153,7 +167,19 @@ const legacy_rest_props_handler = { * @returns {Record} */ export function legacy_rest_props(props, exclude) { - return new Proxy({ props, exclude, special: {}, version: source(0) }, legacy_rest_props_handler); + return new Proxy( + { + props, + exclude, + special: {}, + version: source(0), + // TODO this is only necessary because we need to track component + // destruction inside `prop`, because of `bind:this`, but it + // seems likely that we can simplify `bind:this` instead + parent_effect: /** @type {Effect} */ (active_effect) + }, + legacy_rest_props_handler + ); } /** @@ -376,6 +402,12 @@ export function prop(props, key, flags, fallback) { // Capture the initial value if it's bindable if (bindable) get(d); + var parent_effect = /** @type {Effect} */ (active_effect); + + if (!parent_effect) { + console.trace(); + } + return function (/** @type {any} */ value, /** @type {boolean} */ mutation) { if (arguments.length > 0) { const new_value = mutation ? get(d) : runes && bindable ? proxy(value) : value; @@ -390,10 +422,12 @@ export function prop(props, key, flags, fallback) { return value; } - // special case — avoid recalculating the derived if - // we're in a teardown function and the prop - // was overridden locally - if (is_destroying_effect && overridden) { + // special case — avoid recalculating the derived if we're in a + // teardown function and the prop was overridden locally, or the + // component was already destroyed (this latter part is necessary + // because `bind:this` can read props after the component has + // been destroyed. TODO simplify `bind:this` + if ((is_destroying_effect && overridden) || (parent_effect.f & DESTROYED) !== 0) { return d.v; }