Skip to content

Commit a2164ff

Browse files
authored
fix: make bind_this implementation more robust (#10598)
The previous `bind_this` implementation had flaws around timing and didn't work correctly when the binding wasn't `$state` in runes mode. This PR reimplements `bind_this` by aligning it more with how Svelte 4 bindings work, namely reacting to each block context variables updates properly and introducing a mechanism to get the previous binding destination using said variables.
1 parent d2b11ec commit a2164ff

File tree

10 files changed

+202
-83
lines changed

10 files changed

+202
-83
lines changed

.changeset/moody-sheep-type.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: make `bind_this` implementation more robust

packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { regex_is_valid_identifier } from '../../../patterns.js';
3636
import { javascript_visitors_runes } from './javascript-runes.js';
3737
import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js';
38+
import { walk } from 'zimmerframe';
3839

3940
/**
4041
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element
@@ -978,24 +979,11 @@ function serialize_inline_component(node, component_name, context) {
978979

979980
if (bind_this !== null) {
980981
const prev = fn;
981-
const assignment = b.assignment('=', bind_this, b.id('$$value'));
982-
const bind_this_id = /** @type {import('estree').Expression} */ (
983-
// if expression is not an identifier, we know it can't be a signal
984-
bind_this.type === 'Identifier'
985-
? bind_this
986-
: bind_this.type === 'MemberExpression' && bind_this.object.type === 'Identifier'
987-
? bind_this.object
988-
: undefined
989-
);
990982
fn = (node_id) =>
991-
b.call(
992-
'$.bind_this',
993-
prev(node_id),
994-
b.arrow(
995-
[b.id('$$value')],
996-
serialize_set_binding(assignment, context, () => context.visit(assignment))
997-
),
998-
bind_this_id
983+
serialize_bind_this(
984+
/** @type {import('estree').Identifier | import('estree').MemberExpression} */ (bind_this),
985+
context,
986+
prev(node_id)
999987
);
1000988
}
1001989

@@ -1022,6 +1010,66 @@ function serialize_inline_component(node, component_name, context) {
10221010
return statements.length > 1 ? b.block(statements) : statements[0];
10231011
}
10241012

1013+
/**
1014+
* Serializes `bind:this` for components and elements.
1015+
* @param {import('estree').Identifier | import('estree').MemberExpression} bind_this
1016+
* @param {import('zimmerframe').Context<import('#compiler').SvelteNode, import('../types.js').ComponentClientTransformState>} context
1017+
* @param {import('estree').Expression} node
1018+
* @returns
1019+
*/
1020+
function serialize_bind_this(bind_this, context, node) {
1021+
let i = 0;
1022+
/** @type {Map<import('#compiler').Binding, [arg_idx: number, transformed: import('estree').Expression, expression: import('#compiler').Binding['expression']]>} */
1023+
const each_ids = new Map();
1024+
// Transform each reference to an each block context variable into a $$value_<i> variable
1025+
// by temporarily changing the `expression` of the corresponding binding.
1026+
// These $$value_<i> variables will be filled in by the bind_this runtime function through its last argument.
1027+
// Note that we only do this for each context variables, the consequence is that the value might be stale in
1028+
// some scenarios where the value is a member expression with changing computed parts or using a combination of multiple
1029+
// variables, but that was the same case in Svelte 4, too. Once legacy mode is gone completely, we can revisit this.
1030+
walk(
1031+
bind_this,
1032+
{},
1033+
{
1034+
Identifier(node) {
1035+
const binding = context.state.scope.get(node.name);
1036+
if (!binding || each_ids.has(binding)) return;
1037+
1038+
const associated_node = Array.from(context.state.scopes.entries()).find(
1039+
([_, scope]) => scope === binding?.scope
1040+
)?.[0];
1041+
if (associated_node?.type === 'EachBlock') {
1042+
each_ids.set(binding, [
1043+
i,
1044+
/** @type {import('estree').Expression} */ (context.visit(node)),
1045+
binding.expression
1046+
]);
1047+
binding.expression = b.id('$$value_' + i);
1048+
i++;
1049+
}
1050+
}
1051+
}
1052+
);
1053+
1054+
const bind_this_id = /** @type {import('estree').Expression} */ (context.visit(bind_this));
1055+
const ids = Array.from(each_ids.values()).map((id) => b.id('$$value_' + id[0]));
1056+
const assignment = b.assignment('=', bind_this, b.id('$$value'));
1057+
const update = serialize_set_binding(assignment, context, () => context.visit(assignment));
1058+
1059+
for (const [binding, [, , expression]] of each_ids) {
1060+
// reset expressions to what they were before
1061+
binding.expression = expression;
1062+
}
1063+
1064+
/** @type {import('estree').Expression[]} */
1065+
const args = [node, b.arrow([b.id('$$value'), ...ids], update), b.arrow([...ids], bind_this_id)];
1066+
if (each_ids.size) {
1067+
args.push(b.thunk(b.array(Array.from(each_ids.values()).map((id) => id[1]))));
1068+
}
1069+
1070+
return b.call('$.bind_this', ...args);
1071+
}
1072+
10251073
/**
10261074
* Creates a new block which looks roughly like this:
10271075
* ```js
@@ -2749,19 +2797,7 @@ export const template_visitors = {
27492797
}
27502798

27512799
case 'this':
2752-
call_expr = b.call(
2753-
`$.bind_this`,
2754-
state.node,
2755-
setter,
2756-
/** @type {import('estree').Expression} */ (
2757-
// if expression is not an identifier, we know it can't be a signal
2758-
expression.type === 'Identifier'
2759-
? expression
2760-
: expression.type === 'MemberExpression' && expression.object.type === 'Identifier'
2761-
? expression.object
2762-
: undefined
2763-
)
2764-
);
2800+
call_expr = serialize_bind_this(node.expression, context, state.node);
27652801
break;
27662802
case 'textContent':
27672803
case 'innerHTML':

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

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import {
3636
} from './reconciler.js';
3737
import {
3838
destroy_signal,
39-
is_signal,
4039
push_destroy_fn,
4140
execute_effect,
4241
untrack,
@@ -79,7 +78,6 @@ import { run } from '../common.js';
7978
import { bind_transition, trigger_transitions } from './transitions.js';
8079
import { mutable_source, source } from './reactivity/sources.js';
8180
import { safe_equal, safe_not_equal } from './reactivity/equality.js';
82-
import { STATE_SYMBOL } from './constants.js';
8381

8482
/** @type {Set<string>} */
8583
const all_registerd_events = new Set();
@@ -1321,39 +1319,48 @@ export function bind_prop(props, prop, value) {
13211319
}
13221320
}
13231321

