Skip to content

Commit be36c93

Browse files
authored
fix: better bind:this cleanup timing (#17885)
This removes the `queue_micro_task`-workaround we employed in `bind:this` in favor of a search for the nearest component effect / effect that is still getting destroyed, whichever comes first. We used `queue_micro_task` mainly due to timing issues with components wanting to access the bound property on teardown still, and when nulling it out on cleanup of the bind-this-effect itself, that was too early. The microtask is too late though in some cases, when accessing properties of objects that are no longer there. The targeted upwards-walk solves this while keeping the binding around as long as needed. For that I had to add a new `DESTROYING` flag. We _could_ have done it without one and by deleting code in `props.js` where we don't do `get(d)` when the prop derived is destroyed, but I wanted to keep that because you could still run into an access error if you e.g. access the property in a timeout. Alternative to #17862
1 parent d2b3470 commit be36c93

File tree

12 files changed

+113
-12
lines changed

12 files changed

+113
-12
lines changed

.changeset/vast-moles-burn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: better `bind:this` cleanup timing

packages/svelte/src/internal/client/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const INERT = 1 << 13;
2929
export const DESTROYED = 1 << 14;
3030
/** Set once a reaction has run for the first time */
3131
export const REACTION_RAN = 1 << 15;
32+
/** Effect is in the process of getting destroyed. Can be observed in child teardown functions */
33+
export const DESTROYING = 1 << 25;
3234

3335
// Flags exclusive to effects
3436
/**

packages/svelte/src/internal/client/context.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { active_effect, active_reaction } from './runtime.js';
55
import { create_user_effect } from './reactivity/effects.js';
66
import { async_mode_flag, legacy_mode_flag } from '../flags/index.js';
77
import { FILENAME } from '../../constants.js';
8-
import { BRANCH_EFFECT, REACTION_RAN } from './constants.js';
8+
import { BRANCH_EFFECT } from './constants.js';
99

1010
/** @type {ComponentContext | null} */
1111
export let component_context = null;
@@ -182,6 +182,7 @@ export function push(props, runes = false, fn) {
182182
e: null,
183183
s: props,
184184
x: null,
185+
r: /** @type {Effect} */ (active_effect),
185186
l: legacy_mode_flag && !runes ? { s: null, u: null, $: [] } : null
186187
};
187188

packages/svelte/src/internal/client/dom/elements/bindings/this.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { STATE_SYMBOL } from '#client/constants';
1+
/** @import { ComponentContext, Effect } from '#client' */
2+
import { DESTROYING, STATE_SYMBOL } from '#client/constants';
3+
import { component_context } from '../../../context.js';
24
import { effect, render_effect } from '../../../reactivity/effects.js';
3-
import { untrack } from '../../../runtime.js';
4-
import { queue_micro_task } from '../../task.js';
5+
import { active_effect, untrack } from '../../../runtime.js';
56

67
/**
78
* @param {any} bound_value
@@ -23,6 +24,9 @@ function is_bound_this(bound_value, element_or_component) {
2324
* @returns {void}
2425
*/
2526
export function bind_this(element_or_component = {}, update, get_value, get_parts) {
27+
var component_effect = /** @type {ComponentContext} */ (component_context).r;
28+
var parent = /** @type {Effect} */ (active_effect);
29+
2630
effect(() => {
2731
/** @type {unknown[]} */
2832
var old_parts;
@@ -48,12 +52,25 @@ export function bind_this(element_or_component = {}, update, get_value, get_part
4852
});
4953

5054
return () => {
51-
// We cannot use effects in the teardown phase, we we use a microtask instead.
52-
queue_micro_task(() => {
55+
// When the bind:this effect is destroyed, we go up the effect parent chain until we find the last parent effect that is destroyed,
56+
// or the effect containing the component bind:this is in (whichever comes first). That way we can time the nulling of the binding
57+
// as close to user/developer expectation as possible.
58+
// TODO Svelte 6: Decide if we want to keep this logic or just always null the binding in the component effect's teardown
59+
// (which would be simpler, but less intuitive in some cases, and breaks the `ondestroy-before-cleanup` test)
60+
let p = parent;
61+
while (p !== component_effect && p.parent !== null && p.parent.f & DESTROYING) {
62+
p = p.parent;
63+
}
64+
const teardown = () => {
5365
if (parts && is_bound_this(get_value(...parts), element_or_component)) {
5466
update(null, ...parts);
5567
}
56-
});
68+
};
69+
const original_teardown = p.teardown;
70+
p.teardown = () => {
71+
teardown();
72+
original_teardown?.();
73+
};
5774
};
5875
});
5976

packages/svelte/src/internal/client/reactivity/effects.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ import {
3434
USER_EFFECT,
3535
ASYNC,
3636
CONNECTED,
37-
MANAGED_EFFECT
37+
MANAGED_EFFECT,
38+
DESTROYING
3839
} from '#client/constants';
3940
import * as e from '../errors.js';
4041
import { DEV } from 'esm-env';
@@ -520,9 +521,9 @@ export function destroy_effect(effect, remove_dom = true) {
520521
removed = true;
521522
}
522523

524+
set_signal_status(effect, DESTROYING);
523525
destroy_effect_children(effect, remove_dom && !removed);
524526
remove_reactions(effect, 0);
525-
set_signal_status(effect, DESTROYED);
526527

527528
var transitions = effect.nodes && effect.nodes.t;
528529

@@ -534,6 +535,9 @@ export function destroy_effect(effect, remove_dom = true) {
534535

535536
execute_effect_teardown(effect);
536537

538+
effect.f ^= DESTROYING;
539+
effect.f |= DESTROYED;
540+
537541
var parent = effect.parent;
538542

539543
// If the parent doesn't have any children, then skip this work altogether

packages/svelte/src/internal/client/reactivity/props.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,9 +419,7 @@ export function prop(props, key, flags, fallback) {
419419

420420
// special case — avoid recalculating the derived if we're in a
421421
// teardown function and the prop was overridden locally, or the
422-
// component was already destroyed (this latter part is necessary
423-
// because `bind:this` can read props after the component has
424-
// been destroyed. TODO simplify `bind:this`
422+
// component was already destroyed (people could access props in a timeout)
425423
if ((is_destroying_effect && overridden) || (parent_effect.f & DESTROYED) !== 0) {
426424
return d.v;
427425
}

packages/svelte/src/internal/client/types.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export type ComponentContext = {
3838
* @deprecated remove in 6.0
3939
*/
4040
x: Record<string, any> | null;
41+
/**
42+
* The parent effect of this component
43+
* TODO 6.0 this is used to control `bind:this` timing that might change,
44+
* in which case we can remove this property
45+
*/
46+
r: Effect;
4147
/**
4248
* legacy stuff
4349
* @deprecated remove in 6.0
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
let { data } = $props();
3+
4+
const processed = $derived(data.toUpperCase());
5+
6+
export function getProcessed() {
7+
return processed;
8+
}
9+
</script>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
async test({ assert, target }) {
6+
const [btn] = target.querySelectorAll('button');
7+
8+
btn.click();
9+
await tick();
10+
assert.htmlEqual(
11+
target.innerHTML,
12+
`
13+
<button>clear</button>
14+
<p></p>
15+
`
16+
);
17+
}
18+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script>
2+
import Inner from './Inner.svelte';
3+
4+
let value = $state('hello');
5+
6+
let innerComp = $state();
7+
8+
// Reads Inner's derived value from outside the {#if} block, keeping it
9+
// connected in the reactive graph even after the branch is destroyed.
10+
const externalView = $derived(innerComp?.getProcessed() ?? '');
11+
</script>
12+
13+
{#if value}
14+
{@const result = value}
15+
<Inner data={result} bind:this={innerComp} />
16+
{/if}
17+
18+
<button onclick={() => (value = undefined)}>clear</button>
19+
<p>{externalView}</p>

0 commit comments

Comments
 (0)