Skip to content

Commit afe8445

Browse files
fix: better binding interop between runes/non-runes components (#12123)
* Ensure binding from legacy component passed to runes component updates correctly. This is done by also using the `prop(..)` variant for a property if it's mutated in runes mode, and then figuring out at runtime whether or not the parent should be notified or not fixes #12032 * fix adjacent bug around wrong value getting return upon mutation * deduplicate * changeset * simplify * move comment * rename --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 6c66680 commit afe8445

File tree

15 files changed

+200
-24
lines changed

15 files changed

+200
-24
lines changed

.changeset/nervous-ducks-repeat.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: better binding interop between runes/non-runes components

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

+6-1
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,12 @@ export function client_component(source, analysis, options) {
363363
}
364364

365365
if (analysis.uses_props || analysis.uses_rest_props) {
366-
const to_remove = [b.literal('children'), b.literal('$$slots'), b.literal('$$events')];
366+
const to_remove = [
367+
b.literal('children'),
368+
b.literal('$$slots'),
369+
b.literal('$$events'),
370+
b.literal('$$legacy')
371+
];
367372
if (analysis.custom_element) {
368373
to_remove.push(b.literal('$$host'));
369374
}

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

+35-14
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export function serialize_get_binding(node, state) {
8989
}
9090

9191
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') {
92-
if (!state.analysis.runes || binding.reassigned || binding.initial) {
92+
if (is_prop_source(binding, state)) {
9393
return b.call(node);
9494
}
9595

@@ -391,18 +391,20 @@ export function serialize_set_binding(node, context, fallback, prefix, options)
391391
),
392392
b.call('$.untrack', b.id('$' + left_name))
393393
);
394-
} else if (!state.analysis.runes) {
394+
} else if (
395+
!state.analysis.runes ||
396+
// this condition can go away once legacy mode is gone; only necessary for interop with legacy parent bindings
397+
(binding.mutated && binding.kind === 'bindable_prop')
398+
) {
395399
if (binding.kind === 'bindable_prop') {
396400
return b.call(
397401
left,
398-
b.sequence([
399-
b.assignment(
400-
node.operator,
401-
/** @type {import('estree').Pattern} */ (visit(node.left)),
402-
value
403-
),
404-
b.call(left)
405-
])
402+
b.assignment(
403+
node.operator,
404+
/** @type {import('estree').Pattern} */ (visit(node.left)),
405+
value
406+
),
407+
b.true
406408
);
407409
} else {
408410
return b.call(
@@ -538,9 +540,7 @@ function get_hoistable_params(node, context) {
538540
} else if (
539541
// If we are referencing a simple $$props value, then we need to reference the object property instead
540542
(binding.kind === 'prop' || binding.kind === 'bindable_prop') &&
541-
!binding.reassigned &&
542-
binding.initial === null &&
543-
!context.state.analysis.accessors
543+
!is_prop_source(binding, context.state)
544544
) {
545545
push_unique(b.id('$$props'));
546546
} else {
@@ -602,7 +602,9 @@ export function get_prop_source(binding, state, name, initial) {
602602

603603
if (
604604
state.analysis.accessors ||
605-
(state.analysis.immutable ? binding.reassigned : binding.mutated)
605+
(state.analysis.immutable
606+
? binding.reassigned || (state.analysis.runes && binding.mutated)
607+
: binding.mutated)
606608
) {
607609
flags |= PROPS_IS_UPDATED;
608610
}
@@ -637,6 +639,25 @@ export function get_prop_source(binding, state, name, initial) {
637639
return b.call('$.prop', ...args);
638640
}
639641

642+
/**
643+
*
644+
* @param {import('#compiler').Binding} binding
645+
* @param {import('./types').ClientTransformState} state
646+
* @returns
647+
*/
648+
export function is_prop_source(binding, state) {
649+
return (
650+
(binding.kind === 'prop' || binding.kind === 'bindable_prop') &&
651+
(!state.analysis.runes ||
652+
state.analysis.accessors ||
653+
binding.reassigned ||
654+
binding.initial ||
655+
// Until legacy mode is gone, we also need to use the prop source when only mutated is true,
656+
// because the parent could be a legacy component which needs coarse-grained reactivity
657+
binding.mutated)
658+
);
659+
}
660+
640661
/**
641662
* @param {import('estree').Expression} node
642663
* @param {import("../../scope.js").Scope | null} scope

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

+7-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as b from '../../../../utils/builders.js';
44
import * as assert from '../../../../utils/assert.js';
55
import {
66
get_prop_source,
7+
is_prop_source,
78
is_state_source,
89
serialize_proxy_reassignment,
910
should_proxy_or_freeze
@@ -240,7 +241,11 @@ export const javascript_visitors_runes = {
240241
assert.equal(declarator.id.type, 'ObjectPattern');
241242

242243
/** @type {string[]} */
243-
const seen = [];
244+
const seen = ['$$slots', '$$events', '$$legacy'];
245+
246+
if (state.analysis.custom_element) {
247+
seen.push('$$host');
248+
}
244249

245250
for (const property of declarator.id.properties) {
246251
if (property.type === 'Property') {
@@ -264,7 +269,7 @@ export const javascript_visitors_runes = {
264269
initial = b.call('$.proxy', initial);
265270
}
266271

267-
if (binding.reassigned || state.analysis.accessors || initial) {
272+
if (is_prop_source(binding, state)) {
268273
declarations.push(b.declarator(id, get_prop_source(binding, state, name, initial)));
269274
}
270275
} else {

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

+4
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,10 @@ function serialize_inline_component(node, component_name, context) {
900900
push_prop(b.init('$$slots', b.object(serialized_slots)));
901901
}
902902

903+
if (!context.state.analysis.runes) {
904+
push_prop(b.init('$$legacy', b.true));
905+
}
906+
903907
const props_expression =
904908
props_and_spreads.length === 0 ||
905909
(props_and_spreads.length === 1 && Array.isArray(props_and_spreads[0]))

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

+15-6
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,16 @@ export function prop(props, key, flags, fallback) {
267267
// intermediate mode — prop is written to, but the parent component had
268268
// `bind:foo` which means we can just call `$$props.foo = value` directly
269269
if (setter) {
270-
return function (/** @type {V} */ value) {
271-
if (arguments.length === 1) {
272-
/** @type {Function} */ (setter)(value);
270+
var legacy_parent = props.$$legacy;
271+
return function (/** @type {any} */ value, /** @type {boolean} */ mutation) {
272+
if (arguments.length > 0) {
273+
// We don't want to notify if the value was mutated and the parent is in runes mode.
274+
// In that case the state proxy (if it exists) should take care of the notification.
275+
// If the parent is not in runes mode, we need to notify on mutation, too, that the prop
276+
// has changed because the parent will not be able to detect the change otherwise.
277+
if (!runes || !mutation || legacy_parent) {
278+
/** @type {Function} */ (setter)(mutation ? getter() : value);
279+
}
273280
return value;
274281
} else {
275282
return getter();
@@ -302,7 +309,7 @@ export function prop(props, key, flags, fallback) {
302309

303310
if (!immutable) current_value.equals = safe_equals;
304311

305-
return function (/** @type {V} */ value) {
312+
return function (/** @type {any} */ value, /** @type {boolean} */ mutation) {
306313
var current = get(current_value);
307314

308315
// legacy nonsense — need to ensure the source is invalidated when necessary
@@ -318,9 +325,11 @@ export function prop(props, key, flags, fallback) {
318325
}
319326

320327
if (arguments.length > 0) {
321-
if (!current_value.equals(value)) {
328+
const new_value = mutation ? get(current_value) : value;
329+
330+
if (!current_value.equals(new_value)) {
322331
from_child = true;
323-
set(inner_current_value, value);
332+
set(inner_current_value, new_value);
324333
get(current_value); // force a synchronisation immediately
325334
}
326335

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
mode: ['client'],
5+
test({ assert, logs }) {
6+
assert.deepEqual(logs, [true]);
7+
}
8+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<script>
2+
export let a = {};
3+
console.log((a.b = true));
4+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
let { object = $bindable(), primitive = $bindable() } = $props();
3+
</script>
4+
5+
{#if primitive}
6+
<button onclick={() => (primitive = 'bar')}>{primitive}</button>
7+
{:else}
8+
<button onclick={() => (object.value = 'bar')}>{object.value}</button>
9+
{/if}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
let { object = $bindable({}), primitive = $bindable('') } = $props();
3+
</script>
4+
5+
{#if primitive}
6+
<button onclick={() => (primitive = 'bar')}>{primitive}</button>
7+
{:else}
8+
<button onclick={() => (object.value = 'bar')}>{object.value}</button>
9+
{/if}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<svelte:options runes={false} />
2+
3+
<script>
4+
import Component1 from './Component1.svelte';
5+
import Component2 from './Component2.svelte';
6+
7+
let object1 = { value: 'foo' };
8+
let object2 = { value: 'foo' };
9+
10+
let primitive1 = 'foo';
11+
let primitive2 = 'foo';
12+
</script>
13+
14+
{object1.value}
15+
<Component1 bind:object={object1} />
16+
17+
{object2.value}
18+
<Component2 bind:object={object2} />
19+
20+
{primitive1}
21+
<Component1 bind:primitive={primitive1} />
22+
23+
{primitive2}
24+
<Component2 bind:primitive={primitive2} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script>
2+
import Component1 from './Component1.svelte';
3+
import Component2 from './Component2.svelte';
4+
5+
let object1 = $state({ value: 'foo' });
6+
let object2 = $state({ value: 'foo' });
7+
8+
class Frozen {
9+
constructor(value) {
10+
this.value = value;
11+
}
12+
}
13+
let object3 = $state(new Frozen('foo'));
14+
let object4 = $state(new Frozen('foo'));
15+
16+
let primitive1 = $state('foo');
17+
let primitive2 = $state('foo');
18+
</script>
19+
20+
{object1.value}
21+
<Component1 bind:object={object1} />
22+
23+
{object2.value}
24+
<Component2 bind:object={object2} />
25+
26+
<!-- force them into a different render effect so they don't coincidently update with the others -->
27+
{#if true}
28+
{object3.value}
29+
<Component1 bind:object={object3} />
30+
31+
{object4.value}
32+
<Component2 bind:object={object4} />
33+
{/if}
34+
35+
{primitive1}
36+
<Component1 bind:primitive={primitive1} />
37+
38+
{primitive2}
39+
<Component2 bind:primitive={primitive2} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
mode: ['client'],
6+
async test({ assert, target }) {
7+
const buttons = target.querySelectorAll('button');
8+
9+
for (const button of buttons) {
10+
await button.click();
11+
flushSync();
12+
}
13+
flushSync();
14+
15+
assert.htmlEqual(
16+
target.innerHTML,
17+
`
18+
bar <button>bar</button> bar <button>bar</button> bar <button>bar</button> bar <button>bar</button>
19+
<hr>
20+
bar <button>bar</button> bar <button>bar</button> foo <button>foo</button> foo <button>foo</button> bar <button>bar</button> bar <button>bar</button>
21+
`
22+
);
23+
}
24+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<script>
2+
import Legacy from './Legacy.svelte';
3+
import Runes from './Runes.svelte';
4+
</script>
5+
6+
<Legacy />
7+
8+
<hr />
9+
10+
<Runes />

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ export default function Bind_this($$anchor) {
55
var fragment = $.comment();
66
var node = $.first_child(fragment);
77

8-
$.bind_this(Foo(node, {}), ($$value) => foo = $$value, () => foo);
8+
$.bind_this(Foo(node, { $$legacy: true }), ($$value) => foo = $$value, () => foo);
99
$.append($$anchor, fragment);
1010
}

0 commit comments

Comments
 (0)