1324-
/**
1325-
* @param {unknown} value
1326-
*/
1327-
function is_state_object(value) {
1328-
return value != null && typeof value === 'object' && STATE_SYMBOL in value;
1329-
}
1330-
13311322
/**
13321323
* @param {Element} element_or_component
1333-
* @param {(value: unknown) => void} update
1334-
* @param {import('./types.js').MaybeSignal} binding
1324+
* @param {(value: unknown, ...parts: unknown[]) => void} update
1325+
* @param {(...parts: unknown[]) => unknown} get_value
1326+
* @param {() => unknown[]} [get_parts] Set if the this binding is used inside an each block,
1327+
* returns all the parts of the each block context that are used in the expression
13351328
* @returns {void}
13361329
*/
1337-
export function bind_this(element_or_component, update, binding) {
1338-
render_effect(() => {
1339-
// If we are reading from a proxied state binding, then we don't need to untrack
1340-
// the update function as it will be fine-grain.
1341-
if (is_state_object(binding) || (is_signal(binding) && is_state_object(binding.v))) {
1342-
update(element_or_component);
1343-
} else {
1344-
untrack(() => update(element_or_component));
1345-
}
1346-
return () => {
1347-
// Defer to the next tick so that all updates can be reconciled first.
1348-
// This solves the case where one variable is shared across multiple this-bindings.
1349-
render_effect(() => {
1350-
untrack(() => {
1351-
if (!is_signal(binding) || binding.v === element_or_component) {
1352-
update(null);
1353-
}
1354-
});
1355-
});
1356-
};
1330+
export function bind_this(element_or_component, update, get_value, get_parts) {
1331+
/** @type {unknown[]} */
1332+
let old_parts;
1333+
/** @type {unknown[]} */
1334+
let parts;
1335+
1336+
const e = effect(() => {
1337+
old_parts = parts;
1338+
// We only track changes to the parts, not the value itself to avoid unnecessary reruns.
1339+
parts = get_parts?.() || [];
1340+
1341+
untrack(() => {
1342+
if (element_or_component !== get_value(...parts)) {
1343+
update(element_or_component, ...parts);
1344+
// If this is an effect rerun (cause: each block context changes), then nullfiy the binding at
1345+
// the previous position if it isn't already taken over by a different effect.
1346+
if (old_parts && get_value(...old_parts) === element_or_component) {
1347+
update(null, ...old_parts);
1348+
}
1349+
}
1350+
});
1351+
});
1352+
1353+
// Add effect teardown (likely causes: if block became false, each item removed, component unmounted).
1354+
// In these cases we need to nullify the binding only if we detect that the value is still the same.
1355+
// If not, that means that another effect has now taken over the binding.
1356+
push_destroy_fn(e, () => {
1357+
// Defer to the next tick so that all updates can be reconciled first.
1358+
// This solves the case where one variable is shared across multiple this-bindings.
1359+
effect(() => {
1360+
if (get_value(...parts) === element_or_component) {
1361+
update(null, ...parts);
1362+
}
1363+
});
13571364
});
13581365
}
13591366

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ export function execute_effect(signal) {
434434

435435
function infinite_loop_guard() {
436436
if (flush_count > 100) {
437+
flush_count = 0;
437438
throw new Error(
438439
'ERR_SVELTE_TOO_MANY_UPDATES' +
439440
(DEV

packages/svelte/tests/runtime-legacy/samples/await-then-catch-event/_config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { tick } from 'svelte';
12
import { test } from '../../test';
23
import { create_deferred } from '../../../helpers.js';
34

@@ -22,7 +23,8 @@ export default test({
2223
deferred.resolve(42);
2324

2425
return deferred.promise
25-
.then(() => {
26+
.then(async () => {
27+
await tick();
2628
assert.htmlEqual(target.innerHTML, '<button>click me</button>');
2729

2830
const { button } = component;

packages/svelte/tests/runtime-legacy/samples/binding-this-with-context/_config.js

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,8 @@ export default test({
4646
component.items = ['foo', 'baz'];
4747
assert.equal(component.divs.length, 3, 'the divs array is still 3 long');
4848
assert.equal(component.divs[2], null, 'the last div is unregistered');
49-
// Different from Svelte 3
50-
assert.equal(component.ps[1], null, 'the second p is unregistered');
51-
// Different from Svelte 3
52-
assert.equal(component.spans['-baz2'], null, 'the baz span is unregistered');
49+
assert.equal(component.ps[2], null, 'the last p is unregistered');
50+
assert.equal(component.spans['-bar1'], null, 'the bar span is unregistered');
5351
assert.equal(component.hrs.bar, null, 'the bar hr is unregistered');
5452

5553
divs = target.querySelectorAll('div');
@@ -58,22 +56,17 @@ export default test({
5856
assert.equal(e, divs[i], `div ${i} is still correct`);
5957
});
6058

61-
// TODO: unsure if need these two tests to pass, as the logic between Svelte 3
62-
// and Svelte 5 is different for these cases.
63-
64-
// elems = target.querySelectorAll('span');
65-
// component.items.forEach((e, i) => {
66-
// assert.equal(
67-
// component.spans[`-${e}${i}`],
68-
// elems[i],
69-
// `span -${e}${i} is still correct`,
70-
// );
71-
// });
59+
spans = target.querySelectorAll('span');
60+
// @ts-ignore
61+
component.items.forEach((e, i) => {
62+
assert.equal(component.spans[`-${e}${i}`], spans[i], `span -${e}${i} is still correct`);
63+
});
7264

73-
// elems = target.querySelectorAll('p');
74-
// component.ps.forEach((e, i) => {
75-
// assert.equal(e, elems[i], `p ${i} is still correct`);
76-
// });
65+
ps = target.querySelectorAll('p');
66+
// @ts-ignore
67+
component.ps.forEach((e, i) => {
68+
assert.equal(e, ps[i], `p ${i} is still correct`);
69+
});
7770

7871
hrs = target.querySelectorAll('hr');
7972
// @ts-ignore

packages/svelte/tests/runtime-legacy/samples/transition-js-if-block-outro-timeout/_config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { flushSync } from 'svelte';
12
import { test } from '../../test';
23

34
export default test({
@@ -15,7 +16,8 @@ export default test({
1516
assert.equal(window.getComputedStyle(div).opacity, '0');
1617

1718
raf.tick(600);
18-
assert.equal(component.div, undefined);
1919
assert.equal(target.querySelector('div'), undefined);
20+
flushSync();
21+
assert.equal(component.div, undefined);
2022
}
2123
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
/** @param {number | null} selected */
5+
function get_html(selected) {
6+
return `
7+
<button>1</button>
8+
<button>2</button>
9+
<button>3</button>
10+
11+
<hr></hr>
12+
13+
${selected !== null ? `<div>${selected}</div>` : ''}
14+
15+
<hr></hr>
16+
17+
<p>${selected ?? '...'}</p>
18+
`;
19+
}
20+
21+
export default test({
22+
html: get_html(null),
23+
24+
async test({ assert, target }) {
25+
const [btn1, btn2, btn3] = target.querySelectorAll('button');
26+
27+
await btn1?.click();
28+
await tick();
29+
assert.htmlEqual(target.innerHTML, get_html(1));
30+
31+
await btn2?.click();
32+
await tick();
33+
assert.htmlEqual(target.innerHTML, get_html(2));
34+
35+
await btn1?.click();
36+
await tick();
37+
assert.htmlEqual(target.innerHTML, get_html(1));
38+
39+
await btn3?.click();
40+
await tick();
41+
assert.htmlEqual(target.innerHTML, get_html(3));
42+
}
43+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script>
2+
import { tick } from 'svelte';
3+
4+
let selected = $state(-1);
5+
let current = $state();
6+
7+
let div; // explicitly no $state
8+
</script>
9+
10+
{#each [1, 2, 3] as n, i}
11+
<button
12+
onclick={async () => {
13+
selected = i;
14+
await tick();
15+
current = div?.textContent;
16+
}}
17+
>{n}</button>
18+
{/each}
19+
20+
<hr />
21+
22+
{#each [1, 2, 3] as n, i}
23+
{#if selected === i}
24+
<div bind:this={div}>{n}</div>
25+
{/if}
26+
{/each}
27+
28+
<hr />
29+
30+
<p>{current ?? '...'}</p>

packages/svelte/tests/snapshot/samples/bind-this/_expected/client/index.svelte.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default function Bind_this($$anchor, $$props) {
1111
var fragment = $.comment($$anchor);
1212
var node = $.child_frag(fragment);
1313

14-
$.bind_this(Foo(node, {}), ($$value) => foo = $$value, foo);
14+
$.bind_this(Foo(node, {}), ($$value) => foo = $$value, () => foo);
1515
$.close_frag($$anchor, fragment);
1616
$.pop();
1717
}

0 commit comments

Comments
 (0)