From 578b5df23fd6a8f0be863ac3c2ea9b1e0fd72113 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 14:13:47 +0200 Subject: [PATCH 01/19] support spreading function bindings --- .../compiler/phases/1-parse/state/element.js | 29 ++++++ .../2-analyze/visitors/BindDirective.js | 21 +++++ .../client/visitors/BindDirective.js | 89 +++++++++++-------- .../client/visitors/shared/utils.js | 20 ++++- packages/svelte/src/compiler/phases/nodes.js | 3 +- packages/svelte/src/compiler/types/index.d.ts | 2 + .../svelte/src/compiler/types/template.d.ts | 5 +- .../samples/bind-spread/_config.js | 27 ++++++ .../samples/bind-spread/main.svelte | 24 +++++ packages/svelte/types/index.d.ts | 4 +- 10 files changed, 179 insertions(+), 45 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index ed1b047d55..f281c61f75 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -618,6 +618,15 @@ function read_attribute(parser) { e.directive_missing_name({ start, end: start + colon_index + 1 }, name); } + if ( + type !== 'BindDirective' && + value !== true && + 'metadata' in value && + value.metadata.expression.has_spread + ) { + e.directive_invalid_value(value.start); + } + if (type === 'StyleDirective') { return { start, @@ -646,6 +655,17 @@ function read_attribute(parser) { // TODO throw a parser error in a future version here if this `[ExpressionTag]` instead of `ExpressionTag`, // which means stringified value, which isn't allowed for some directives? expression = first_value.expression; + + // Handle spread syntax in bind directives + if (type === 'BindDirective' && first_value.metadata.expression.has_spread) { + // Create a SpreadElement to represent ...array syntax + expression = { + type: 'SpreadElement', + start: first_value.start, + end: first_value.end, + argument: expression + }; + } } } @@ -812,6 +832,13 @@ function read_sequence(parser, done, location) { flush(parser.index - 1); parser.allow_whitespace(); + + const has_spread = parser.match('...'); + if (has_spread) { + parser.eat('...', true); + parser.allow_whitespace(); + } + const expression = read_expression(parser); parser.allow_whitespace(); parser.eat('}', true); @@ -827,6 +854,8 @@ function read_sequence(parser, done, location) { } }; + chunk.metadata.expression.has_spread = has_spread; + chunks.push(chunk); current_chunk = { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js index 9f02e7fa5a..4ff27a68d8 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js @@ -158,6 +158,27 @@ export function BindDirective(node, context) { return; } + // Handle spread syntax for bind directives: bind:value={...bindings} + if (node.expression.type === 'SpreadElement') { + if (node.name === 'group') { + e.bind_group_invalid_expression(node); + } + + // Validate that the spread is applied to a valid expression that returns an array + const argument = node.expression.argument; + if ( + argument.type !== 'Identifier' && + argument.type !== 'MemberExpression' && + argument.type !== 'CallExpression' + ) { + e.bind_invalid_expression(node); + } + + mark_subtree_dynamic(context.path); + + return; + } + validate_assignment(node, node.expression, context); const assignee = node.expression; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js index 506fd4aafd..5a87d72dbc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js @@ -13,52 +13,67 @@ import { build_bind_this, validate_binding } from './shared/utils.js'; * @param {ComponentContext} context */ export function BindDirective(node, context) { - const expression = /** @type {Expression} */ (context.visit(node.expression)); - const property = binding_properties[node.name]; - - const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1)); - let get, set; - - if (expression.type === 'SequenceExpression') { - [get, set] = expression.expressions; + + // Handle SpreadElement by creating a variable declaration before visiting + if (node.expression.type === 'SpreadElement') { + // Generate a unique variable name for this spread binding + const id = b.id(context.state.scope.generate('$$bindings')); + + // Store the spread expression in a variable at the component init level + const spread_expression = /** @type {Expression} */ (context.visit(node.expression.argument)); + context.state.init.push(b.const(id, spread_expression)); + + // Use member access to get getter and setter + get = b.member(id, b.literal(0), true); + set = b.member(id, b.literal(1), true); } else { - if ( - dev && - context.state.analysis.runes && - expression.type === 'MemberExpression' && - (node.name !== 'this' || - context.path.some( - ({ type }) => - type === 'IfBlock' || - type === 'EachBlock' || - type === 'AwaitBlock' || - type === 'KeyBlock' - )) && - !is_ignored(node, 'binding_property_non_reactive') - ) { - validate_binding(context.state, node, expression); - } + const expression = /** @type {Expression} */ (context.visit(node.expression)); - get = b.thunk(expression); + if (expression.type === 'SequenceExpression') { + [get, set] = expression.expressions; + } else { + if ( + dev && + context.state.analysis.runes && + expression.type === 'MemberExpression' && + (node.name !== 'this' || + context.path.some( + ({ type }) => + type === 'IfBlock' || + type === 'EachBlock' || + type === 'AwaitBlock' || + type === 'KeyBlock' + )) && + !is_ignored(node, 'binding_property_non_reactive') + ) { + validate_binding(context.state, node, expression); + } - /** @type {Expression | undefined} */ - set = b.unthunk( - b.arrow( - [b.id('$$value')], - /** @type {Expression} */ ( - context.visit( - b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value')) + get = b.thunk(expression); + + /** @type {Expression | undefined} */ + set = b.unthunk( + b.arrow( + [b.id('$$value')], + /** @type {Expression} */ ( + context.visit( + b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value')) + ) ) ) - ) - ); + ); - if (get === set) { - set = undefined; + if (get === set) { + set = undefined; + } } } + const property = binding_properties[node.name]; + + const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1)); + /** @type {CallExpression} */ let call; @@ -222,7 +237,7 @@ export function BindDirective(node, context) { if (value !== undefined) { group_getter = b.thunk( - b.block([b.stmt(build_attribute_value(value, context).value), b.return(expression)]) + b.block([b.stmt(build_attribute_value(value, context).value), b.return(get)]) ); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index 014547cf2d..2fd6277660 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -1,4 +1,4 @@ -/** @import { AssignmentExpression, Expression, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */ +/** @import { AssignmentExpression, Expression, Identifier, MemberExpression, SequenceExpression, SpreadElement, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */ /** @import { AST, ExpressionMetadata } from '#compiler' */ /** @import { ComponentClientTransformState, ComponentContext, Context } from '../../types' */ import { walk } from 'zimmerframe'; @@ -204,11 +204,25 @@ export function parse_directive_name(name) { /** * Serializes `bind:this` for components and elements. - * @param {Identifier | MemberExpression | SequenceExpression} expression + * @param {Identifier | MemberExpression | SequenceExpression | SpreadElement} expression * @param {Expression} value * @param {import('zimmerframe').Context} context */ export function build_bind_this(expression, value, { state, visit }) { + if (expression.type === 'SpreadElement') { + // Generate a unique variable name for this spread binding + const id = b.id(state.scope.generate('$$bindings')); + + // Store the spread expression in a variable at the component init level + const spread_expression = /** @type {Expression} */ (visit(expression.argument)); + state.init.push(b.const(id, spread_expression)); + + // Use member access to get getter and setter + const get = b.member(id, b.literal(0), true); + const set = b.member(id, b.literal(1), true); + return b.call('$.bind_this', value, set, get); + } + if (expression.type === 'SequenceExpression') { const [get, set] = /** @type {SequenceExpression} */ (visit(expression)).expressions; return b.call('$.bind_this', value, set, get); @@ -290,7 +304,7 @@ export function build_bind_this(expression, value, { state, visit }) { * @param {MemberExpression} expression */ export function validate_binding(state, binding, expression) { - if (binding.expression.type === 'SequenceExpression') { + if (binding.expression.type === 'SequenceExpression' || binding.expression.type === 'SpreadElement') { return; } // If we are referencing a $store.foo then we don't need to add validation diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 4874554ff0..6e29c23be8 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -67,7 +67,8 @@ export function create_expression_metadata() { has_call: false, has_member_expression: false, has_assignment: false, - has_await: false + has_await: false, + has_spread: false }; } diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index 6211e69bd3..7d8c51db0f 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -304,6 +304,8 @@ export interface ExpressionMetadata { has_member_expression: boolean; /** True if the expression includes an assignment or an update */ has_assignment: boolean; + /** True if the expression includes a spread element */ + has_spread: boolean; } export interface StateField { diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 060df2dcb2..b3ae4fae5f 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -15,7 +15,8 @@ import type { Program, ChainExpression, SimpleCallExpression, - SequenceExpression + SequenceExpression, + SpreadElement } from 'estree'; import type { Scope } from '../phases/scope'; import type { _CSS } from './css'; @@ -211,7 +212,7 @@ export namespace AST { /** The 'x' in `bind:x` */ name: string; /** The y in `bind:x={y}` */ - expression: Identifier | MemberExpression | SequenceExpression; + expression: Identifier | MemberExpression | SequenceExpression | SpreadElement; /** @internal */ metadata: { binding_group_name: Identifier; diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js new file mode 100644 index 0000000000..ae971acaf8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js @@ -0,0 +1,27 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const checkboxes = target.querySelectorAll('input'); + + // input.value = '2'; + // input.dispatchEvent(new window.Event('input')); + + flushSync(); + + assert.htmlEqual(target.innerHTML, ``.repeat(3)); + + // assert.deepEqual(logs, ['b', '2', 'a', '2']); + + flushSync(() => { + checkboxes.forEach((checkbox) => checkbox.click()); + }); + assert.deepEqual(logs, ['getBindings', ...repeatArray(3, ['check', false])]); + } +}); + +/** @template T */ +function repeatArray(/** @type {number} */ times = 1, /** @type {T[]} */ array) { + return /** @type {T[]} */ Array.from({ length: times }, () => array).flat(); +} diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte new file mode 100644 index 0000000000..13646ef83a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 97e6f0f5a3..90b5e18fb5 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -795,7 +795,7 @@ declare module 'svelte/attachments' { declare module 'svelte/compiler' { import type { SourceMap } from 'magic-string'; - import type { ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression } from 'estree'; + import type { ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression, SpreadElement } from 'estree'; import type { Location } from 'locate-character'; /** * `compile` converts your `.svelte` source code into a JavaScript module that exports a component @@ -1268,7 +1268,7 @@ declare module 'svelte/compiler' { /** The 'x' in `bind:x` */ name: string; /** The y in `bind:x={y}` */ - expression: Identifier | MemberExpression | SequenceExpression; + expression: Identifier | MemberExpression | SequenceExpression | SpreadElement; } /** A `class:` directive */ From 9c6028b10fb695b6910831a44b63a1da02e47e24 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 14:19:46 +0200 Subject: [PATCH 02/19] dedupe repeated code --- .../client/visitors/BindDirective.js | 15 +++----- .../client/visitors/shared/utils.js | 35 +++++++++++++------ 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js index 5a87d72dbc..56792d8dac 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js @@ -6,7 +6,7 @@ import { is_text_attribute } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; import { binding_properties } from '../../../bindings.js'; import { build_attribute_value } from './shared/element.js'; -import { build_bind_this, validate_binding } from './shared/utils.js'; +import { build_bind_this, validate_binding, handle_spread_binding } from './shared/utils.js'; /** * @param {AST.BindDirective} node @@ -17,16 +17,9 @@ export function BindDirective(node, context) { // Handle SpreadElement by creating a variable declaration before visiting if (node.expression.type === 'SpreadElement') { - // Generate a unique variable name for this spread binding - const id = b.id(context.state.scope.generate('$$bindings')); - - // Store the spread expression in a variable at the component init level - const spread_expression = /** @type {Expression} */ (context.visit(node.expression.argument)); - context.state.init.push(b.const(id, spread_expression)); - - // Use member access to get getter and setter - get = b.member(id, b.literal(0), true); - set = b.member(id, b.literal(1), true); + const { get: getter, set: setter } = handle_spread_binding(node.expression, context.state, context.visit); + get = getter; + set = setter; } else { const expression = /** @type {Expression} */ (context.visit(node.expression)); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index 2fd6277660..bac446cc0c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -202,6 +202,25 @@ export function parse_directive_name(name) { return expression; } +/** + * Handles SpreadElement by creating a variable declaration and returning getter/setter expressions + * @param {SpreadElement} spread_expression + * @param {ComponentClientTransformState} state + * @param {function} visit + * @returns {{get: Expression, set: Expression}} + */ +export function handle_spread_binding(spread_expression, state, visit) { + // Generate a unique variable name for this spread binding + const id = b.id(state.scope.generate('$$bindings')); + + const visited_expression = /** @type {Expression} */ (visit(spread_expression.argument)); + state.init.push(b.const(id, visited_expression)); + + const get = b.member(id, b.literal(0), true); + const set = b.member(id, b.literal(1), true); + return { get, set }; +} + /** * Serializes `bind:this` for components and elements. * @param {Identifier | MemberExpression | SequenceExpression | SpreadElement} expression @@ -210,16 +229,7 @@ export function parse_directive_name(name) { */ export function build_bind_this(expression, value, { state, visit }) { if (expression.type === 'SpreadElement') { - // Generate a unique variable name for this spread binding - const id = b.id(state.scope.generate('$$bindings')); - - // Store the spread expression in a variable at the component init level - const spread_expression = /** @type {Expression} */ (visit(expression.argument)); - state.init.push(b.const(id, spread_expression)); - - // Use member access to get getter and setter - const get = b.member(id, b.literal(0), true); - const set = b.member(id, b.literal(1), true); + const { get, set } = handle_spread_binding(expression, state, visit); return b.call('$.bind_this', value, set, get); } @@ -304,7 +314,10 @@ export function build_bind_this(expression, value, { state, visit }) { * @param {MemberExpression} expression */ export function validate_binding(state, binding, expression) { - if (binding.expression.type === 'SequenceExpression' || binding.expression.type === 'SpreadElement') { + if ( + binding.expression.type === 'SequenceExpression' || + binding.expression.type === 'SpreadElement' + ) { return; } // If we are referencing a $store.foo then we don't need to add validation From 59a96c9a9761d47e21b29183ac1d9d2bcea9b360 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 15:22:41 +0200 Subject: [PATCH 03/19] support spreading object with get/set methods --- .../client/visitors/BindDirective.js | 11 ++++-- .../client/visitors/shared/utils.js | 20 +---------- .../server/visitors/shared/element.js | 13 +++++-- .../3-transform/shared/spread_bindings.js | 36 +++++++++++++++++++ .../samples/bind-spread/_config.js | 8 +++-- .../samples/bind-spread/main.svelte | 15 +++++--- 6 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js index 56792d8dac..74b3978f5d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js @@ -6,7 +6,8 @@ import { is_text_attribute } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; import { binding_properties } from '../../../bindings.js'; import { build_attribute_value } from './shared/element.js'; -import { build_bind_this, validate_binding, handle_spread_binding } from './shared/utils.js'; +import { build_bind_this, validate_binding } from './shared/utils.js'; +import { handle_spread_binding } from '../../shared/spread_bindings.js'; /** * @param {AST.BindDirective} node @@ -14,10 +15,14 @@ import { build_bind_this, validate_binding, handle_spread_binding } from './shar */ export function BindDirective(node, context) { let get, set; - + // Handle SpreadElement by creating a variable declaration before visiting if (node.expression.type === 'SpreadElement') { - const { get: getter, set: setter } = handle_spread_binding(node.expression, context.state, context.visit); + const { get: getter, set: setter } = handle_spread_binding( + node.expression, + context.state, + context.visit + ); get = getter; set = setter; } else { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index bac446cc0c..83d0a49959 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -9,6 +9,7 @@ import { regex_is_valid_identifier } from '../../../../patterns.js'; import is_reference from 'is-reference'; import { dev, is_ignored, locator, component_name } from '../../../../../state.js'; import { build_getter } from '../../utils.js'; +import { handle_spread_binding } from '../../../shared/spread_bindings.js'; /** * A utility for extracting complex expressions (such as call expressions) @@ -202,25 +203,6 @@ export function parse_directive_name(name) { return expression; } -/** - * Handles SpreadElement by creating a variable declaration and returning getter/setter expressions - * @param {SpreadElement} spread_expression - * @param {ComponentClientTransformState} state - * @param {function} visit - * @returns {{get: Expression, set: Expression}} - */ -export function handle_spread_binding(spread_expression, state, visit) { - // Generate a unique variable name for this spread binding - const id = b.id(state.scope.generate('$$bindings')); - - const visited_expression = /** @type {Expression} */ (visit(spread_expression.argument)); - state.init.push(b.const(id, visited_expression)); - - const get = b.member(id, b.literal(0), true); - const set = b.member(id, b.literal(1), true); - return { get, set }; -} - /** * Serializes `bind:this` for components and elements. * @param {Identifier | MemberExpression | SequenceExpression | SpreadElement} expression diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js index 7207564ef9..0ce26571e5 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js @@ -21,6 +21,7 @@ import { is_load_error_element } from '../../../../../../utils.js'; import { escape_html } from '../../../../../../escaping.js'; +import { handle_spread_binding } from '../../../shared/spread_bindings.js'; const WHITESPACE_INSENSITIVE_ATTRIBUTES = ['class', 'style']; @@ -118,7 +119,11 @@ export function build_element_attributes(node, context) { let expression = /** @type {Expression} */ (context.visit(attribute.expression)); - if (expression.type === 'SequenceExpression') { + // Handle SpreadElement for bind directives + if (attribute.expression.type === 'SpreadElement') { + const { get } = handle_spread_binding(attribute.expression, context.state, context.visit); + expression = b.call(get); + } else if (expression.type === 'SequenceExpression') { expression = b.call(expression.expressions[0]); } @@ -126,7 +131,11 @@ export function build_element_attributes(node, context) { content = expression; } else if (attribute.name === 'value' && node.name === 'textarea') { content = b.call('$.escape', expression); - } else if (attribute.name === 'group' && attribute.expression.type !== 'SequenceExpression') { + } else if ( + attribute.name === 'group' && + attribute.expression.type !== 'SequenceExpression' && + attribute.expression.type !== 'SpreadElement' + ) { const value_attribute = /** @type {AST.Attribute | undefined} */ ( node.attributes.find((attr) => attr.type === 'Attribute' && attr.name === 'value') ); diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js new file mode 100644 index 0000000000..01afe26b4b --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js @@ -0,0 +1,36 @@ +/** @import { Expression, MemberExpression, SequenceExpression, SpreadElement, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */ +/** @import { ComponentClientTransformState } from '../client/types.js' */ +/** @import { ComponentServerTransformState } from '../server/types.js' */ +import * as b from '#compiler/builders'; + +/** + * Handles SpreadElement by creating a variable declaration and returning getter/setter expressions + * @param {SpreadElement} spread_expression + * @param {ComponentClientTransformState | ComponentServerTransformState} state + * @param {function} visit + * @returns {{get: Expression, set: Expression}} + */ +export function handle_spread_binding(spread_expression, state, visit) { + // Generate a unique variable name for this spread binding + const id = b.id(state.scope.generate('$$bindings')); + + const visited_expression = /** @type {Expression} */ (visit(spread_expression.argument)); + state.init.push(b.const(id, visited_expression)); + + // Create conditional expressions that work for both arrays and objects + // Array.isArray($$bindings) ? $$bindings[0] : $$bindings.get + const get = b.conditional( + b.call('Array.isArray', id), + b.member(id, b.literal(0), true), + b.member(id, b.id('get')) + ); + + // Array.isArray($$bindings) ? $$bindings[1] : $$bindings.set + const set = b.conditional( + b.call('Array.isArray', id), + b.member(id, b.literal(1), true), + b.member(id, b.id('set')) + ); + + return { get, set }; +} diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js index ae971acaf8..d2b13c9b19 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js @@ -10,14 +10,18 @@ export default test({ flushSync(); - assert.htmlEqual(target.innerHTML, ``.repeat(3)); + assert.htmlEqual(target.innerHTML, ``.repeat(4)); // assert.deepEqual(logs, ['b', '2', 'a', '2']); flushSync(() => { checkboxes.forEach((checkbox) => checkbox.click()); }); - assert.deepEqual(logs, ['getBindings', ...repeatArray(3, ['check', false])]); + assert.deepEqual(logs, [ + 'getArrayBindings', + 'getObjectBindings', + ...repeatArray(4, ['check', false]) + ]); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte index 13646ef83a..d2cb8491e6 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte @@ -9,10 +9,16 @@ } ]; - function getBindings() { - console.log('getBindings'); + function getArrayBindings() { + console.log('getArrayBindings'); return check_bindings; } + + function getObjectBindings() { + console.log('getObjectBindings'); + const [get, set] = check_bindings; + return { get, set }; + } @@ -20,5 +26,6 @@ - - + + + From c8294cc6329902b91d2378166f3c8875ba9c5698 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 15:25:28 +0200 Subject: [PATCH 04/19] test IIFE support --- .../runtime-runes/samples/bind-spread/_config.js | 11 +++-------- .../runtime-runes/samples/bind-spread/main.svelte | 2 ++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js index d2b13c9b19..db712b6e60 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js @@ -5,14 +5,9 @@ export default test({ async test({ assert, target, logs }) { const checkboxes = target.querySelectorAll('input'); - // input.value = '2'; - // input.dispatchEvent(new window.Event('input')); - flushSync(); - assert.htmlEqual(target.innerHTML, ``.repeat(4)); - - // assert.deepEqual(logs, ['b', '2', 'a', '2']); + assert.htmlEqual(target.innerHTML, ``.repeat(checkboxes.length)); flushSync(() => { checkboxes.forEach((checkbox) => checkbox.click()); @@ -20,12 +15,12 @@ export default test({ assert.deepEqual(logs, [ 'getArrayBindings', 'getObjectBindings', - ...repeatArray(4, ['check', false]) + ...repeatArray(checkboxes.length, ['check', false]) ]); } }); /** @template T */ -function repeatArray(/** @type {number} */ times = 1, /** @type {T[]} */ array) { +function repeatArray(/** @type {number} */ times, /** @type {T[]} */ array) { return /** @type {T[]} */ Array.from({ length: times }, () => array).flat(); } diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte index d2cb8491e6..bfd766c3b5 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte @@ -28,4 +28,6 @@ + check_bindings)()} /> + From a9ffe159ec2458a0ed2532aba9479859bcba7440 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 18:02:27 +0200 Subject: [PATCH 05/19] cleaner error messages --- .../3-transform/shared/spread_bindings.js | 39 ++++++++++++------- .../samples/bind-spread/_config.js | 5 +-- .../samples/bind-spread/main.svelte | 26 +++++++------ 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js index 01afe26b4b..c5727aead5 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js +++ b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js @@ -1,4 +1,4 @@ -/** @import { Expression, MemberExpression, SequenceExpression, SpreadElement, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */ +/** @import { CallExpression, Expression, SpreadElement, Super } from 'estree' */ /** @import { ComponentClientTransformState } from '../client/types.js' */ /** @import { ComponentServerTransformState } from '../server/types.js' */ import * as b from '#compiler/builders'; @@ -17,20 +17,33 @@ export function handle_spread_binding(spread_expression, state, visit) { const visited_expression = /** @type {Expression} */ (visit(spread_expression.argument)); state.init.push(b.const(id, visited_expression)); - // Create conditional expressions that work for both arrays and objects - // Array.isArray($$bindings) ? $$bindings[0] : $$bindings.get - const get = b.conditional( - b.call('Array.isArray', id), - b.member(id, b.literal(0), true), - b.member(id, b.id('get')) - ); + const noop = b.arrow([], b.block([])); + + // Generate helper variables for clearer error messages + const get = b.id(state.scope.generate(id.name + '_get')); + const set = b.id(state.scope.generate(id.name + '_set')); - // Array.isArray($$bindings) ? $$bindings[1] : $$bindings.set - const set = b.conditional( - b.call('Array.isArray', id), - b.member(id, b.literal(1), true), - b.member(id, b.id('set')) + const getter = b.logical( + '??', + b.conditional( + b.call('Array.isArray', id), + b.member(id, b.literal(0), true), + b.member(id, b.id('get')) + ), + noop ); + const setter = b.logical( + '??', + b.conditional( + b.call('Array.isArray', id), + b.member(id, b.literal(1), true), + b.member(id, b.id('set')) + ), + noop + ); + + state.init.push(b.const(get, getter)); + state.init.push(b.const(set, setter)); return { get, set }; } diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js index db712b6e60..2161bdea2d 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js @@ -9,9 +9,8 @@ export default test({ assert.htmlEqual(target.innerHTML, ``.repeat(checkboxes.length)); - flushSync(() => { - checkboxes.forEach((checkbox) => checkbox.click()); - }); + checkboxes.forEach((checkbox) => checkbox.click()); + assert.deepEqual(logs, [ 'getArrayBindings', 'getObjectBindings', diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte index bfd766c3b5..7f11eb81f6 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte @@ -1,33 +1,35 @@ + - + - + - check_bindings)()} /> + [get, set])()} /> From 02ea592b52ccc453ff3a19213a871fcccb2c0303 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 29 Jul 2025 16:42:39 +0200 Subject: [PATCH 06/19] better error reporting --- .../compiler/phases/1-parse/state/element.js | 8 +-- .../2-analyze/visitors/BindDirective.js | 4 +- .../client/visitors/BindDirective.js | 8 +-- .../client/visitors/shared/utils.js | 7 ++- .../server/visitors/shared/element.js | 4 +- .../3-transform/shared/spread_bindings.js | 55 +++++++------------ .../svelte/src/compiler/utils/builders.js | 4 +- packages/svelte/src/internal/client/index.js | 3 +- packages/svelte/src/internal/server/index.js | 5 +- packages/svelte/src/internal/shared/errors.js | 19 ++++++- .../svelte/src/internal/shared/validate.js | 21 +++++++ .../samples/bind-spread-empty/_config.js | 21 +++++++ .../samples/bind-spread-empty/main.svelte | 37 +++++++++++++ .../samples/bind-spread-error/_config.js | 9 +++ .../samples/bind-spread-error/main.svelte | 7 +++ 15 files changed, 152 insertions(+), 60 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-empty/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-empty/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-error/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-error/main.svelte diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index f281c61f75..731f4b5997 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -1,4 +1,4 @@ -/** @import { Expression } from 'estree' */ +/** @import { Expression, SpreadElement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Parser } from '../index.js' */ import { is_void } from '../../../../utils.js'; @@ -643,7 +643,7 @@ function read_attribute(parser) { const first_value = value === true ? undefined : Array.isArray(value) ? value[0] : value; - /** @type {Expression | null} */ + /** @type {Expression | SpreadElement | null} */ let expression = null; if (first_value) { @@ -655,10 +655,8 @@ function read_attribute(parser) { // TODO throw a parser error in a future version here if this `[ExpressionTag]` instead of `ExpressionTag`, // which means stringified value, which isn't allowed for some directives? expression = first_value.expression; - - // Handle spread syntax in bind directives + if (type === 'BindDirective' && first_value.metadata.expression.has_spread) { - // Create a SpreadElement to represent ...array syntax expression = { type: 'SpreadElement', start: first_value.start, diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js index 4ff27a68d8..dbb05bc5ef 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js @@ -167,8 +167,8 @@ export function BindDirective(node, context) { // Validate that the spread is applied to a valid expression that returns an array const argument = node.expression.argument; if ( - argument.type !== 'Identifier' && - argument.type !== 'MemberExpression' && + argument.type !== 'Identifier' && + argument.type !== 'MemberExpression' && argument.type !== 'CallExpression' ) { e.bind_invalid_expression(node); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js index 74b3978f5d..19b7dda348 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js @@ -7,7 +7,7 @@ import * as b from '#compiler/builders'; import { binding_properties } from '../../../bindings.js'; import { build_attribute_value } from './shared/element.js'; import { build_bind_this, validate_binding } from './shared/utils.js'; -import { handle_spread_binding } from '../../shared/spread_bindings.js'; +import { init_spread_bindings } from '../../shared/spread_bindings.js'; /** * @param {AST.BindDirective} node @@ -18,11 +18,7 @@ export function BindDirective(node, context) { // Handle SpreadElement by creating a variable declaration before visiting if (node.expression.type === 'SpreadElement') { - const { get: getter, set: setter } = handle_spread_binding( - node.expression, - context.state, - context.visit - ); + const { get: getter, set: setter } = init_spread_bindings(node.expression, context); get = getter; set = setter; } else { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index 83d0a49959..c29f70ef98 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -9,7 +9,7 @@ import { regex_is_valid_identifier } from '../../../../patterns.js'; import is_reference from 'is-reference'; import { dev, is_ignored, locator, component_name } from '../../../../../state.js'; import { build_getter } from '../../utils.js'; -import { handle_spread_binding } from '../../../shared/spread_bindings.js'; +import { init_spread_bindings } from '../../../shared/spread_bindings.js'; /** * A utility for extracting complex expressions (such as call expressions) @@ -209,9 +209,10 @@ export function parse_directive_name(name) { * @param {Expression} value * @param {import('zimmerframe').Context} context */ -export function build_bind_this(expression, value, { state, visit }) { +export function build_bind_this(expression, value, context) { + const { state, visit } = context; if (expression.type === 'SpreadElement') { - const { get, set } = handle_spread_binding(expression, state, visit); + const { get, set } = init_spread_bindings(expression, context); return b.call('$.bind_this', value, set, get); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js index 0ce26571e5..2f9b8ab81e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js @@ -21,7 +21,7 @@ import { is_load_error_element } from '../../../../../../utils.js'; import { escape_html } from '../../../../../../escaping.js'; -import { handle_spread_binding } from '../../../shared/spread_bindings.js'; +import { init_spread_bindings } from '../../../shared/spread_bindings.js'; const WHITESPACE_INSENSITIVE_ATTRIBUTES = ['class', 'style']; @@ -121,7 +121,7 @@ export function build_element_attributes(node, context) { // Handle SpreadElement for bind directives if (attribute.expression.type === 'SpreadElement') { - const { get } = handle_spread_binding(attribute.expression, context.state, context.visit); + const { get } = init_spread_bindings(attribute.expression, context); expression = b.call(get); } else if (expression.type === 'SequenceExpression') { expression = b.call(expression.expressions[0]); diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js index c5727aead5..03a463b593 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js +++ b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js @@ -1,49 +1,32 @@ -/** @import { CallExpression, Expression, SpreadElement, Super } from 'estree' */ +/** @import { Expression, SpreadElement } from 'estree' */ +/** @import { Context } from 'zimmerframe' */ /** @import { ComponentClientTransformState } from '../client/types.js' */ /** @import { ComponentServerTransformState } from '../server/types.js' */ +/** @import { AST } from '#compiler' */ import * as b from '#compiler/builders'; +import { dev, source } from '../../../state.js'; /** - * Handles SpreadElement by creating a variable declaration and returning getter/setter expressions + * Initializes spread bindings for a SpreadElement in a bind directive. * @param {SpreadElement} spread_expression - * @param {ComponentClientTransformState | ComponentServerTransformState} state - * @param {function} visit - * @returns {{get: Expression, set: Expression}} + * @param {Context | Context} context + * @returns {{ get: Expression, set: Expression }} */ -export function handle_spread_binding(spread_expression, state, visit) { - // Generate a unique variable name for this spread binding - const id = b.id(state.scope.generate('$$bindings')); - +export function init_spread_bindings(spread_expression, { state, visit }) { const visited_expression = /** @type {Expression} */ (visit(spread_expression.argument)); - state.init.push(b.const(id, visited_expression)); - - const noop = b.arrow([], b.block([])); - - // Generate helper variables for clearer error messages - const get = b.id(state.scope.generate(id.name + '_get')); - const set = b.id(state.scope.generate(id.name + '_set')); + const expression_text = dev + ? b.literal(source.slice(spread_expression.argument.start, spread_expression.argument.end)) + : undefined; - const getter = b.logical( - '??', - b.conditional( - b.call('Array.isArray', id), - b.member(id, b.literal(0), true), - b.member(id, b.id('get')) - ), - noop + const id = state.scope.generate('$$spread_binding'); + const get = b.id(id + '_get'); + const set = b.id(id + '_set'); + state.init.push( + b.const( + b.array_pattern([get, set]), + b.call('$.validate_spread_bindings', visited_expression, expression_text) + ) ); - const setter = b.logical( - '??', - b.conditional( - b.call('Array.isArray', id), - b.member(id, b.literal(1), true), - b.member(id, b.id('set')) - ), - noop - ); - - state.init.push(b.const(get, getter)); - state.init.push(b.const(set, setter)); return { get, set }; } diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 56a5f31ffe..6701884c9f 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -649,13 +649,13 @@ function return_builder(argument = null) { } /** - * @param {string} str + * @param {string | ESTree.TemplateLiteral} str * @returns {ESTree.ThrowStatement} */ export function throw_error(str) { return { type: 'ThrowStatement', - argument: new_builder('Error', literal(str)) + argument: new_builder('Error', typeof str === 'string' ? literal(str) : str) }; } diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index c094c9e044..34781d1039 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -172,7 +172,8 @@ export { validate_dynamic_element_tag, validate_store, validate_void_dynamic_element, - prevent_snippet_stringification + prevent_snippet_stringification, + validate_spread_bindings } from '../shared/validate.js'; export { strict_equals, equals } from './dev/equality.js'; export { log_if_contains_state } from './dev/console-log.js'; diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 62ee22d6fc..7cfe15cae9 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -511,13 +511,14 @@ export { assign_payload, copy_payload } from './payload.js'; export { snapshot } from '../shared/clone.js'; -export { fallback, to_array } from '../shared/utils.js'; +export { fallback, to_array, noop } from '../shared/utils.js'; export { invalid_default_snippet, validate_dynamic_element_tag, validate_void_dynamic_element, - prevent_snippet_stringification + prevent_snippet_stringification, + validate_spread_bindings } from '../shared/validate.js'; export { escape_html as escape }; diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index 66685cb00b..ac06e510f0 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -114,4 +114,21 @@ export function svelte_element_invalid_this_value() { } else { throw new Error(`https://svelte.dev/e/svelte_element_invalid_this_value`); } -} \ No newline at end of file +} + +/** + * `%name%%member%` must be a function or `undefined` + * @param {string} name + * @returns {never} + */ +export function invalid_spread_bindings(name) { + if (DEV) { + const error = new Error(`invalid_spread_bindings\n\`${name}\` must be a function or \`undefined\`\nhttps://svelte.dev/e/invalid_spread_bindings`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/invalid_spread_bindings`); + } +} diff --git a/packages/svelte/src/internal/shared/validate.js b/packages/svelte/src/internal/shared/validate.js index 48e76f0958..720baa03cb 100644 --- a/packages/svelte/src/internal/shared/validate.js +++ b/packages/svelte/src/internal/shared/validate.js @@ -1,6 +1,7 @@ import { is_void } from '../../utils.js'; import * as w from './warnings.js'; import * as e from './errors.js'; +import { noop } from './utils.js'; export { invalid_default_snippet } from './errors.js'; @@ -45,3 +46,23 @@ export function prevent_snippet_stringification(fn) { }; return fn; } + +/** + * @param {any} spread_object + * @param {string} name + * @return {[() => unknown, (value: unknown) => void]} + */ +export function validate_spread_bindings(spread_object, name) { + const is_array = Array.isArray(spread_object); + const getter = is_array ? spread_object[0] : spread_object.get; + const setter = is_array ? spread_object[1] : spread_object.set; + + if (typeof getter !== 'function' && getter != null) { + e.invalid_spread_bindings(name + (is_array ? '[0]' : '.get')); + } + if (typeof setter !== 'function' && setter != null) { + e.invalid_spread_bindings(name + (is_array ? '[1]' : '.set')); + } + + return [getter ?? noop, setter ?? noop]; +} diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-empty/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-spread-empty/_config.js new file mode 100644 index 0000000000..57cce72d30 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-empty/_config.js @@ -0,0 +1,21 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const checkboxes = target.querySelectorAll('input'); + + flushSync(); + + assert.htmlEqual(target.innerHTML, ``.repeat(checkboxes.length)); + + checkboxes.forEach((checkbox) => checkbox.click()); + + assert.deepEqual(logs, repeatArray(checkboxes.length, ['change', true])); + } +}); + +/** @template T */ +function repeatArray(/** @type {number} */ times, /** @type {T[]} */ array) { + return /** @type {T[]} */ Array.from({ length: times }, () => array).flat(); +} diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-empty/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread-empty/main.svelte new file mode 100644 index 0000000000..e1c37443f6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-empty/main.svelte @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-error/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-spread-error/_config.js new file mode 100644 index 0000000000..65c6deeda0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-error/_config.js @@ -0,0 +1,9 @@ +import { test } from '../../test'; + +export default test({ + expect_unhandled_rejections: true, + compileOptions: { + dev: true + }, + error: 'invalid_spread_bindings' +}); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-error/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread-error/main.svelte new file mode 100644 index 0000000000..8dc8f03644 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-error/main.svelte @@ -0,0 +1,7 @@ + + + From d700bf1bc54dbd11f9d2cb94da9cd88b13080ec1 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 29 Jul 2025 16:57:32 +0200 Subject: [PATCH 07/19] use existing Context types --- .../compiler/phases/3-transform/shared/spread_bindings.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js index 03a463b593..cf496c8b85 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js +++ b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js @@ -1,15 +1,13 @@ /** @import { Expression, SpreadElement } from 'estree' */ -/** @import { Context } from 'zimmerframe' */ -/** @import { ComponentClientTransformState } from '../client/types.js' */ -/** @import { ComponentServerTransformState } from '../server/types.js' */ -/** @import { AST } from '#compiler' */ +/** @import { ComponentContext as ClientContext } from '../client/types.js' */ +/** @import { ComponentContext as ServerContext } from '../server/types.js' */ import * as b from '#compiler/builders'; import { dev, source } from '../../../state.js'; /** * Initializes spread bindings for a SpreadElement in a bind directive. * @param {SpreadElement} spread_expression - * @param {Context | Context} context + * @param {ClientContext | ServerContext} context * @returns {{ get: Expression, set: Expression }} */ export function init_spread_bindings(spread_expression, { state, visit }) { From 484885d718a225243d0083cae317accd174643d3 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 29 Jul 2025 17:13:03 +0200 Subject: [PATCH 08/19] component props --- .../client/visitors/shared/component.js | 143 ++++++++++-------- .../server/visitors/shared/component.js | 13 +- .../bind-spread-component/Child.svelte | 16 ++ .../samples/bind-spread-component/_config.js | 26 ++++ .../samples/bind-spread-component/main.svelte | 30 ++++ 5 files changed, 164 insertions(+), 64 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-component/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-component/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-component/main.svelte diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index 7feeebdbbc..fc2d5b0833 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -1,4 +1,4 @@ -/** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, Statement } from 'estree' */ +/** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, Statement, SpreadElement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../../types.js' */ import { dev, is_ignored } from '../../../../../state.js'; @@ -8,6 +8,7 @@ import { add_svelte_meta, build_bind_this, Memoizer, validate_binding } from '.. import { build_attribute_value } from '../shared/element.js'; import { build_event_handler } from './events.js'; import { determine_slot } from '../../../../../utils/slot.js'; +import { init_spread_bindings } from '../../../shared/spread_bindings.js'; /** * @param {AST.Component | AST.SvelteComponent | AST.SvelteSelf} node @@ -48,7 +49,7 @@ export function build_component(node, component_name, context) { /** @type {Property[]} */ const custom_css_props = []; - /** @type {Identifier | MemberExpression | SequenceExpression | null} */ + /** @type {Identifier | MemberExpression | SequenceExpression | SpreadElement | null} */ let bind_this = null; /** @type {ExpressionStatement[]} */ @@ -196,84 +197,100 @@ export function build_component(node, component_name, context) { push_prop(b.init(attribute.name, value)); } } else if (attribute.type === 'BindDirective') { - const expression = /** @type {Expression} */ (context.visit(attribute.expression)); + if (attribute.expression.type === 'SpreadElement') { + const { get, set } = init_spread_bindings(attribute.expression, context); - if ( - dev && - attribute.name !== 'this' && - !is_ignored(node, 'ownership_invalid_binding') && - // bind:x={() => x.y, y => x.y = y} will be handled by the assignment expression binding validation - attribute.expression.type !== 'SequenceExpression' - ) { - const left = object(attribute.expression); - const binding = left && context.state.scope.get(left.name); - - if (binding?.kind === 'bindable_prop' || binding?.kind === 'prop') { - context.state.analysis.needs_mutation_validation = true; - binding_initializers.push( - b.stmt( - b.call( - '$$ownership_validator.binding', - b.literal(binding.node.name), - b.id(is_component_dynamic ? intermediate_name : component_name), - b.thunk(expression) - ) - ) - ); - } - } - - if (expression.type === 'SequenceExpression') { if (attribute.name === 'this') { bind_this = attribute.expression; } else { - const [get, set] = expression.expressions; - const get_id = b.id(context.state.scope.generate('bind_get')); - const set_id = b.id(context.state.scope.generate('bind_set')); - - context.state.init.push(b.var(get_id, get)); - context.state.init.push(b.var(set_id, set)); - - push_prop(b.get(attribute.name, [b.return(b.call(get_id))])); - push_prop(b.set(attribute.name, [b.stmt(b.call(set_id, b.id('$$value')))])); + push_prop(b.get(attribute.name, [b.return(b.call(get))]), true); + push_prop(b.set(attribute.name, [b.stmt(b.call(set, b.id('$$value')))]), true); } } else { + const expression = /** @type {Expression} */ (context.visit(attribute.expression)); + if ( dev && - expression.type === 'MemberExpression' && - context.state.analysis.runes && - !is_ignored(node, 'binding_property_non_reactive') + attribute.name !== 'this' && + !is_ignored(node, 'ownership_invalid_binding') && + // bind:x={() => x.y, y => x.y = y} will be handled by the assignment expression binding validation + attribute.expression.type !== 'SequenceExpression' ) { - validate_binding(context.state, attribute, expression); + const left = object(attribute.expression); + const binding = left && context.state.scope.get(left.name); + + if (binding?.kind === 'bindable_prop' || binding?.kind === 'prop') { + context.state.analysis.needs_mutation_validation = true; + binding_initializers.push( + b.stmt( + b.call( + '$$ownership_validator.binding', + b.literal(binding.node.name), + b.id(is_component_dynamic ? intermediate_name : component_name), + b.thunk(expression) + ) + ) + ); + } } - if (attribute.name === 'this') { - bind_this = attribute.expression; + if (expression.type === 'SequenceExpression') { + if (attribute.name === 'this') { + bind_this = attribute.expression; + } else { + const [get, set] = expression.expressions; + const get_id = b.id(context.state.scope.generate('bind_get')); + const set_id = b.id(context.state.scope.generate('bind_set')); + + context.state.init.push(b.var(get_id, get)); + context.state.init.push(b.var(set_id, set)); + + push_prop(b.get(attribute.name, [b.return(b.call(get_id))])); + push_prop(b.set(attribute.name, [b.stmt(b.call(set_id, b.id('$$value')))])); + } } else { - const is_store_sub = - attribute.expression.type === 'Identifier' && - context.state.scope.get(attribute.expression.name)?.kind === 'store_sub'; + if ( + dev && + expression.type === 'MemberExpression' && + context.state.analysis.runes && + !is_ignored(node, 'binding_property_non_reactive') + ) { + validate_binding(context.state, attribute, expression); + } + + if (attribute.name === 'this') { + bind_this = attribute.expression; + } else { + const is_store_sub = + attribute.expression.type === 'Identifier' && + context.state.scope.get(attribute.expression.name)?.kind === 'store_sub'; + + // Delay prop pushes so bindings come at the end, to avoid spreads overwriting them + if (is_store_sub) { + push_prop( + b.get(attribute.name, [ + b.stmt(b.call('$.mark_store_binding')), + b.return(expression) + ]), + true + ); + } else { + push_prop(b.get(attribute.name, [b.return(expression)]), true); + } + + const assignment = b.assignment( + '=', + /** @type {Pattern} */ (attribute.expression), + b.id('$$value') + ); - // Delay prop pushes so bindings come at the end, to avoid spreads overwriting them - if (is_store_sub) { push_prop( - b.get(attribute.name, [b.stmt(b.call('$.mark_store_binding')), b.return(expression)]), + b.set(attribute.name, [ + b.stmt(/** @type {Expression} */ (context.visit(assignment))) + ]), true ); - } else { - push_prop(b.get(attribute.name, [b.return(expression)]), true); } - - const assignment = b.assignment( - '=', - /** @type {Pattern} */ (attribute.expression), - b.id('$$value') - ); - - push_prop( - b.set(attribute.name, [b.stmt(/** @type {Expression} */ (context.visit(assignment)))]), - true - ); } } } else if (attribute.type === 'AttachTag') { diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js index 9bccf9e05e..a4b3439eae 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js @@ -5,6 +5,7 @@ import { empty_comment, build_attribute_value } from './utils.js'; import * as b from '#compiler/builders'; import { is_element_node } from '../../../../nodes.js'; import { dev } from '../../../../../state.js'; +import { init_spread_bindings } from '../../../shared/spread_bindings.js'; /** * @param {AST.Component | AST.SvelteComponent | AST.SvelteSelf} node @@ -93,7 +94,17 @@ export function build_inline_component(node, expression, context) { const value = build_attribute_value(attribute.value, context, false, true); push_prop(b.prop('init', b.key(attribute.name), value)); } else if (attribute.type === 'BindDirective' && attribute.name !== 'this') { - if (attribute.expression.type === 'SequenceExpression') { + if (attribute.expression.type === 'SpreadElement') { + const { get, set } = init_spread_bindings(attribute.expression, context); + + push_prop(b.get(attribute.name, [b.return(b.call(get))])); + push_prop( + b.set(attribute.name, [ + b.stmt(b.call(set, b.id('$$value'))), + b.stmt(b.assignment('=', b.id('$$settled'), b.false)) + ]) + ); + } else if (attribute.expression.type === 'SequenceExpression') { const [get, set] = /** @type {SequenceExpression} */ (context.visit(attribute.expression)) .expressions; const get_id = b.id(context.state.scope.generate('bind_get')); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-component/Child.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread-component/Child.svelte new file mode 100644 index 0000000000..5c8fd19fa4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-component/Child.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-component/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-spread-component/_config.js new file mode 100644 index 0000000000..dd5c387405 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-component/_config.js @@ -0,0 +1,26 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; +import { assert_ok } from '../../../suite'; + +export default test({ + async test({ assert, target, logs }) { + const [input, checkbox] = target.querySelectorAll('input'); + + input.value = '2'; + input.dispatchEvent(new window.Event('input')); + + flushSync(); + + assert.htmlEqual( + target.innerHTML, + `
` + ); + + assert.deepEqual(logs, ['b', '2', 'a', '2']); + + flushSync(() => { + checkbox.click(); + }); + assert.deepEqual(logs, ['b', '2', 'a', '2', 'check', false]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-component/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread-component/main.svelte new file mode 100644 index 0000000000..7dc2aaf317 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-component/main.svelte @@ -0,0 +1,30 @@ + + + + + + +
+ +
From 805ca34a965a946af065e066df0e308c1c90d9b7 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 29 Jul 2025 17:13:03 +0200 Subject: [PATCH 09/19] component props --- .../src/compiler/phases/2-analyze/visitors/BindDirective.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js index dbb05bc5ef..a4aa13ad67 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js @@ -158,13 +158,11 @@ export function BindDirective(node, context) { return; } - // Handle spread syntax for bind directives: bind:value={...bindings} if (node.expression.type === 'SpreadElement') { if (node.name === 'group') { e.bind_group_invalid_expression(node); } - // Validate that the spread is applied to a valid expression that returns an array const argument = node.expression.argument; if ( argument.type !== 'Identifier' && From 4c3c658bfa0e6252af33304a3e567096668f9c34 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:19:01 +0200 Subject: [PATCH 10/19] fix bind:group --- .../client/visitors/BindDirective.js | 75 +++++++++---------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js index 19b7dda348..c5fca8dda2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js @@ -14,6 +14,11 @@ import { init_spread_bindings } from '../../shared/spread_bindings.js'; * @param {ComponentContext} context */ export function BindDirective(node, context) { + const expression = /** @type {Expression} */ (context.visit(node.expression)); + const property = binding_properties[node.name]; + + const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1)); + let get, set; // Handle SpreadElement by creating a variable declaration before visiting @@ -21,53 +26,45 @@ export function BindDirective(node, context) { const { get: getter, set: setter } = init_spread_bindings(node.expression, context); get = getter; set = setter; + } else if (expression.type === 'SequenceExpression') { + [get, set] = expression.expressions; } else { - const expression = /** @type {Expression} */ (context.visit(node.expression)); - - if (expression.type === 'SequenceExpression') { - [get, set] = expression.expressions; - } else { - if ( - dev && - context.state.analysis.runes && - expression.type === 'MemberExpression' && - (node.name !== 'this' || - context.path.some( - ({ type }) => - type === 'IfBlock' || - type === 'EachBlock' || - type === 'AwaitBlock' || - type === 'KeyBlock' - )) && - !is_ignored(node, 'binding_property_non_reactive') - ) { - validate_binding(context.state, node, expression); - } + if ( + dev && + context.state.analysis.runes && + expression.type === 'MemberExpression' && + (node.name !== 'this' || + context.path.some( + ({ type }) => + type === 'IfBlock' || + type === 'EachBlock' || + type === 'AwaitBlock' || + type === 'KeyBlock' + )) && + !is_ignored(node, 'binding_property_non_reactive') + ) { + validate_binding(context.state, node, expression); + } - get = b.thunk(expression); + get = b.thunk(expression); - /** @type {Expression | undefined} */ - set = b.unthunk( - b.arrow( - [b.id('$$value')], - /** @type {Expression} */ ( - context.visit( - b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value')) - ) + /** @type {Expression | undefined} */ + set = b.unthunk( + b.arrow( + [b.id('$$value')], + /** @type {Expression} */ ( + context.visit( + b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value')) ) ) - ); + ) + ); - if (get === set) { - set = undefined; - } + if (get === set) { + set = undefined; } } - const property = binding_properties[node.name]; - - const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1)); - /** @type {CallExpression} */ let call; @@ -231,7 +228,7 @@ export function BindDirective(node, context) { if (value !== undefined) { group_getter = b.thunk( - b.block([b.stmt(build_attribute_value(value, context).value), b.return(get)]) + b.block([b.stmt(build_attribute_value(value, context).value), b.return(b.call(get))]) ); } } From c1f14c11e8f90252872817b520186eabd0847928 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:31:36 +0200 Subject: [PATCH 11/19] update docs --- documentation/docs/03-template-syntax/12-bind.md | 16 ++++++++++++++++ .../98-reference/.generated/compile-errors.md | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/documentation/docs/03-template-syntax/12-bind.md b/documentation/docs/03-template-syntax/12-bind.md index de57815687..e75f520b01 100644 --- a/documentation/docs/03-template-syntax/12-bind.md +++ b/documentation/docs/03-template-syntax/12-bind.md @@ -40,6 +40,22 @@ In the case of readonly bindings like [dimension bindings](#Dimensions), the `ge > [!NOTE] > Function bindings are available in Svelte 5.9.0 and newer. +If you already have a tuple [get, set] or an object with `get` and/or `set` functions, you can use the spread syntax to bind them directly, instead of destructuring them beforehand. +This is especially handy when using helpers that return getter/setter pairs. + +```svelte + + + +``` + ## `` A `bind:value` directive on an `` element binds the input's `value` property: diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 957a9f67c7..640b0c125c 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -93,7 +93,7 @@ Cannot `bind:group` to a snippet parameter ### bind_invalid_expression ``` -Can only bind to an Identifier or MemberExpression or a `{get, set}` pair +Can only bind to an Identifier, a MemberExpression, a SpreadElement, or a `{get, set}` pair ``` ### bind_invalid_name From c848429de9a2691f117a8b14b55745cba314033b Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:41:37 +0200 Subject: [PATCH 12/19] changeset --- .changeset/tall-donkeys-sit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tall-donkeys-sit.md diff --git a/.changeset/tall-donkeys-sit.md b/.changeset/tall-donkeys-sit.md new file mode 100644 index 0000000000..5eb42e4575 --- /dev/null +++ b/.changeset/tall-donkeys-sit.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: support for spreading function bindings From 2c6316bcae9589e08ff94748e288ab442d55e45c Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:53:26 +0200 Subject: [PATCH 13/19] revert unecessary change --- .../phases/3-transform/client/visitors/BindDirective.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js index c5fca8dda2..8fdecd76e1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js @@ -228,7 +228,7 @@ export function BindDirective(node, context) { if (value !== undefined) { group_getter = b.thunk( - b.block([b.stmt(build_attribute_value(value, context).value), b.return(b.call(get))]) + b.block([b.stmt(build_attribute_value(value, context).value), b.return(expression)]) ); } } From 03b1d7758d8e3be1cc6d8fd17ff63ea3ba406130 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:59:51 +0200 Subject: [PATCH 14/19] clean up --- .../phases/3-transform/server/visitors/shared/component.js | 7 +------ packages/svelte/src/compiler/utils/builders.js | 4 ++-- packages/svelte/src/internal/server/index.js | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js index a4b3439eae..d2ba1091f4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js @@ -98,12 +98,7 @@ export function build_inline_component(node, expression, context) { const { get, set } = init_spread_bindings(attribute.expression, context); push_prop(b.get(attribute.name, [b.return(b.call(get))])); - push_prop( - b.set(attribute.name, [ - b.stmt(b.call(set, b.id('$$value'))), - b.stmt(b.assignment('=', b.id('$$settled'), b.false)) - ]) - ); + push_prop(b.set(attribute.name, [b.stmt(b.call(set, b.id('$$value')))])); } else if (attribute.expression.type === 'SequenceExpression') { const [get, set] = /** @type {SequenceExpression} */ (context.visit(attribute.expression)) .expressions; diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 6701884c9f..56a5f31ffe 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -649,13 +649,13 @@ function return_builder(argument = null) { } /** - * @param {string | ESTree.TemplateLiteral} str + * @param {string} str * @returns {ESTree.ThrowStatement} */ export function throw_error(str) { return { type: 'ThrowStatement', - argument: new_builder('Error', typeof str === 'string' ? literal(str) : str) + argument: new_builder('Error', literal(str)) }; } diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 7cfe15cae9..37aadabc32 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -511,7 +511,7 @@ export { assign_payload, copy_payload } from './payload.js'; export { snapshot } from '../shared/clone.js'; -export { fallback, to_array, noop } from '../shared/utils.js'; +export { fallback, to_array } from '../shared/utils.js'; export { invalid_default_snippet, From 80540843c294da7fce1ad2c0e6f766f733f3d445 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 19:08:08 +0200 Subject: [PATCH 15/19] docs formatting --- documentation/docs/03-template-syntax/12-bind.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/03-template-syntax/12-bind.md b/documentation/docs/03-template-syntax/12-bind.md index e75f520b01..1772d5b600 100644 --- a/documentation/docs/03-template-syntax/12-bind.md +++ b/documentation/docs/03-template-syntax/12-bind.md @@ -40,7 +40,7 @@ In the case of readonly bindings like [dimension bindings](#Dimensions), the `ge > [!NOTE] > Function bindings are available in Svelte 5.9.0 and newer. -If you already have a tuple [get, set] or an object with `get` and/or `set` functions, you can use the spread syntax to bind them directly, instead of destructuring them beforehand. +If you already have a `[get, set]` tuple or an object with `get` and/or `set` functions, you can use the spread syntax to bind them directly, instead of destructuring them beforehand. This is especially handy when using helpers that return getter/setter pairs. ```svelte From 92e9e79c6c0d46cdb66e2c4431b3c5f09c94fe53 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 21:56:50 +0200 Subject: [PATCH 16/19] register error properly --- documentation/docs/98-reference/.generated/shared-errors.md | 6 ++++++ packages/svelte/messages/compile-errors/template.md | 2 +- packages/svelte/messages/shared-errors/errors.md | 4 ++++ packages/svelte/src/compiler/errors.js | 4 ++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index de34b3f5da..338aa9b090 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -56,6 +56,12 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` ``` +### invalid_spread_bindings + +``` +`%name%` must be a function or `undefined` +``` + ### lifecycle_outside_component ``` diff --git a/packages/svelte/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md index 0569f63ad3..54ecd94806 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -60,7 +60,7 @@ ## bind_invalid_expression -> Can only bind to an Identifier or MemberExpression or a `{get, set}` pair +> Can only bind to an Identifier, a MemberExpression, a SpreadElement, or a `{get, set}` pair ## bind_invalid_name diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index f9160671d3..fde6169c0b 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -48,6 +48,10 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P > A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` +## invalid_spread_bindings + +> `%name%` must be a function or `undefined` + ## lifecycle_outside_component > `%name%(...)` can only be used during component initialisation diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index e763a6e073..bb11544664 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -830,12 +830,12 @@ export function bind_group_invalid_snippet_parameter(node) { } /** - * Can only bind to an Identifier or MemberExpression or a `{get, set}` pair + * Can only bind to an Identifier, a MemberExpression, a SpreadElement, or a `{get, set}` pair * @param {null | number | NodeLike} node * @returns {never} */ export function bind_invalid_expression(node) { - e(node, 'bind_invalid_expression', `Can only bind to an Identifier or MemberExpression or a \`{get, set}\` pair\nhttps://svelte.dev/e/bind_invalid_expression`); + e(node, 'bind_invalid_expression', `Can only bind to an Identifier, a MemberExpression, a SpreadElement, or a \`{get, set}\` pair\nhttps://svelte.dev/e/bind_invalid_expression`); } /** From dfcc5f682913a100c38d804be14c370bee3931b2 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 21:58:33 +0200 Subject: [PATCH 17/19] use BindDirective metadata to mark a spread element instead of setting the SpreadElement as the expression --- .../compiler/phases/1-parse/state/element.js | 34 ++++++------------ .../2-analyze/visitors/BindDirective.js | 14 ++------ .../client/visitors/BindDirective.js | 3 +- .../client/visitors/RegularElement.js | 2 +- .../client/visitors/shared/component.js | 7 ++-- .../client/visitors/shared/utils.js | 7 ++-- .../server/visitors/shared/component.js | 2 +- .../server/visitors/shared/element.js | 5 ++- .../3-transform/shared/spread_bindings.js | 10 +++--- .../src/compiler/types/legacy-nodes.d.ts | 5 +-- .../svelte/src/compiler/types/template.d.ts | 3 +- packages/svelte/src/internal/shared/errors.js | 36 +++++++++---------- packages/svelte/types/index.d.ts | 4 +-- 13 files changed, 56 insertions(+), 76 deletions(-) diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index 731f4b5997..68db2c9df1 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -1,4 +1,4 @@ -/** @import { Expression, SpreadElement } from 'estree' */ +/** @import { Expression } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Parser } from '../index.js' */ import { is_void } from '../../../../utils.js'; @@ -618,15 +618,6 @@ function read_attribute(parser) { e.directive_missing_name({ start, end: start + colon_index + 1 }, name); } - if ( - type !== 'BindDirective' && - value !== true && - 'metadata' in value && - value.metadata.expression.has_spread - ) { - e.directive_invalid_value(value.start); - } - if (type === 'StyleDirective') { return { start, @@ -643,7 +634,7 @@ function read_attribute(parser) { const first_value = value === true ? undefined : Array.isArray(value) ? value[0] : value; - /** @type {Expression | SpreadElement | null} */ + /** @type {Expression | null} */ let expression = null; if (first_value) { @@ -655,29 +646,26 @@ function read_attribute(parser) { // TODO throw a parser error in a future version here if this `[ExpressionTag]` instead of `ExpressionTag`, // which means stringified value, which isn't allowed for some directives? expression = first_value.expression; - - if (type === 'BindDirective' && first_value.metadata.expression.has_spread) { - expression = { - type: 'SpreadElement', - start: first_value.start, - end: first_value.end, - argument: expression - }; - } } } - /** @type {AST.Directive} */ - const directive = { + const directive = /** @type {AST.Directive} */ ({ start, end, type, name: directive_name, + modifiers: [], expression, metadata: { expression: create_expression_metadata() } - }; + }); + if (first_value?.metadata.expression.has_spread) { + if (directive.type !== 'BindDirective') { + e.directive_invalid_value(first_value.start); + } + directive.metadata.spread_binding = true; + } // @ts-expect-error we do this separately from the declaration to avoid upsetting typescript directive.modifiers = modifiers; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js index a4aa13ad67..a562a0d46d 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js @@ -158,20 +158,11 @@ export function BindDirective(node, context) { return; } - if (node.expression.type === 'SpreadElement') { + if (node.metadata.spread_binding) { if (node.name === 'group') { e.bind_group_invalid_expression(node); } - const argument = node.expression.argument; - if ( - argument.type !== 'Identifier' && - argument.type !== 'MemberExpression' && - argument.type !== 'CallExpression' - ) { - e.bind_invalid_expression(node); - } - mark_subtree_dynamic(context.path); return; @@ -261,7 +252,8 @@ export function BindDirective(node, context) { node.metadata = { binding_group_name: group_name, - parent_each_blocks: each_blocks + parent_each_blocks: each_blocks, + spread_binding: false }; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js index 8fdecd76e1..a55ba2a37a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js @@ -21,8 +21,7 @@ export function BindDirective(node, context) { let get, set; - // Handle SpreadElement by creating a variable declaration before visiting - if (node.expression.type === 'SpreadElement') { + if (node.metadata.spread_binding) { const { get: getter, set: setter } = init_spread_bindings(node.expression, context); get = getter; set = setter; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 4296aa959e..c305a0cb6a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -414,7 +414,7 @@ function setup_select_synchronization(value_binding, context) { let bound = value_binding.expression; - if (bound.type === 'SequenceExpression') { + if (bound.type === 'SequenceExpression' || value_binding.metadata.spread_binding) { return; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index fc2d5b0833..5e678f4332 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -197,11 +197,14 @@ export function build_component(node, component_name, context) { push_prop(b.init(attribute.name, value)); } } else if (attribute.type === 'BindDirective') { - if (attribute.expression.type === 'SpreadElement') { + if (attribute.metadata.spread_binding) { const { get, set } = init_spread_bindings(attribute.expression, context); if (attribute.name === 'this') { - bind_this = attribute.expression; + bind_this = { + type: 'SpreadElement', + argument: attribute.expression + }; } else { push_prop(b.get(attribute.name, [b.return(b.call(get))]), true); push_prop(b.set(attribute.name, [b.stmt(b.call(set, b.id('$$value')))]), true); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index c29f70ef98..7a443693d3 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -212,7 +212,7 @@ export function parse_directive_name(name) { export function build_bind_this(expression, value, context) { const { state, visit } = context; if (expression.type === 'SpreadElement') { - const { get, set } = init_spread_bindings(expression, context); + const { get, set } = init_spread_bindings(expression.argument, context); return b.call('$.bind_this', value, set, get); } @@ -297,10 +297,7 @@ export function build_bind_this(expression, value, context) { * @param {MemberExpression} expression */ export function validate_binding(state, binding, expression) { - if ( - binding.expression.type === 'SequenceExpression' || - binding.expression.type === 'SpreadElement' - ) { + if (binding.expression.type === 'SequenceExpression' || binding.metadata.spread_binding) { return; } // If we are referencing a $store.foo then we don't need to add validation diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js index d2ba1091f4..050e5077bc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js @@ -94,7 +94,7 @@ export function build_inline_component(node, expression, context) { const value = build_attribute_value(attribute.value, context, false, true); push_prop(b.prop('init', b.key(attribute.name), value)); } else if (attribute.type === 'BindDirective' && attribute.name !== 'this') { - if (attribute.expression.type === 'SpreadElement') { + if (attribute.metadata.spread_binding) { const { get, set } = init_spread_bindings(attribute.expression, context); push_prop(b.get(attribute.name, [b.return(b.call(get))])); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js index 2f9b8ab81e..0bb4ca87a6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js @@ -119,8 +119,7 @@ export function build_element_attributes(node, context) { let expression = /** @type {Expression} */ (context.visit(attribute.expression)); - // Handle SpreadElement for bind directives - if (attribute.expression.type === 'SpreadElement') { + if (attribute.metadata.spread_binding) { const { get } = init_spread_bindings(attribute.expression, context); expression = b.call(get); } else if (expression.type === 'SequenceExpression') { @@ -134,7 +133,7 @@ export function build_element_attributes(node, context) { } else if ( attribute.name === 'group' && attribute.expression.type !== 'SequenceExpression' && - attribute.expression.type !== 'SpreadElement' + !attribute.metadata.spread_binding ) { const value_attribute = /** @type {AST.Attribute | undefined} */ ( node.attributes.find((attr) => attr.type === 'Attribute' && attr.name === 'value') diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js index cf496c8b85..6414be66b5 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js +++ b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js @@ -1,4 +1,4 @@ -/** @import { Expression, SpreadElement } from 'estree' */ +/** @import { Expression } from 'estree' */ /** @import { ComponentContext as ClientContext } from '../client/types.js' */ /** @import { ComponentContext as ServerContext } from '../server/types.js' */ import * as b from '#compiler/builders'; @@ -6,14 +6,14 @@ import { dev, source } from '../../../state.js'; /** * Initializes spread bindings for a SpreadElement in a bind directive. - * @param {SpreadElement} spread_expression + * @param {Expression} spread_expression * @param {ClientContext | ServerContext} context * @returns {{ get: Expression, set: Expression }} */ export function init_spread_bindings(spread_expression, { state, visit }) { - const visited_expression = /** @type {Expression} */ (visit(spread_expression.argument)); + const expression = /** @type {Expression} */ (visit(spread_expression)); const expression_text = dev - ? b.literal(source.slice(spread_expression.argument.start, spread_expression.argument.end)) + ? b.literal(source.slice(spread_expression.start, spread_expression.end)) : undefined; const id = state.scope.generate('$$spread_binding'); @@ -22,7 +22,7 @@ export function init_spread_bindings(spread_expression, { state, visit }) { state.init.push( b.const( b.array_pattern([get, set]), - b.call('$.validate_spread_bindings', visited_expression, expression_text) + b.call('$.validate_spread_bindings', expression, expression_text) ) ); diff --git a/packages/svelte/src/compiler/types/legacy-nodes.d.ts b/packages/svelte/src/compiler/types/legacy-nodes.d.ts index 389fc92332..259320ca40 100644 --- a/packages/svelte/src/compiler/types/legacy-nodes.d.ts +++ b/packages/svelte/src/compiler/types/legacy-nodes.d.ts @@ -7,7 +7,8 @@ import type { MemberExpression, ObjectExpression, Pattern, - SequenceExpression + SequenceExpression, + SpreadElement } from 'estree'; interface BaseNode { @@ -50,7 +51,7 @@ export interface LegacyBinding extends BaseNode { /** The 'x' in `bind:x` */ name: string; /** The y in `bind:x={y}` */ - expression: Identifier | MemberExpression | SequenceExpression; + expression: Identifier | MemberExpression | SequenceExpression | SpreadElement; } export interface LegacyBody extends BaseElement { diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index b3ae4fae5f..a48a7c15e7 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -212,11 +212,12 @@ export namespace AST { /** The 'x' in `bind:x` */ name: string; /** The y in `bind:x={y}` */ - expression: Identifier | MemberExpression | SequenceExpression | SpreadElement; + expression: Identifier | MemberExpression | SequenceExpression; /** @internal */ metadata: { binding_group_name: Identifier; parent_each_blocks: EachBlock[]; + spread_binding: boolean; }; } diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index ac06e510f0..a2056dd071 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -50,6 +50,23 @@ export function invalid_snippet_arguments() { } } +/** + * `%name%` must be a function or `undefined` + * @param {string} name + * @returns {never} + */ +export function invalid_spread_bindings(name) { + if (DEV) { + const error = new Error(`invalid_spread_bindings\n\`${name}\` must be a function or \`undefined\`\nhttps://svelte.dev/e/invalid_spread_bindings`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/invalid_spread_bindings`); + } +} + /** * `%name%(...)` can only be used during component initialisation * @param {string} name @@ -114,21 +131,4 @@ export function svelte_element_invalid_this_value() { } else { throw new Error(`https://svelte.dev/e/svelte_element_invalid_this_value`); } -} - -/** - * `%name%%member%` must be a function or `undefined` - * @param {string} name - * @returns {never} - */ -export function invalid_spread_bindings(name) { - if (DEV) { - const error = new Error(`invalid_spread_bindings\n\`${name}\` must be a function or \`undefined\`\nhttps://svelte.dev/e/invalid_spread_bindings`); - - error.name = 'Svelte error'; - - throw error; - } else { - throw new Error(`https://svelte.dev/e/invalid_spread_bindings`); - } -} +} \ No newline at end of file diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 90b5e18fb5..97e6f0f5a3 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -795,7 +795,7 @@ declare module 'svelte/attachments' { declare module 'svelte/compiler' { import type { SourceMap } from 'magic-string'; - import type { ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression, SpreadElement } from 'estree'; + import type { ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression } from 'estree'; import type { Location } from 'locate-character'; /** * `compile` converts your `.svelte` source code into a JavaScript module that exports a component @@ -1268,7 +1268,7 @@ declare module 'svelte/compiler' { /** The 'x' in `bind:x` */ name: string; /** The y in `bind:x={y}` */ - expression: Identifier | MemberExpression | SequenceExpression | SpreadElement; + expression: Identifier | MemberExpression | SequenceExpression; } /** A `class:` directive */ From 9c9a1a841cb18afde2e8ab3198a2aab6d0c505b3 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 23:30:30 +0200 Subject: [PATCH 18/19] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/svelte/src/compiler/phases/1-parse/state/element.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index 68db2c9df1..9651d956ba 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -654,7 +654,6 @@ function read_attribute(parser) { end, type, name: directive_name, - modifiers: [], expression, metadata: { expression: create_expression_metadata() From d4417cb24569fcc89ab3c8a1168ac2822b456ad6 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 23:51:15 +0200 Subject: [PATCH 19/19] clean up diff --- .../compiler/phases/1-parse/state/element.js | 3 +- .../2-analyze/visitors/BindDirective.js | 2 +- .../client/visitors/shared/component.js | 141 +++++++++--------- .../svelte/src/compiler/types/template.d.ts | 3 +- 4 files changed, 71 insertions(+), 78 deletions(-) diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index 9651d956ba..810513e4f9 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -818,9 +818,8 @@ function read_sequence(parser, done, location) { parser.allow_whitespace(); - const has_spread = parser.match('...'); + const has_spread = parser.eat('...'); if (has_spread) { - parser.eat('...', true); parser.allow_whitespace(); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js index a562a0d46d..f0efc56f96 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js @@ -253,7 +253,7 @@ export function BindDirective(node, context) { node.metadata = { binding_group_name: group_name, parent_each_blocks: each_blocks, - spread_binding: false + spread_binding: node.metadata.spread_binding }; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index 5e678f4332..74e5f1c989 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -197,6 +197,35 @@ export function build_component(node, component_name, context) { push_prop(b.init(attribute.name, value)); } } else if (attribute.type === 'BindDirective') { + const expression = /** @type {Expression} */ (context.visit(attribute.expression)); + + if ( + dev && + attribute.name !== 'this' && + !is_ignored(node, 'ownership_invalid_binding') && + // bind:x={() => x.y, y => x.y = y} and bind:x={...[() => x.y, y => x.y = y]} + // will be handled by the assignment expression binding validation + attribute.expression.type !== 'SequenceExpression' && + !attribute.metadata.spread_binding + ) { + const left = object(attribute.expression); + const binding = left && context.state.scope.get(left.name); + + if (binding?.kind === 'bindable_prop' || binding?.kind === 'prop') { + context.state.analysis.needs_mutation_validation = true; + binding_initializers.push( + b.stmt( + b.call( + '$$ownership_validator.binding', + b.literal(binding.node.name), + b.id(is_component_dynamic ? intermediate_name : component_name), + b.thunk(expression) + ) + ) + ); + } + } + if (attribute.metadata.spread_binding) { const { get, set } = init_spread_bindings(attribute.expression, context); @@ -209,91 +238,57 @@ export function build_component(node, component_name, context) { push_prop(b.get(attribute.name, [b.return(b.call(get))]), true); push_prop(b.set(attribute.name, [b.stmt(b.call(set, b.id('$$value')))]), true); } - } else { - const expression = /** @type {Expression} */ (context.visit(attribute.expression)); + } else if (expression.type === 'SequenceExpression') { + if (attribute.name === 'this') { + bind_this = attribute.expression; + } else { + const [get, set] = expression.expressions; + const get_id = b.id(context.state.scope.generate('bind_get')); + const set_id = b.id(context.state.scope.generate('bind_set')); + context.state.init.push(b.var(get_id, get)); + context.state.init.push(b.var(set_id, set)); + + push_prop(b.get(attribute.name, [b.return(b.call(get_id))])); + push_prop(b.set(attribute.name, [b.stmt(b.call(set_id, b.id('$$value')))])); + } + } else { if ( dev && - attribute.name !== 'this' && - !is_ignored(node, 'ownership_invalid_binding') && - // bind:x={() => x.y, y => x.y = y} will be handled by the assignment expression binding validation - attribute.expression.type !== 'SequenceExpression' + expression.type === 'MemberExpression' && + context.state.analysis.runes && + !is_ignored(node, 'binding_property_non_reactive') ) { - const left = object(attribute.expression); - const binding = left && context.state.scope.get(left.name); - - if (binding?.kind === 'bindable_prop' || binding?.kind === 'prop') { - context.state.analysis.needs_mutation_validation = true; - binding_initializers.push( - b.stmt( - b.call( - '$$ownership_validator.binding', - b.literal(binding.node.name), - b.id(is_component_dynamic ? intermediate_name : component_name), - b.thunk(expression) - ) - ) - ); - } + validate_binding(context.state, attribute, expression); } - if (expression.type === 'SequenceExpression') { - if (attribute.name === 'this') { - bind_this = attribute.expression; - } else { - const [get, set] = expression.expressions; - const get_id = b.id(context.state.scope.generate('bind_get')); - const set_id = b.id(context.state.scope.generate('bind_set')); - - context.state.init.push(b.var(get_id, get)); - context.state.init.push(b.var(set_id, set)); - - push_prop(b.get(attribute.name, [b.return(b.call(get_id))])); - push_prop(b.set(attribute.name, [b.stmt(b.call(set_id, b.id('$$value')))])); - } + if (attribute.name === 'this') { + bind_this = attribute.expression; } else { - if ( - dev && - expression.type === 'MemberExpression' && - context.state.analysis.runes && - !is_ignored(node, 'binding_property_non_reactive') - ) { - validate_binding(context.state, attribute, expression); - } - - if (attribute.name === 'this') { - bind_this = attribute.expression; - } else { - const is_store_sub = - attribute.expression.type === 'Identifier' && - context.state.scope.get(attribute.expression.name)?.kind === 'store_sub'; - - // Delay prop pushes so bindings come at the end, to avoid spreads overwriting them - if (is_store_sub) { - push_prop( - b.get(attribute.name, [ - b.stmt(b.call('$.mark_store_binding')), - b.return(expression) - ]), - true - ); - } else { - push_prop(b.get(attribute.name, [b.return(expression)]), true); - } - - const assignment = b.assignment( - '=', - /** @type {Pattern} */ (attribute.expression), - b.id('$$value') - ); + const is_store_sub = + attribute.expression.type === 'Identifier' && + context.state.scope.get(attribute.expression.name)?.kind === 'store_sub'; + // Delay prop pushes so bindings come at the end, to avoid spreads overwriting them + if (is_store_sub) { push_prop( - b.set(attribute.name, [ - b.stmt(/** @type {Expression} */ (context.visit(assignment))) - ]), + b.get(attribute.name, [b.stmt(b.call('$.mark_store_binding')), b.return(expression)]), true ); + } else { + push_prop(b.get(attribute.name, [b.return(expression)]), true); } + + const assignment = b.assignment( + '=', + /** @type {Pattern} */ (attribute.expression), + b.id('$$value') + ); + + push_prop( + b.set(attribute.name, [b.stmt(/** @type {Expression} */ (context.visit(assignment)))]), + true + ); } } } else if (attribute.type === 'AttachTag') { diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index a48a7c15e7..6c8701d6b9 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -15,8 +15,7 @@ import type { Program, ChainExpression, SimpleCallExpression, - SequenceExpression, - SpreadElement + SequenceExpression } from 'estree'; import type { Scope } from '../phases/scope'; import type { _CSS } from './css';