Skip to content

Commit 2d378bb

Browse files
breaking: disallow binding to component exports in runes mode (#11238)
* breaking: disallow binding to component exports in runes mode Svelte 4 allowed you to have `export const foo = ..` in component A and then do `<A bind:foo />`. This is confusing because it's not clear whether the binding is for a property or an export, and we have to sanitize rest props from the export bindings. This PR therefore introduces a breaking change in runes mode: You cannot bind to these exports anymore. Instead use `<A bind:this={a} />` and then do `a.foo` - makes things easier to reason about. * Update sites/svelte-5-preview/src/routes/docs/content/03-appendix/02-breaking-changes.md Co-authored-by: Rich Harris <[email protected]> * tweak messages * fix tests * use component.name * oops --------- Co-authored-by: Rich Harris <[email protected]>
1 parent e3c8589 commit 2d378bb

File tree

12 files changed

+59
-60
lines changed

12 files changed

+59
-60
lines changed

.changeset/beige-seas-share.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+
breaking: disallow binding to component exports in runes mode

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,14 +255,24 @@ export function client_component(source, analysis, options) {
255255
);
256256

257257
if (analysis.runes && options.dev) {
258-
const bindable = analysis.exports.map(({ name, alias }) => b.literal(alias ?? name));
258+
const exports = analysis.exports.map(({ name, alias }) => b.literal(alias ?? name));
259+
/** @type {import('estree').Literal[]} */
260+
const bindable = [];
259261
for (const [name, binding] of properties) {
260262
if (binding.kind === 'bindable_prop') {
261263
bindable.push(b.literal(binding.prop_alias ?? name));
262264
}
263265
}
264266
instance.body.unshift(
265-
b.stmt(b.call('$.validate_prop_bindings', b.id('$$props'), b.array(bindable)))
267+
b.stmt(
268+
b.call(
269+
'$.validate_prop_bindings',
270+
b.id('$$props'),
271+
b.array(bindable),
272+
b.array(exports),
273+
b.id(`${analysis.name}`)
274+
)
275+
)
266276
);
267277
}
268278

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ export const javascript_visitors_runes = {
222222
if (rune === '$props') {
223223
assert.equal(declarator.id.type, 'ObjectPattern');
224224

225-
const seen = state.analysis.exports.map(({ name, alias }) => alias ?? name);
225+
/** @type {string[]} */
226+
const seen = [];
226227

227228
for (const property of declarator.id.properties) {
228229
if (property.type === 'Property') {

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

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -692,8 +692,7 @@ const javascript_visitors_runes = {
692692
}
693693

694694
if (rune === '$props') {
695-
// remove $bindable() from props declaration and handle rest props
696-
let uses_rest_props = false;
695+
// remove $bindable() from props declaration
697696
const id = walk(declarator.id, null, {
698697
AssignmentPattern(node) {
699698
if (
@@ -705,26 +704,9 @@ const javascript_visitors_runes = {
705704
: b.id('undefined');
706705
return b.assignment_pattern(node.left, right);
707706
}
708-
},
709-
RestElement(node, { path }) {
710-
if (path.at(-1) === declarator.id) {
711-
uses_rest_props = true;
712-
}
713707
}
714708
});
715-
716-
const exports = /** @type {import('../../types').ComponentAnalysis} */ (
717-
state.analysis
718-
).exports.map(({ name, alias }) => b.literal(alias ?? name));
719-
720-
declarations.push(
721-
b.declarator(
722-
id,
723-
uses_rest_props && exports.length > 0
724-
? b.call('$.rest_props', b.id('$$props'), b.array(exports))
725-
: b.id('$$props')
726-
)
727-
);
709+
declarations.push(b.declarator(id, b.id('$$props')));
728710
continue;
729711
}
730712

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,26 @@ export function loop_guard(timeout) {
8585
/**
8686
* @param {Record<string, any>} $$props
8787
* @param {string[]} bindable
88+
* @param {string[]} exports
89+
* @param {Function & { filename: string }} component
8890
*/
89-
export function validate_prop_bindings($$props, bindable) {
91+
export function validate_prop_bindings($$props, bindable, exports, component) {
9092
for (const key in $$props) {
91-
if (!bindable.includes(key)) {
92-
var setter = get_descriptor($$props, key)?.set;
93+
var setter = get_descriptor($$props, key)?.set;
94+
var name = component.name;
9395

94-
if (setter) {
96+
if (setter) {
97+
if (exports.includes(key)) {
9598
throw new Error(
96-
`Cannot use bind:${key} on this component because the property was not declared as bindable. ` +
97-
`To mark a property as bindable, use the $bindable() rune like this: \`let { ${key} = $bindable() } = $props()\``
99+
`Component ${component.filename} has an export named ${key} that a consumer component is trying to access using bind:${key}, which is disallowed. ` +
100+
`Instead, use bind:this (e.g. <${name} bind:this={component} />) ` +
101+
`and then access the property on the bound component instance (e.g. component.${key}).`
102+
);
103+
}
104+
if (!bindable.includes(key)) {
105+
throw new Error(
106+
`A component is binding to property ${key} of ${name}.svelte (i.e. <${name} bind:${key} />). This is disallowed because the property was not declared as bindable inside ${component.filename}. ` +
107+
`To mark a property as bindable, use the $bindable() rune in ${name}.svelte like this: \`let { ${key} = $bindable() } = $props()\``
98108
);
99109
}
100110
}

packages/svelte/tests/runtime-runes/samples/export-binding/Counter.svelte

Lines changed: 0 additions & 12 deletions
This file was deleted.

packages/svelte/tests/runtime-runes/samples/export-binding/_config.js

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,9 @@ import { test } from '../../test';
22

33
export default test({
44
compileOptions: {
5-
dev: true // to ensure we don't throw a false-positive "cannot bind to this" error
5+
dev: true // to ensure we we catch the error
66
},
7-
html: `0 0 <button>increment</button>`,
8-
9-
async test({ assert, target }) {
10-
const btn = target.querySelector('button');
11-
12-
btn?.click();
13-
await Promise.resolve();
14-
15-
assert.htmlEqual(target.innerHTML, `0 1 <button>increment</button>`);
16-
}
7+
error:
8+
'Component .../export-binding/counter/index.svelte has an export named increment that a consumer component is trying to access using bind:increment, which is disallowed. ' +
9+
'Instead, use bind:this (e.g. <Counter bind:this={component} />) and then access the property on the bound component instance (e.g. component.increment).'
1710
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
let count = $state(0);
3+
export function increment() {
4+
count++;
5+
}
6+
</script>
7+
8+
{count}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script>
2-
import Counter from './Counter.svelte';
2+
import Counter from './counter/index.svelte';
33
let increment;
44
</script>
55

66
<Counter bind:increment={increment} />
7-
<button onclick={increment}>increment</button>
7+
<button onclick={increment}>increment</button>

packages/svelte/tests/runtime-runes/samples/props-not-bindable-spread/_config.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export default test({
55
dev: true
66
},
77
error:
8-
'Cannot use bind:count on this component because the property was not declared as bindable. ' +
9-
'To mark a property as bindable, use the $bindable() rune like this: `let { count = $bindable() } = $props()`',
8+
'A component is binding to property count of Counter.svelte (i.e. <Counter bind:count />). This is disallowed because the property was ' +
9+
'not declared as bindable inside .../samples/props-not-bindable-spread/Counter.svelte. To mark a property as bindable, use the $bindable() rune ' +
10+
'in Counter.svelte like this: `let { count = $bindable() } = $props()`',
1011
html: `0`
1112
});

packages/svelte/tests/runtime-runes/samples/props-not-bindable/_config.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export default test({
55
dev: true
66
},
77
error:
8-
'Cannot use bind:count on this component because the property was not declared as bindable. ' +
9-
'To mark a property as bindable, use the $bindable() rune like this: `let { count = $bindable() } = $props()`',
8+
'A component is binding to property count of Counter.svelte (i.e. <Counter bind:count />). This is disallowed because the property was ' +
9+
'not declared as bindable inside .../samples/props-not-bindable/Counter.svelte. To mark a property as bindable, use the $bindable() rune ' +
10+
'in Counter.svelte like this: `let { count = $bindable() } = $props()`',
1011
html: `0`
1112
});

sites/svelte-5-preview/src/routes/docs/content/03-appendix/02-breaking-changes.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ Content inside component tags becomes a [snippet prop](/docs/snippets) called `c
121121

122122
Some breaking changes only apply once your component is in runes mode.
123123

124-
### Bindings to component exports don't show up in rest props
124+
### Bindings to component exports are not allowed
125125

126-
In runes mode, bindings to component exports don't show up in rest props. For example, `rest` in `let { foo, bar, ...rest } = $props();` would not contain `baz` if `baz` was defined as `export const baz = ...;` inside the component. In Svelte 4 syntax, the equivalent to `rest` would be `$$restProps`, which contains these component exports.
126+
Exports from runes mode components cannot be bound to directly. For example, having `export const foo = ...` in component `A` and then doing `<A bind:foo />` causes an error. Use `bind:this` instead — `<A bind:this={a} />` — and access the export as `a.foo`. This change makes things easier to reason about, as it enforces a clear separation between props and exports.
127127

128128
### Bindings need to be explicitly defined using `$bindable()`
129129

0 commit comments

Comments
 (0)