From 416bc85d9c089aa797cc1587f90b34462f291cc8 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 22 Mar 2024 18:35:41 +0100 Subject: [PATCH] breaking: add $bindable() rune to denote bindable props (#10851) Alternative to / closes #10804 closes #10768 closes #10711 --- packages/svelte/src/compiler/errors.js | 1 + .../src/compiler/phases/2-analyze/index.js | 28 ++++++++++--- .../compiler/phases/2-analyze/validation.js | 33 ++++++++++++--- .../3-transform/client/transform-client.js | 40 +++++++++++++------ .../phases/3-transform/client/utils.js | 9 +++-- .../3-transform/client/visitors/global.js | 3 +- .../client/visitors/javascript-legacy.js | 6 +-- .../client/visitors/javascript-runes.js | 35 ++++++++-------- .../3-transform/client/visitors/template.js | 1 + .../3-transform/server/transform-server.js | 28 ++++++++++--- .../svelte/src/compiler/phases/constants.js | 1 + packages/svelte/src/compiler/types/index.d.ts | 6 ++- .../svelte/src/compiler/utils/builders.js | 9 +++++ .../src/internal/client/reactivity/props.js | 18 +++++++-- .../svelte/src/internal/client/runtime.js | 26 ++++++++---- .../svelte/src/internal/client/validate.js | 21 +++++++++- packages/svelte/src/internal/server/index.js | 4 +- packages/svelte/src/main/ambient.d.ts | 13 +++++- .../runes-wrong-bindable-args/_config.js | 8 ++++ .../runes-wrong-bindable-args/main.svelte | 3 ++ .../runes-wrong-bindable-placement/_config.js | 8 ++++ .../main.svelte | 3 ++ .../main.svelte.js | 1 + .../samples/bind-and-spread/button.svelte | 5 +-- .../bind-state-property/CheckBox.svelte | 2 +- .../samples/each-bind-this-member/main.svelte | 2 +- .../Counter.svelte | 2 +- .../sub.svelte | 2 +- .../Counter.svelte | 2 +- .../samples/props-alias/Counter.svelte | 2 +- .../props-bound-fallback/Counter.svelte | 2 +- .../props-bound-to-normal/Inner.svelte | 2 +- .../samples/props-bound/Counter.svelte | 2 +- .../props-default-reactivity/Counter.svelte | 2 +- .../props-default-value-behavior/inner.svelte | 2 +- .../props-not-bindable-spread/Counter.svelte | 5 +++ .../props-not-bindable-spread/_config.js | 11 +++++ .../props-not-bindable-spread/main.svelte | 7 ++++ .../samples/props-not-bindable/Counter.svelte | 5 +++ .../samples/props-not-bindable/_config.js | 11 +++++ .../samples/props-not-bindable/main.svelte | 7 ++++ .../samples/proxy-prop-bound/Counter.svelte | 2 +- .../_expected/server/index.svelte.js | 1 - packages/svelte/types/index.d.ts | 19 +++++++-- .../src/lib/CodeMirror.svelte | 1 + .../routes/docs/content/01-api/02-runes.md | 22 +++++++++- 46 files changed, 330 insertions(+), 93 deletions(-) create mode 100644 packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-args/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-args/main.svelte create mode 100644 packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/main.svelte create mode 100644 packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/main.svelte.js create mode 100644 packages/svelte/tests/runtime-runes/samples/props-not-bindable-spread/Counter.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/props-not-bindable-spread/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/props-not-bindable-spread/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/props-not-bindable/Counter.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/props-not-bindable/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/props-not-bindable/main.svelte diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 72397e1f6e..ade5e580ae 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -182,6 +182,7 @@ const runes = { `$props() assignment must not contain nested properties or computed keys`, 'invalid-props-location': () => `$props() can only be used at the top level of components as a variable declaration initializer`, + 'invalid-bindable-location': () => `$bindable() can only be used inside a $props() declaration`, /** @param {string} rune */ 'invalid-state-location': (rune) => `${rune}(...) can only be used as a variable declaration initializer or a class field`, diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 8140439145..da3886cc53 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -436,7 +436,7 @@ export function analyze_component(root, options) { ); } } else { - instance.scope.declare(b.id('$$props'), 'prop', 'synthetic'); + instance.scope.declare(b.id('$$props'), 'bindable_prop', 'synthetic'); instance.scope.declare(b.id('$$restProps'), 'rest_prop', 'synthetic'); for (const { ast, scope, scopes } of [module, instance, template]) { @@ -466,7 +466,10 @@ export function analyze_component(root, options) { } for (const [name, binding] of instance.scope.declarations) { - if (binding.kind === 'prop' && binding.node.name !== '$$props') { + if ( + (binding.kind === 'prop' || binding.kind === 'bindable_prop') && + binding.node.name !== '$$props' + ) { const references = binding.references.filter( (r) => r.node !== binding.node && r.path.at(-1)?.type !== 'ExportSpecifier' ); @@ -759,7 +762,7 @@ const legacy_scope_tweaker = { (binding.kind === 'normal' && (binding.declaration_kind === 'let' || binding.declaration_kind === 'var'))) ) { - binding.kind = 'prop'; + binding.kind = 'bindable_prop'; if (specifier.exported.name !== specifier.local.name) { binding.prop_alias = specifier.exported.name; } @@ -797,7 +800,7 @@ const legacy_scope_tweaker = { for (const declarator of node.declaration.declarations) { for (const id of extract_identifiers(declarator.id)) { const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(id.name)); - binding.kind = 'prop'; + binding.kind = 'bindable_prop'; } } } @@ -886,11 +889,24 @@ const runes_scope_tweaker = { property.key.type === 'Identifier' ? property.key.name : /** @type {string} */ (/** @type {import('estree').Literal} */ (property.key).value); - const initial = property.value.type === 'AssignmentPattern' ? property.value.right : null; + let initial = property.value.type === 'AssignmentPattern' ? property.value.right : null; const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(name)); binding.prop_alias = alias; - binding.initial = initial; // rewire initial from $props() to the actual initial value + + // rewire initial from $props() to the actual initial value, stripping $bindable() if necessary + if ( + initial?.type === 'CallExpression' && + initial.callee.type === 'Identifier' && + initial.callee.name === '$bindable' + ) { + binding.initial = /** @type {import('estree').Expression | null} */ ( + initial.arguments[0] ?? null + ); + binding.kind = 'bindable_prop'; + } else { + binding.initial = initial; + } } } }, diff --git a/packages/svelte/src/compiler/phases/2-analyze/validation.js b/packages/svelte/src/compiler/phases/2-analyze/validation.js index cf6f00637f..74ed1b1005 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/validation.js +++ b/packages/svelte/src/compiler/phases/2-analyze/validation.js @@ -299,17 +299,19 @@ const validation = { error(node, 'invalid-binding-expression'); } + const binding = context.state.scope.get(left.name); + if ( assignee.type === 'Identifier' && node.name !== 'this' // bind:this also works for regular variables ) { - const binding = context.state.scope.get(left.name); // reassignment if ( !binding || (binding.kind !== 'state' && binding.kind !== 'frozen_state' && binding.kind !== 'prop' && + binding.kind !== 'bindable_prop' && binding.kind !== 'each' && binding.kind !== 'store_sub' && !binding.mutated) @@ -328,8 +330,6 @@ const validation = { // TODO handle mutations of non-state/props in runes mode } - const binding = context.state.scope.get(left.name); - if (node.name === 'group') { if (!binding) { error(node, 'INTERNAL', 'Cannot find declaration for bind:group'); @@ -780,7 +780,25 @@ function validate_call_expression(node, scope, path) { error(node, 'invalid-props-location'); } - if (rune === '$state' || rune === '$derived' || rune === '$derived.by') { + if (rune === '$bindable') { + if (parent.type === 'AssignmentPattern' && path.at(-3)?.type === 'ObjectPattern') { + const declarator = path.at(-4); + if ( + declarator?.type === 'VariableDeclarator' && + get_rune(declarator.init, scope) === '$props' + ) { + return; + } + } + error(node, 'invalid-bindable-location'); + } + + if ( + rune === '$state' || + rune === '$state.frozen' || + rune === '$derived' || + rune === '$derived.by' + ) { if (parent.type === 'VariableDeclarator') return; if (parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) return; error(node, 'invalid-state-location', rune); @@ -873,6 +891,8 @@ export const validation_runes_js = { error(node, 'invalid-rune-args-length', rune, [0, 1]); } else if (rune === '$props') { error(node, 'invalid-props-location'); + } else if (rune === '$bindable') { + error(node, 'invalid-bindable-location'); } }, AssignmentExpression(node, { state }) { @@ -1022,6 +1042,9 @@ export const validation_runes = merge(validation, a11y_validators, { } }, CallExpression(node, { state, path }) { + if (get_rune(node, state.scope) === '$bindable' && node.arguments.length > 1) { + error(node, 'invalid-rune-args-length', '$bindable', [0, 1]); + } validate_call_expression(node, state.scope, path); }, EachBlock(node, { next, state }) { @@ -1062,7 +1085,7 @@ export const validation_runes = merge(validation, a11y_validators, { state.has_props_rune = true; if (args.length > 0) { - error(node, 'invalid-rune-args-length', '$props', [0]); + error(node, 'invalid-rune-args-length', rune, [0]); } if (node.id.type !== 'ObjectPattern') { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index b13ec8e627..1b384798b7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -239,7 +239,7 @@ export function client_component(source, analysis, options) { ); }); - const properties = analysis.exports.map(({ name, alias }) => { + const component_returned_object = analysis.exports.map(({ name, alias }) => { const expression = serialize_get_binding(b.id(name), instance_state); if (expression.type === 'Identifier' && !options.dev) { @@ -249,10 +249,26 @@ export function client_component(source, analysis, options) { return b.get(alias ?? name, [b.return(expression)]); }); - if (analysis.accessors) { - for (const [name, binding] of analysis.instance.scope.declarations) { - if (binding.kind !== 'prop' || name.startsWith('$$')) continue; + const properties = [...analysis.instance.scope.declarations].filter( + ([name, binding]) => + (binding.kind === 'prop' || binding.kind === 'bindable_prop') && !name.startsWith('$$') + ); + if (analysis.runes && options.dev) { + /** @type {import('estree').Literal[]} */ + const bindable = []; + for (const [name, binding] of properties) { + if (binding.kind === 'bindable_prop') { + bindable.push(b.literal(binding.prop_alias ?? name)); + } + } + instance.body.unshift( + b.stmt(b.call('$.validate_prop_bindings', b.id('$$props'), b.array(bindable))) + ); + } + + if (analysis.accessors) { + for (const [name, binding] of properties) { const key = binding.prop_alias ?? name; const getter = b.get(key, [b.return(b.call(b.id(name)))]); @@ -271,12 +287,12 @@ export function client_component(source, analysis, options) { }; } - properties.push(getter, setter); + component_returned_object.push(getter, setter); } } if (options.legacy.componentApi) { - properties.push( + component_returned_object.push( b.init('$set', b.id('$.update_legacy_props')), b.init( '$on', @@ -292,7 +308,7 @@ export function client_component(source, analysis, options) { ) ); } else if (options.dev) { - properties.push( + component_returned_object.push( b.init( '$set', b.thunk( @@ -360,8 +376,8 @@ export function client_component(source, analysis, options) { append_styles(); component_block.body.push( - properties.length > 0 - ? b.return(b.call('$.pop', b.object(properties))) + component_returned_object.length > 0 + ? b.return(b.call('$.pop', b.object(component_returned_object))) : b.stmt(b.call('$.pop')) ); @@ -369,7 +385,7 @@ export function client_component(source, analysis, options) { /** @type {string[]} */ const named_props = analysis.exports.map(({ name, alias }) => alias ?? name); for (const [name, binding] of analysis.instance.scope.declarations) { - if (binding.kind === 'prop') named_props.push(binding.prop_alias ?? name); + if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name); } component_block.body.unshift( @@ -476,9 +492,7 @@ export function client_component(source, analysis, options) { /** @type {import('estree').Property[]} */ const props_str = []; - for (const [name, binding] of analysis.instance.scope.declarations) { - if (binding.kind !== 'prop' || name.startsWith('$$')) continue; - + for (const [name, binding] of properties) { const key = binding.prop_alias ?? name; const prop_def = typeof ce === 'boolean' ? {} : ce.props?.[key] || {}; if ( diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index d93f8f5b92..cd274eef23 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -78,7 +78,7 @@ export function serialize_get_binding(node, state) { return typeof binding.expression === 'function' ? binding.expression(node) : binding.expression; } - if (binding.kind === 'prop') { + if (binding.kind === 'prop' || binding.kind === 'bindable_prop') { if (binding.node.name === '$$props') { // Special case for $$props which only exists in the old world // TODO this probably shouldn't have a 'prop' binding kind @@ -377,6 +377,7 @@ export function serialize_set_binding(node, context, fallback, options) { binding.kind !== 'state' && binding.kind !== 'frozen_state' && binding.kind !== 'prop' && + binding.kind !== 'bindable_prop' && binding.kind !== 'each' && binding.kind !== 'legacy_reactive' && !is_store @@ -389,7 +390,7 @@ export function serialize_set_binding(node, context, fallback, options) { const serialize = () => { if (left === node.left) { - if (binding.kind === 'prop') { + if (binding.kind === 'prop' || binding.kind === 'bindable_prop') { return b.call(left, value); } else if (is_store) { return b.call('$.store_set', serialize_get_binding(b.id(left_name), state), value); @@ -467,7 +468,7 @@ export function serialize_set_binding(node, context, fallback, options) { b.call('$.untrack', b.id('$' + left_name)) ); } else if (!state.analysis.runes) { - if (binding.kind === 'prop') { + if (binding.kind === 'bindable_prop') { return b.call( left, b.sequence([ @@ -571,7 +572,7 @@ function get_hoistable_params(node, context) { params.push(b.id(binding.expression.object.arguments[0].name)); } else if ( // If we are referencing a simple $$props value, then we need to reference the object property instead - binding.kind === 'prop' && + (binding.kind === 'prop' || binding.kind === 'bindable_prop') && !binding.reassigned && binding.initial === null && !context.state.analysis.accessors diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js index 5d1689cadc..c299dd99ef 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js @@ -52,6 +52,7 @@ export const global_visitors = { binding?.kind === 'each' || binding?.kind === 'legacy_reactive' || binding?.kind === 'prop' || + binding?.kind === 'bindable_prop' || is_store ) { /** @type {import('estree').Expression[]} */ @@ -64,7 +65,7 @@ export const global_visitors = { fn += '_store'; args.push(serialize_get_binding(b.id(name), state), b.call('$' + name)); } else { - if (binding.kind === 'prop') fn += '_prop'; + if (binding.kind === 'prop' || binding.kind === 'bindable_prop') fn += '_prop'; args.push(b.id(name)); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js index ed4c6e8474..ffb089bf83 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js @@ -40,7 +40,7 @@ export const javascript_visitors_legacy = { state.scope.get_bindings(declarator) ); const has_state = bindings.some((binding) => binding.kind === 'state'); - const has_props = bindings.some((binding) => binding.kind === 'prop'); + const has_props = bindings.some((binding) => binding.kind === 'bindable_prop'); if (!has_state && !has_props) { const init = declarator.init; @@ -80,7 +80,7 @@ export const javascript_visitors_legacy = { declarations.push( b.declarator( path.node, - binding.kind === 'prop' + binding.kind === 'bindable_prop' ? get_prop_source(binding, state, binding.prop_alias ?? name, value) : value ) @@ -168,7 +168,7 @@ export const javascript_visitors_legacy = { // If the binding is a prop, we need to deep read it because it could be fine-grained $state // from a runes-component, where mutations don't trigger an update on the prop as a whole. - if (name === '$$props' || name === '$$restProps' || binding.kind === 'prop') { + if (name === '$$props' || name === '$$restProps' || binding.kind === 'bindable_prop') { serialized = b.call('$.deep_read_state', serialized); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js index 3eb43322b7..a89ae00f70 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js @@ -207,33 +207,30 @@ export const javascript_visitors_runes = { seen.push(name); - let id = property.value; - let initial = undefined; - - if (property.value.type === 'AssignmentPattern') { - id = property.value.left; - initial = /** @type {import('estree').Expression} */ (visit(property.value.right)); - } - + let id = + property.value.type === 'AssignmentPattern' ? property.value.left : property.value; assert.equal(id.type, 'Identifier'); - const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(id.name)); + const initial = + binding.initial && + /** @type {import('estree').Expression} */ (visit(binding.initial)); if (binding.reassigned || state.analysis.accessors || initial) { declarations.push(b.declarator(id, get_prop_source(binding, state, name, initial))); } } else { // RestElement - declarations.push( - b.declarator( - property.argument, - b.call( - '$.rest_props', - b.id('$$props'), - b.array(seen.map((name) => b.literal(name))) - ) - ) - ); + /** @type {import('estree').Expression[]} */ + const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))]; + + if (state.options.dev) { + // include rest name, so we can provide informative error messages + args.push( + b.literal(/** @type {import('estree').Identifier} */ (property.argument).name) + ); + } + + declarations.push(b.declarator(property.argument, b.call('$.rest_props', ...args))); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index 265b09591c..7e4a82bef9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -1382,6 +1382,7 @@ function serialize_event_handler(node, { state, visit }) { binding.kind === 'legacy_reactive' || binding.kind === 'derived' || binding.kind === 'prop' || + binding.kind === 'bindable_prop' || binding.kind === 'store_sub') ) { handler = dynamic_handler(); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 8ff7d2c316..fc5ef8e57e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -446,6 +446,7 @@ function serialize_set_binding(node, context, fallback) { binding.kind !== 'state' && binding.kind !== 'frozen_state' && binding.kind !== 'prop' && + binding.kind !== 'bindable_prop' && binding.kind !== 'each' && binding.kind !== 'legacy_reactive' && !is_store @@ -690,7 +691,21 @@ const javascript_visitors_runes = { } if (rune === '$props') { - declarations.push(b.declarator(declarator.id, b.id('$$props'))); + // remove $bindable() from props declaration + const id = walk(declarator.id, null, { + AssignmentPattern(node) { + if ( + node.right.type === 'CallExpression' && + get_rune(node.right, state.scope) === '$bindable' + ) { + const right = node.right.arguments.length + ? /** @type {import('estree').Expression} */ (visit(node.right.arguments[0])) + : b.id('undefined'); + return b.assignment_pattern(node.left, right); + } + } + }); + declarations.push(b.declarator(id, b.id('$$props'))); continue; } @@ -1131,7 +1146,7 @@ const javascript_visitors_legacy = { state.scope.get_bindings(declarator) ); const has_state = bindings.some((binding) => binding.kind === 'state'); - const has_props = bindings.some((binding) => binding.kind === 'prop'); + const has_props = bindings.some((binding) => binding.kind === 'bindable_prop'); if (!has_state && !has_props) { declarations.push(/** @type {import('estree').VariableDeclarator} */ (visit(declarator))); @@ -2217,7 +2232,8 @@ export function server_component(analysis, options) { }); } - // If the component binds to a child, we need to put the template in a loop and repeat until bindings are stable + // If the component binds to a child, we need to put the template in a loop and repeat until legacy bindings are stable. + // We can remove this once the legacy syntax is gone. if (analysis.uses_component_bindings) { template.body = [ b.let('$$settled', b.true), @@ -2258,7 +2274,7 @@ export function server_component(analysis, options) { /** @type {import('estree').Property[]} */ const props = []; for (const [name, binding] of analysis.instance.scope.declarations) { - if (binding.kind === 'prop' && !name.startsWith('$$')) { + if (binding.kind === 'bindable_prop' && !name.startsWith('$$')) { props.push(b.init(binding.prop_alias ?? name, b.id(name))); } } @@ -2266,6 +2282,8 @@ export function server_component(analysis, options) { props.push(b.init(alias ?? name, b.id(name))); } if (props.length > 0) { + // This has no effect in runes mode other than throwing an error when someone passes + // undefined to a binding that has a default value. template.body.push(b.stmt(b.call('$.bind_props', b.id('$$props'), b.object(props)))); } @@ -2280,7 +2298,7 @@ export function server_component(analysis, options) { /** @type {string[]} */ const named_props = analysis.exports.map(({ name, alias }) => alias ?? name); for (const [name, binding] of analysis.instance.scope.declarations) { - if (binding.kind === 'prop') named_props.push(binding.prop_alias ?? name); + if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name); } component_block.body.unshift( diff --git a/packages/svelte/src/compiler/phases/constants.js b/packages/svelte/src/compiler/phases/constants.js index eaf01b7f34..822174d4dd 100644 --- a/packages/svelte/src/compiler/phases/constants.js +++ b/packages/svelte/src/compiler/phases/constants.js @@ -32,6 +32,7 @@ export const Runes = /** @type {const} */ ([ '$state', '$state.frozen', '$props', + '$bindable', '$derived', '$derived.by', '$effect', diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index 6d09effe93..ba7fe22b0f 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -241,7 +241,8 @@ export interface Binding { node: Identifier; /** * - `normal`: A variable that is not in any way special - * - `prop`: A normal prop (possibly mutated) + * - `prop`: A normal prop (possibly reassigned or mutated) + * - `bindable_prop`: A prop one can `bind:` to (possibly reassigned or mutated) * - `rest_prop`: A rest prop * - `state`: A state variable * - `derived`: A derived variable @@ -253,6 +254,7 @@ export interface Binding { kind: | 'normal' | 'prop' + | 'bindable_prop' | 'rest_prop' | 'state' | 'frozen_state' @@ -280,7 +282,7 @@ export interface Binding { scope: Scope; /** For `legacy_reactive`: its reactive dependencies */ legacy_dependencies: Binding[]; - /** Legacy props: the `class` in `{ export klass as class}` */ + /** Legacy props: the `class` in `{ export klass as class}`. $props(): The `class` in { class: klass } = $props() */ prop_alias: string | null; /** * If this is set, all references should use this expression instead of the identifier name. diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 0b837d5daf..a5776a46de 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -17,6 +17,15 @@ export function array_pattern(elements) { return { type: 'ArrayPattern', elements }; } +/** + * @param {import('estree').Pattern} left + * @param {import('estree').Expression} right + * @returns {import('estree').AssignmentPattern} + */ +export function assignment_pattern(left, right) { + return { type: 'AssignmentPattern', left, right }; +} + /** * @param {Array} params * @param {import('estree').BlockStatement | import('estree').Expression} body diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index 8cfd5c266a..20d4870825 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -36,13 +36,22 @@ export function update_pre_prop(fn, d = 1) { /** * The proxy handler for rest props (i.e. `const { x, ...rest } = $props()`). * Is passed the full `$$props` object and excludes the named props. - * @type {ProxyHandler<{ props: Record, exclude: Array }>}} + * @type {ProxyHandler<{ props: Record, exclude: Array, name?: string }>}} */ const rest_props_handler = { get(target, key) { if (target.exclude.includes(key)) return; return target.props[key]; }, + set(target, key) { + if (DEV) { + throw new Error( + `Rest element properties of $props() such as ${target.name}.${String(key)} are readonly` + ); + } + + return false; + }, getOwnPropertyDescriptor(target, key) { if (target.exclude.includes(key)) return; if (key in target.props) { @@ -64,11 +73,12 @@ const rest_props_handler = { /** * @param {Record} props - * @param {string[]} rest + * @param {string[]} exclude + * @param {string} [name] * @returns {Record} */ -export function rest_props(props, rest) { - return new Proxy({ props, exclude: rest }, rest_props_handler); +export function rest_props(props, exclude, name) { + return new Proxy(DEV ? { props, exclude, name } : { props, exclude }, rest_props_handler); } /** diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index fe2c323c32..c3af8c992e 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -1258,22 +1258,34 @@ export function unwrap(value) { } if (DEV) { - /** @param {string} rune */ - function throw_rune_error(rune) { + /** + * @param {string} rune + * @param {string[]} [variants] + */ + function throw_rune_error(rune, variants = []) { if (!(rune in globalThis)) { + // TODO if people start adjusting the "this can contain runes" config through v-p-s more, adjust this message // @ts-ignore globalThis[rune] = () => { - // TODO if people start adjusting the "this can contain runes" config through v-p-s more, adjust this message - throw new Error(`${rune} is only available inside .svelte and .svelte.js/ts files`); + throw new Error(`${rune}() is only available inside .svelte and .svelte.js/ts files`); }; + for (const variant of variants) { + // @ts-ignore + globalThis[rune][variant] = () => { + throw new Error( + `${rune}.${variant}() is only available inside .svelte and .svelte.js/ts files` + ); + }; + } } } - throw_rune_error('$state'); - throw_rune_error('$effect'); - throw_rune_error('$derived'); + throw_rune_error('$state', ['frozen']); + throw_rune_error('$effect', ['pre', 'root', 'active']); + throw_rune_error('$derived', ['by']); throw_rune_error('$inspect'); throw_rune_error('$props'); + throw_rune_error('$bindable'); } /** diff --git a/packages/svelte/src/internal/client/validate.js b/packages/svelte/src/internal/client/validate.js index 442d91190b..befd68b027 100644 --- a/packages/svelte/src/internal/client/validate.js +++ b/packages/svelte/src/internal/client/validate.js @@ -1,5 +1,5 @@ import { untrack } from './runtime.js'; -import { is_array } from './utils.js'; +import { get_descriptor, is_array } from './utils.js'; /** regex of all html void element names */ const void_element_names = @@ -137,3 +137,22 @@ export function validate_component(component_fn) { } return component_fn; } + +/** + * @param {Record} $$props + * @param {string[]} bindable + */ +export function validate_prop_bindings($$props, bindable) { + for (const key in $$props) { + if (!bindable.includes(key)) { + var setter = get_descriptor($$props, key)?.set; + + if (setter) { + throw new Error( + `Cannot use bind:${key} on this component because the property was not declared as bindable. ` + + `To mark a property as bindable, use the $bindable() rune like this: \`let { ${key} = $bindable() } = $props()\`` + ); + } + } + } +} diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index a6b0001d33..bc1caf2d36 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -588,8 +588,8 @@ export function sanitize_slots(props) { } /** - * If the prop has a fallback and is bound in the parent component, - * propagate the fallback value upwards. + * Legacy mode: If the prop has a fallback and is bound in the + * parent component, propagate the fallback value upwards. * @param {Record} props_parent * @param {Record} props_now */ diff --git a/packages/svelte/src/main/ambient.d.ts b/packages/svelte/src/main/ambient.d.ts index 1cb02f9f4d..a2ad6d63af 100644 --- a/packages/svelte/src/main/ambient.d.ts +++ b/packages/svelte/src/main/ambient.d.ts @@ -172,13 +172,24 @@ declare namespace $effect { * Declares the props that a component accepts. Example: * * ```ts - * let { optionalProp = 42, requiredProp }: { optionalProp?: number; requiredProps: string } = $props(); + * let { optionalProp = 42, requiredProp, bindableProp = $bindable() }: { optionalProp?: number; requiredProps: string; bindableProp: boolean } = $props(); * ``` * * https://svelte-5-preview.vercel.app/docs/runes#$props */ declare function $props(): any; +/** + * Declares a prop as bindable, meaning the parent component can use `bind:propName={value}` to bind to it. + * + * ```ts + * let { propName = $bindable() }: { propName: boolean } = $props(); + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$bindable + */ +declare function $bindable(t?: T): T; + /** * Inspects one or more values whenever they, or the properties they contain, change. Example: * diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-args/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-args/_config.js new file mode 100644 index 0000000000..0d1d0e0709 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-args/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'invalid-rune-args-length', + message: '$bindable can only be called with 0 or 1 arguments' + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-args/main.svelte b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-args/main.svelte new file mode 100644 index 0000000000..01d49aa5cd --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-args/main.svelte @@ -0,0 +1,3 @@ + diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/_config.js new file mode 100644 index 0000000000..bc92ee9ced --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'invalid-bindable-location', + message: '$bindable() can only be used inside a $props() declaration' + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/main.svelte b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/main.svelte new file mode 100644 index 0000000000..5af7f0171d --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/main.svelte @@ -0,0 +1,3 @@ + diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/main.svelte.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/main.svelte.js new file mode 100644 index 0000000000..344995a4bb --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-bindable-placement/main.svelte.js @@ -0,0 +1 @@ +const { a = $bindable() } = $state(); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-and-spread/button.svelte b/packages/svelte/tests/runtime-runes/samples/bind-and-spread/button.svelte index 45c1abced8..7bb18ec5f7 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-and-spread/button.svelte +++ b/packages/svelte/tests/runtime-runes/samples/bind-and-spread/button.svelte @@ -1,6 +1,5 @@ - + diff --git a/packages/svelte/tests/runtime-runes/samples/bind-state-property/CheckBox.svelte b/packages/svelte/tests/runtime-runes/samples/bind-state-property/CheckBox.svelte index 172b699205..22c46a363d 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-state-property/CheckBox.svelte +++ b/packages/svelte/tests/runtime-runes/samples/bind-state-property/CheckBox.svelte @@ -1,5 +1,5 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/each-bind-this-member/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-bind-this-member/main.svelte index a685cc9c84..7d46ea90f0 100644 --- a/packages/svelte/tests/runtime-runes/samples/each-bind-this-member/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/each-bind-this-member/main.svelte @@ -1,5 +1,5 @@ {#each items as item, i} diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/Counter.svelte index d1be326830..57cbebde12 100644 --- a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/Counter.svelte +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/Counter.svelte @@ -1,6 +1,6 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/Counter.svelte index d1be326830..57cbebde12 100644 --- a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/Counter.svelte +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/Counter.svelte @@ -1,6 +1,6 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/props-bound-fallback/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/props-bound-fallback/Counter.svelte index 6b9240c70e..a2bda4c70b 100644 --- a/packages/svelte/tests/runtime-runes/samples/props-bound-fallback/Counter.svelte +++ b/packages/svelte/tests/runtime-runes/samples/props-bound-fallback/Counter.svelte @@ -1,5 +1,5 @@ {count} diff --git a/packages/svelte/tests/runtime-runes/samples/props-bound-to-normal/Inner.svelte b/packages/svelte/tests/runtime-runes/samples/props-bound-to-normal/Inner.svelte index 82b2f0648a..3d1261071e 100644 --- a/packages/svelte/tests/runtime-runes/samples/props-bound-to-normal/Inner.svelte +++ b/packages/svelte/tests/runtime-runes/samples/props-bound-to-normal/Inner.svelte @@ -1,5 +1,5 @@ \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/props-bound/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/props-bound/Counter.svelte index e9bcb945b2..67b08a561f 100644 --- a/packages/svelte/tests/runtime-runes/samples/props-bound/Counter.svelte +++ b/packages/svelte/tests/runtime-runes/samples/props-bound/Counter.svelte @@ -1,5 +1,5 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/Counter.svelte index 2ba2ed9100..077eda5709 100644 --- a/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/Counter.svelte +++ b/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/Counter.svelte @@ -1,6 +1,6 @@