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
pull/11072/head
Simon H 3 months ago committed by GitHub
parent 452749494e
commit a9e15bdf2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
breaking: robustify interop of exports and props in runes mode

@ -212,7 +212,9 @@ const runes = {
'duplicate-props-rune': () => `Cannot use $props() more than once`,
'invalid-each-assignment': () =>
`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')`,
'invalid-derived-call': () => `$derived.call(...) has been replaced with $derived.by(...)`
'invalid-derived-call': () => `$derived.call(...) has been replaced with $derived.by(...)`,
'conflicting-property-name': () =>
`Cannot have a property and a component export with the same name`
};
/** @satisfies {Errors} */

@ -437,6 +437,20 @@ export function analyze_component(root, source, options) {
merge(set_scope(scopes), validation_runes, runes_scope_tweaker, common_visitors)
);
}
if (analysis.exports.length > 0) {
for (const [_, binding] of instance.scope.declarations) {
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') {
if (
analysis.exports.some(
({ alias, name }) => (binding.prop_alias ?? binding.node.name) === (alias ?? name)
)
) {
error(binding.node, 'conflicting-property-name');
}
}
}
}
} else {
instance.scope.declare(b.id('$$props'), 'bindable_prop', 'synthetic');
instance.scope.declare(b.id('$$restProps'), 'rest_prop', 'synthetic');

@ -255,8 +255,7 @@ export function client_component(source, analysis, options) {
);
if (analysis.runes && options.dev) {
/** @type {import('estree').Literal[]} */
const bindable = [];
const bindable = analysis.exports.map(({ name, alias }) => b.literal(alias ?? name));
for (const [name, binding] of properties) {
if (binding.kind === 'bindable_prop') {
bindable.push(b.literal(binding.prop_alias ?? name));
@ -382,7 +381,6 @@ export function client_component(source, analysis, options) {
);
if (analysis.uses_rest_props) {
/** @type {string[]} */
const named_props = analysis.exports.map(({ name, alias }) => alias ?? name);
for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name);

@ -195,8 +195,7 @@ export const javascript_visitors_runes = {
if (rune === '$props') {
assert.equal(declarator.id.type, 'ObjectPattern');
/** @type {string[]} */
const seen = [];
const seen = state.analysis.exports.map(({ name, alias }) => alias ?? name);
for (const property of declarator.id.properties) {
if (property.type === 'Property') {

@ -691,7 +691,8 @@ const javascript_visitors_runes = {
}
if (rune === '$props') {
// remove $bindable() from props declaration
// remove $bindable() from props declaration and handle rest props
let uses_rest_props = false;
const id = walk(declarator.id, null, {
AssignmentPattern(node) {
if (
@ -703,9 +704,26 @@ const javascript_visitors_runes = {
: b.id('undefined');
return b.assignment_pattern(node.left, right);
}
},
RestElement(node, { path }) {
if (path.at(-1) === declarator.id) {
uses_rest_props = true;
}
}
});
declarations.push(b.declarator(id, b.id('$$props')));
const exports = /** @type {import('../../types').ComponentAnalysis} */ (
state.analysis
).exports.map(({ name, alias }) => b.literal(alias ?? name));
declarations.push(
b.declarator(
id,
uses_rest_props && exports.length > 0
? b.call('$.rest_props', b.id('$$props'), b.array(exports))
: b.id('$$props')
)
);
continue;
}

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
error: {
code: 'conflicting-property-name',
message: 'Cannot have a property and a component export with the same name'
}
});

@ -0,0 +1,4 @@
<script>
let { x: y } = $props();
export function x() {}
</script>

@ -0,0 +1,12 @@
<script>
let { ...rest } = $props();
let count = $state(0);
export function increment() {
count++;
}
</script>
<!-- test that the binding isn't inside rest -->
{Object.keys(rest).length}
{count}

@ -0,0 +1,17 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true // to ensure we don't throw a false-positive "cannot bind to this" error
},
html: `0 0 <button>increment</button>`,
async test({ assert, target }) {
const btn = target.querySelector('button');
btn?.click();
await Promise.resolve();
assert.htmlEqual(target.innerHTML, `0 1 <button>increment</button>`);
}
});

@ -0,0 +1,7 @@
<script>
import Counter from './Counter.svelte';
let increment;
</script>
<Counter bind:increment={increment} />
<button onclick={increment}>increment</button>

@ -117,6 +117,18 @@ Svelte now use Mutation Observers instead of IFrames to measure dimensions for `
Content inside component tags becomes a [snippet prop](/docs/snippets) called `children`. You cannot have a separate prop by that name.
## Breaking changes in runes mode
Some breaking changes only apply once your component is in runes mode.
### Bindings to component exports don't show up in rest props
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.
### Bindings need to be explicitly defined using `$bindable()`
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.
## Other breaking changes
### Stricter `@const` assignment validation

Loading…
Cancel
Save