Skip to content

fix: re-evaluate derived props during teardown #16278

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 30 commits into from
Jul 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
42f5d10
Store deriveds old value before updating them for consistency with di…
raythurnvoid Jun 29, 2025
1fc9f08
Add tests for derived old values in teardown functions
raythurnvoid Jun 29, 2025
9d9b64b
Add tests for props old values in onDestroy hooks to prevent regressions
raythurnvoid Jun 29, 2025
5ed74b2
Add changeset
raythurnvoid Jun 29, 2025
b213de2
Update comment
raythurnvoid Jun 29, 2025
562c9e3
remove extraneous tests - these pass with or without the src change
Rich-Harris Jun 30, 2025
85268bc
revert
Rich-Harris Jun 30, 2025
0ad9588
WIP
Rich-Harris Jul 1, 2025
8a6d5ed
simplify props
Rich-Harris Jul 1, 2025
e0ebcc8
simplify
Rich-Harris Jul 1, 2025
9506889
tweak
Rich-Harris Jul 1, 2025
2a16f17
reorder a bit
Rich-Harris Jul 1, 2025
b141f60
simplify
Rich-Harris Jul 1, 2025
ab1ea27
unused
Rich-Harris Jul 1, 2025
bdd5897
more
Rich-Harris Jul 1, 2025
1253886
more
Rich-Harris Jul 1, 2025
c9cf7e3
tweak
Rich-Harris Jul 1, 2025
73d66a8
also appears to be unnecessary
Rich-Harris Jul 1, 2025
dfbce4d
changeset
Rich-Harris Jul 1, 2025
29cfec1
Merge branch 'simpler-props' into gh-16263
Rich-Harris Jul 1, 2025
884d431
fix
Rich-Harris Jul 1, 2025
5083231
merge main
Rich-Harris Jul 1, 2025
809693c
Update .changeset/light-rivers-jump.md
Rich-Harris Jul 1, 2025
f32818c
easier debugging
Rich-Harris Jul 1, 2025
4a89898
fix
Rich-Harris Jul 1, 2025
79e7203
Merge branch 'gh-16263' of github.com:sveltejs/svelte into gh-16263
Rich-Harris Jul 1, 2025
05636d3
WIP
Rich-Harris Jul 2, 2025
c8ab9e6
fix
Rich-Harris Jul 2, 2025
99f0464
merge
Rich-Harris Jul 2, 2025
cff7ed6
merge main
Rich-Harris Jul 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-rivers-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: re-evaluate derived props during teardown
3 changes: 2 additions & 1 deletion packages/svelte/src/internal/client/reactivity/deriveds.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
80 changes: 60 additions & 20 deletions packages/svelte/src/internal/client/reactivity/props.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
/** @import { Derived, Source } from './types.js' */
/** @import { ComponentContext } from '#client' */
/** @import { Derived, Effect, Source } from './types.js' */
import { DEV } from 'esm-env';
import {
PROPS_IS_BINDABLE,
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';
import { derived, derived_safe_equal } from './deriveds.js';
import { get, 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';
Expand Down Expand Up @@ -92,7 +100,7 @@ export function rest_props(props, exclude, name) {

/**
* The proxy handler for legacy $$restProps and $$props
* @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Array<string | symbol>, special: Record<string | symbol, (v?: unknown) => unknown>, version: Source<number> }>}}
* @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Array<string | symbol>, special: Record<string | symbol, (v?: unknown) => unknown>, version: Source<number>, parent_effect: Effect }>}}
*/
const legacy_rest_props_handler = {
get(target, key) {
Expand All @@ -102,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<string, (v?: unknown) => 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<string, (v?: unknown) => 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);
Expand Down Expand Up @@ -151,7 +167,19 @@ const legacy_rest_props_handler = {
* @returns {Record<string, unknown>}
*/
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
);
}

/**
Expand Down Expand Up @@ -366,16 +394,24 @@ export function prop(props, key, flags, fallback) {
// create a derived that we can write to locally.
// Or we are in legacy mode where we always create a derived to replicate that
// Svelte 4 did not trigger updates when a primitive value was updated to the same value.
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);

var parent_effect = /** @type {Effect} */ (active_effect);

return function (/** @type {any} */ value, /** @type {boolean} */ mutation) {
if (arguments.length > 0) {
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;
Expand All @@ -384,8 +420,12 @@ export function prop(props, key, flags, fallback) {
return value;
}

// TODO is this still necessary post-#16263?
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, 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;
}

Expand Down
44 changes: 40 additions & 4 deletions packages/svelte/src/internal/client/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -41,6 +41,7 @@ import {
set_dev_stack
} from './context.js';
import { handle_error, invoke_error_boundary } from './error-handling.js';
import { UNINITIALIZED } from '../../constants.js';

let is_flushing = false;

Expand Down Expand Up @@ -774,7 +775,7 @@ export function get(signal) {
}
}

if (is_derived) {
if (is_derived && !is_destroying_effect) {
derived = /** @type {Derived} */ (signal);

if (check_dirtiness(derived)) {
Expand Down Expand Up @@ -815,13 +816,48 @@ 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 = 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;
}
}

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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { flushSync } from 'svelte';
import { test } from '../../test';

export default test({
async test({ assert, logs, target }) {
const [increment] = target.querySelectorAll('button');

flushSync(() => increment.click());
flushSync(() => increment.click());
flushSync(() => increment.click());

assert.deepEqual(logs, [
'count: 1',
'squared: 1',
'count: 2',
'squared: 4',
'count: 3',
'squared: 9',
'count: 4'
]);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script>
let count = $state(1);
let squared = $derived(count * count);
$effect(() => {
console.log(`count: ${count}`);
return () => {
console.log(`squared: ${squared}`);
};
});
</script>

<button onclick={() => count++}>increment</button>

<p>count: {count}</p>

{#if count % 2 === 0}
<p id="squared">squared: {squared}</p>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script>
let { message, count } = $props();
$effect(() => () => {
console.log(count, message);
});
</script>

<p>{count}</p>

<!-- we need these so that the props are made into deriveds -->
<button disabled onclick={() => {
count += 1;
message += '!';
}}>update</button>
Original file line number Diff line number Diff line change
@@ -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']);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script>
import Component from "./Component.svelte";
let message = $state('hello');
let count = $state(0);
</script>

<button onclick={() => count++}>{count}</button>
<button onclick={() => message = message === 'hello' ? 'goodbye' : 'hello'}>{message}</button>

{#if count < 2 && message === 'hello'}
<Component {count} {message} />
{/if}