Skip to content

Commit a9e15bd

Browse files
authored
breaking: robustify interop of exports and props (#11064)
- don't throw a dev time error when binding to an export (fixes #11008) - remove bindings that are for component exports - throw an error when using a component export with the same name as a property
1 parent 4527494 commit a9e15bd

File tree

12 files changed

+104
-8
lines changed

12 files changed

+104
-8
lines changed

.changeset/heavy-ducks-leave.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: robustify interop of exports and props in runes mode

packages/svelte/src/compiler/errors.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,9 @@ const runes = {
212212
'duplicate-props-rune': () => `Cannot use $props() more than once`,
213213
'invalid-each-assignment': () =>
214214
`Cannot reassign or bind to each block argument in runes mode. Use the array and index variables instead (e.g. 'array[i] = value' instead of 'entry = value')`,
215-
'invalid-derived-call': () => `$derived.call(...) has been replaced with $derived.by(...)`
215+
'invalid-derived-call': () => `$derived.call(...) has been replaced with $derived.by(...)`,
216+
'conflicting-property-name': () =>
217+
`Cannot have a property and a component export with the same name`
216218
};
217219

218220
/** @satisfies {Errors} */

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,20 @@ export function analyze_component(root, source, options) {
437437
merge(set_scope(scopes), validation_runes, runes_scope_tweaker, common_visitors)
438438
);
439439
}
440+
441+
if (analysis.exports.length > 0) {
442+
for (const [_, binding] of instance.scope.declarations) {
443+
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') {
444+
if (
445+
analysis.exports.some(
446+
({ alias, name }) => (binding.prop_alias ?? binding.node.name) === (alias ?? name)
447+
)
448+
) {
449+
error(binding.node, 'conflicting-property-name');
450+
}
451+
}
452+
}
453+
}
440454
} else {
441455
instance.scope.declare(b.id('$$props'), 'bindable_prop', 'synthetic');
442456
instance.scope.declare(b.id('$$restProps'), 'rest_prop', 'synthetic');

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,7 @@ export function client_component(source, analysis, options) {
255255
);
256256

257257
if (analysis.runes && options.dev) {
258-
/** @type {import('estree').Literal[]} */
259-
const bindable = [];
258+
const bindable = analysis.exports.map(({ name, alias }) => b.literal(alias ?? name));
260259
for (const [name, binding] of properties) {
261260
if (binding.kind === 'bindable_prop') {
262261
bindable.push(b.literal(binding.prop_alias ?? name));
@@ -382,7 +381,6 @@ export function client_component(source, analysis, options) {
382381
);
383382

384383
if (analysis.uses_rest_props) {
385-
/** @type {string[]} */
386384
const named_props = analysis.exports.map(({ name, alias }) => alias ?? name);
387385
for (const [name, binding] of analysis.instance.scope.declarations) {
388386
if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name);

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,7 @@ export const javascript_visitors_runes = {
195195
if (rune === '$props') {
196196
assert.equal(declarator.id.type, 'ObjectPattern');
197197

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

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

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

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

693693
if (rune === '$props') {
694-
// remove $bindable() from props declaration
694+
// remove $bindable() from props declaration and handle rest props
695+
let uses_rest_props = false;
695696
const id = walk(declarator.id, null, {
696697
AssignmentPattern(node) {
697698
if (
@@ -703,9 +704,26 @@ const javascript_visitors_runes = {
703704
: b.id('undefined');
704705
return b.assignment_pattern(node.left, right);
705706
}
707+
},
708+
RestElement(node, { path }) {
709+
if (path.at(-1) === declarator.id) {
710+
uses_rest_props = true;
711+
}
706712
}
707713
});
708-
declarations.push(b.declarator(id, b.id('$$props')));
714+
715+
const exports = /** @type {import('../../types').ComponentAnalysis} */ (
716+
state.analysis
717+
).exports.map(({ name, alias }) => b.literal(alias ?? name));
718+
719+
declarations.push(
720+
b.declarator(
721+
id,
722+
uses_rest_props && exports.length > 0
723+
? b.call('$.rest_props', b.id('$$props'), b.array(exports))
724+
: b.id('$$props')
725+
)
726+
);
709727
continue;
710728
}
711729

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
error: {
5+
code: 'conflicting-property-name',
6+
message: 'Cannot have a property and a component export with the same name'
7+
}
8+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<script>
2+
let { x: y } = $props();
3+
export function x() {}
4+
</script>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<script>
2+
let { ...rest } = $props();
3+
let count = $state(0);
4+
export function increment() {
5+
count++;
6+
}
7+
</script>
8+
9+
<!-- test that the binding isn't inside rest -->
10+
{Object.keys(rest).length}
11+
12+
{count}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
compileOptions: {
5+
dev: true // to ensure we don't throw a false-positive "cannot bind to this" error
6+
},
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+
}
17+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script>
2+
import Counter from './Counter.svelte';
3+
let increment;
4+
</script>
5+
6+
<Counter bind:increment={increment} />
7+
<button onclick={increment}>increment</button>

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ Svelte now use Mutation Observers instead of IFrames to measure dimensions for `
117117

118118
Content inside component tags becomes a [snippet prop](/docs/snippets) called `children`. You cannot have a separate prop by that name.
119119

120+
## Breaking changes in runes mode
121+
122+
Some breaking changes only apply once your component is in runes mode.
123+
124+
### Bindings to component exports don't show up in rest props
125+
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.
127+
128+
### Bindings need to be explicitly defined using `$bindable()`
129+
130+
In Svelte 4 syntax, every property (declared via `export let`) is bindable, meaning you can `bind:` to it. In runes mode, properties are not bindable by default: you need to denote bindable props with the [`$bindable`](/docs/runes#$bindable) rune.
131+
120132
## Other breaking changes
121133

122134
### Stricter `@const` assignment validation

0 commit comments

Comments
 (0)