From 03bdf5bf9ae6eabfcb6d15a704ada018025d72ed Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 14:13:47 +0200 Subject: [PATCH 01/23] 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 ed1b047d5556..f281c61f7579 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 9f02e7fa5a02..4ff27a68d8fe 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 506fd4aafd82..5a87d72dbcf1 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 ba140a153e91..d31b74283b2a 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 f4127db359fd..c0783854b55c 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -76,7 +76,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 e13c9a9e224e..785f17506b4d 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 060df2dcb2a4..b3ae4fae5fae 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 000000000000..ae971acaf89e --- /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 000000000000..13646ef83a93 --- /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 9888de59b2c2..448fc5d689c8 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 8d83ad316cb2a9d0c575373ba5083445350c022f Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 14:19:46 +0200 Subject: [PATCH 02/23] 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 5a87d72dbcf1..56792d8dac83 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 d31b74283b2a..00e89369281a 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 11c10f611c235749e7991120fc88af8b48a1637d Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 15:22:41 +0200 Subject: [PATCH 03/23] 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 56792d8dac83..74b3978f5d28 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 00e89369281a..1f3ae8e9f0a6 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 84692fca9c59..60efaed56014 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 @@ -22,6 +22,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']; @@ -119,7 +120,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]); } @@ -127,7 +132,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 000000000000..01afe26b4b60 --- /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 ae971acaf89e..d2b13c9b1922 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 13646ef83a93..d2cb8491e641 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 e5d5881145265a90b81cd3d7016c8c8d57657ffe Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 15:25:28 +0200 Subject: [PATCH 04/23] 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 d2b13c9b1922..db712b6e60c0 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 d2cb8491e641..bfd766c3b52d 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 c60b139273e551657ccd812209cb8f0a582486da Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Mon, 28 Jul 2025 18:02:27 +0200 Subject: [PATCH 05/23] 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 01afe26b4b60..c5727aead58a 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 db712b6e60c0..2161bdea2d7e 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 bfd766c3b52d..7f11eb81f6db 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 2db2ed35221145f17b04c7a36c406bfe56dd1b68 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 29 Jul 2025 16:42:39 +0200 Subject: [PATCH 06/23] 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 f281c61f7579..731f4b599719 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 4ff27a68d8fe..dbb05bc5ef6d 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 74b3978f5d28..19b7dda348ef 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 1f3ae8e9f0a6..b2b9963e0f39 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 60efaed56014..6cacbf052843 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 @@ -22,7 +22,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']; @@ -122,7 +122,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 c5727aead58a..03a463b5935c 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 03a946ff9c00..90969fc9b684 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 3c5409bcfec8..a20f5508e718 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -173,7 +173,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 3aa44f2daae9..37e6375d0b4d 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -520,13 +520,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 66685cb00b75..ac06e510f0d3 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 48e76f09589d..720baa03cb7d 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 000000000000..57cce72d3006 --- /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 000000000000..e1c37443f673 --- /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 000000000000..65c6deeda0b7 --- /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 000000000000..8dc8f03644f7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-error/main.svelte @@ -0,0 +1,7 @@ + + + From 2f3f0490870cad86d6e1bfc159415c18d8a902d3 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 29 Jul 2025 16:57:32 +0200 Subject: [PATCH 07/23] 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 03a463b5935c..cf496c8b8511 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 1796546a156b8a9cf737543fe5fd923c55684f49 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 29 Jul 2025 17:13:03 +0200 Subject: [PATCH 08/23] 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 7feeebdbbc4e..fc2d5b083376 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 9bccf9e05e05..a4b3439eaeed 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 000000000000..5c8fd19fa46f --- /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 000000000000..dd5c387405e0 --- /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 000000000000..7dc2aaf317e2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-component/main.svelte @@ -0,0 +1,30 @@ + + + + + + +
+ +
From 35c4d190617244800db36eaff68938bbf97fc328 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 29 Jul 2025 17:13:03 +0200 Subject: [PATCH 09/23] 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 dbb05bc5ef6d..a4aa13ad6768 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 80c0248d9ff29ca58064d971e495b31e0500030a Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:19:01 +0200 Subject: [PATCH 10/23] 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 19b7dda348ef..c5fca8dda274 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 e227a1d74c8d0aefd3d480f5bbe1e3318ecc3867 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:31:36 +0200 Subject: [PATCH 11/23] 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 de57815687dc..e75f520b01ba 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 b9c44163c906..3a795492b38b 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 947089165e970ff05df47e9736a6615abdb62226 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:41:37 +0200 Subject: [PATCH 12/23] 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 000000000000..5eb42e4575a3 --- /dev/null +++ b/.changeset/tall-donkeys-sit.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: support for spreading function bindings From 4d77c5dda46a68c223e91d41587a2ff07643e589 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:53:26 +0200 Subject: [PATCH 13/23] 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 c5fca8dda274..8fdecd76e1c1 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 63c5234016afa84be5aba65caa46f8fd21835d4e Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 18:59:51 +0200 Subject: [PATCH 14/23] 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 a4b3439eaeed..d2ba1091f4a7 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 90969fc9b684..03a946ff9c00 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 37e6375d0b4d..1d0614f1b3e7 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -520,7 +520,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 7cc9b199f258ce5d684b24a827fdd2d4325ed9dc Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 19:08:08 +0200 Subject: [PATCH 15/23] 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 e75f520b01ba..1772d5b600a4 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 5eb11a8a687498a2200e7378127377fc9b2986e0 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 21:56:50 +0200 Subject: [PATCH 16/23] 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 de34b3f5da7c..338aa9b0908c 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 dc26a027677c..da2965d97dce 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 f9160671d3f6..fde6169c0b50 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 44fc641ee52c..29cfcb2b79cb 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 85a51dac952821243df09694fb228d04b8fe959d Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 21:58:33 +0200 Subject: [PATCH 17/23] 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 731f4b599719..68db2c9df1f9 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 a4aa13ad6768..a562a0d46d1c 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 8fdecd76e1c1..a55ba2a37a9e 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 e90606165060..4bde0203dbfc 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 @@ -428,7 +428,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 fc2d5b083376..5e678f433206 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 b2b9963e0f39..3abf900da547 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 d2ba1091f4a7..050e5077bcb8 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 6cacbf052843..58be3eb247b7 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 @@ -120,8 +120,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') { @@ -135,7 +134,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 cf496c8b8511..6414be66b5e0 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 389fc923327a..259320ca400a 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 b3ae4fae5fae..a48a7c15e7e0 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 ac06e510f0d3..a2056dd071c7 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 448fc5d689c8..9888de59b2c2 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 33354d08b777aafb81fce8b57c28cacfcaa74947 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 23:30:30 +0200 Subject: [PATCH 18/23] =?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 68db2c9df1f9..9651d956badc 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 cc3c873165d6c5aad74872e061a0bf752d472537 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 12 Aug 2025 23:51:15 +0200 Subject: [PATCH 19/23] 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 9651d956badc..810513e4f979 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 a562a0d46d1c..f0efc56f96f3 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 5e678f433206..74e5f1c98968 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 a48a7c15e7e0..6c8701d6b97e 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'; From 5a3ed4ad0b5302d793324061aea7fbb68c9beb1c Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 2 Sep 2025 18:56:40 +0200 Subject: [PATCH 20/23] run bindings in derived --- .../3-transform/shared/spread_bindings.js | 16 +++++++++++----- .../samples/bind-spread-reactive/_config.js | 17 +++++++++++++++++ .../samples/bind-spread-reactive/main.svelte | 17 +++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-reactive/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-spread-reactive/main.svelte 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 6414be66b5e0..c9e93a6a9cf7 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 @@ -16,15 +16,21 @@ export function init_spread_bindings(spread_expression, { state, visit }) { ? b.literal(source.slice(spread_expression.start, spread_expression.end)) : undefined; - const id = state.scope.generate('$$spread_binding'); - const get = b.id(id + '_get'); - const set = b.id(id + '_set'); + const id = b.id(state.scope.generate('$$spread_binding')); state.init.push( b.const( - b.array_pattern([get, set]), - b.call('$.validate_spread_bindings', expression, expression_text) + id, + b.call( + '$.derived', + b.thunk(b.call('$.validate_spread_bindings', expression, expression_text)) + ) ) ); + const is_server = state.options.generate === 'server'; + const binding = is_server ? b.call(id) : b.call('$.get', id); + + const get = b.thunk(b.call(b.member(binding, b.literal(0), true))); + const set = b.thunk(b.call(b.member(binding, b.literal(1), true), b.id('$$value'))); return { get, set }; } diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-reactive/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-spread-reactive/_config.js new file mode 100644 index 000000000000..25ef3b134ad6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-reactive/_config.js @@ -0,0 +1,17 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + assert.htmlEqual(target.innerHTML, ``); + + assert.deepEqual(logs, [false]); + + const button = target.querySelector('button'); + + button?.click(); + flushSync(); + + assert.deepEqual(logs, [false, true]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-spread-reactive/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread-reactive/main.svelte new file mode 100644 index 000000000000..0ef8b57fa88d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-reactive/main.svelte @@ -0,0 +1,17 @@ + + + + + From e0491e9256477c3d894c01ebbad6d9d1473fed36 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Tue, 2 Sep 2025 19:00:10 +0200 Subject: [PATCH 21/23] pass value to setter --- .../compiler/phases/3-transform/shared/spread_bindings.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 c9e93a6a9cf7..bd0e7c455756 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 @@ -31,6 +31,9 @@ export function init_spread_bindings(spread_expression, { state, visit }) { const binding = is_server ? b.call(id) : b.call('$.get', id); const get = b.thunk(b.call(b.member(binding, b.literal(0), true))); - const set = b.thunk(b.call(b.member(binding, b.literal(1), true), b.id('$$value'))); + const set = b.arrow( + [b.id('$$value')], + b.call(b.member(binding, b.literal(1), true), b.id('$$value')) + ); return { get, set }; } From 2f1792e74faa24a326949b22714d4d602e29b841 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Sat, 13 Sep 2025 16:11:47 +0200 Subject: [PATCH 22/23] remove support for spreading objects --- .../src/compiler/phases/2-analyze/index.js | 4 ++- .../3-transform/shared/spread_bindings.js | 16 ++++++---- packages/svelte/src/internal/server/index.js | 2 +- .../svelte/src/internal/shared/validate.js | 11 ++++--- .../bind-spread-component/Child.svelte | 4 +-- .../samples/bind-spread-component/_config.js | 1 - .../samples/bind-spread-empty/main.svelte | 30 +++---------------- .../samples/bind-spread-error/main.svelte | 8 +---- .../samples/bind-spread/_config.js | 6 +--- .../samples/bind-spread/main.svelte | 19 +++++------- 10 files changed, 34 insertions(+), 67 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index de27c4623b71..a07e9b2c7ef8 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -716,7 +716,9 @@ export function analyze_component(root, source, options) { if ( type === 'FunctionDeclaration' || type === 'FunctionExpression' || - type === 'ArrowFunctionExpression' + type === 'ArrowFunctionExpression' || + (type === 'BindDirective' && + /** @type {AST.BindDirective} */ (path[i]).metadata.spread_binding) ) { continue inner; } 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 bd0e7c455756..d3cb4dc06107 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 @@ -12,9 +12,7 @@ import { dev, source } from '../../../state.js'; */ export function init_spread_bindings(spread_expression, { state, visit }) { const expression = /** @type {Expression} */ (visit(spread_expression)); - const expression_text = dev - ? b.literal(source.slice(spread_expression.start, spread_expression.end)) - : undefined; + const expression_text = b.literal(source.slice(spread_expression.start, spread_expression.end)); const id = b.id(state.scope.generate('$$spread_binding')); state.init.push( @@ -22,7 +20,9 @@ export function init_spread_bindings(spread_expression, { state, visit }) { id, b.call( '$.derived', - b.thunk(b.call('$.validate_spread_bindings', expression, expression_text)) + b.thunk( + dev ? b.call('$.validate_spread_bindings', expression, expression_text) : expression + ) ) ) ); @@ -30,10 +30,14 @@ export function init_spread_bindings(spread_expression, { state, visit }) { const is_server = state.options.generate === 'server'; const binding = is_server ? b.call(id) : b.call('$.get', id); - const get = b.thunk(b.call(b.member(binding, b.literal(0), true))); + const get = b.thunk( + b.call(b.logical('??', b.member(binding, b.literal(0), true), b.id('$.noop'))) + ); + const set = b.arrow( [b.id('$$value')], - b.call(b.member(binding, b.literal(1), true), b.id('$$value')) + b.call(b.logical('??', b.member(binding, b.literal(1), true), b.id('$.noop')), b.id('$$value')) ); + return { get, set }; } diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 1d0614f1b3e7..9d62213909aa 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -520,7 +520,7 @@ export { assign_payload, copy_payload } from './payload.js'; export { snapshot } from '../shared/clone.js'; -export { fallback, to_array } from '../shared/utils.js'; +export { noop, fallback, to_array } from '../shared/utils.js'; export { invalid_default_snippet, diff --git a/packages/svelte/src/internal/shared/validate.js b/packages/svelte/src/internal/shared/validate.js index 720baa03cb7d..49e5b2af8ed1 100644 --- a/packages/svelte/src/internal/shared/validate.js +++ b/packages/svelte/src/internal/shared/validate.js @@ -53,16 +53,15 @@ export function prevent_snippet_stringification(fn) { * @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; + const getter = spread_object[0]; + const setter = spread_object[1]; if (typeof getter !== 'function' && getter != null) { - e.invalid_spread_bindings(name + (is_array ? '[0]' : '.get')); + e.invalid_spread_bindings(name + '[0]'); } if (typeof setter !== 'function' && setter != null) { - e.invalid_spread_bindings(name + (is_array ? '[1]' : '.set')); + e.invalid_spread_bindings(name + '[1]'); } - return [getter ?? noop, setter ?? noop]; + return [getter, setter]; } 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 index 5c8fd19fa46f..7d26e1f8b161 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread-component/Child.svelte +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-component/Child.svelte @@ -1,13 +1,13 @@ - const empty_bindings_array = [] - const empty_bindings_object = {} - const incompatible_bindings_object = { - read() { - console.log('read'); - return true; - }, - write(v) { - console.log('write', v); - } - } - const undefined_bindings_array = [undefined, undefined]; - const undefined_bindings_object = { get: undefined, set: undefined }; - const null_bindings_array = [null, null]; - const null_bindings_object = { get: null, set: null }; - function onchange(event) { console.log('change', event.currentTarget.checked); } @@ -22,16 +6,10 @@ - - - - - - - + - + - + - + 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 index 8dc8f03644f7..305589d0fa99 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread-error/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread-error/main.svelte @@ -1,7 +1 @@ - - - + 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 2161bdea2d7e..77daccb80f81 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js @@ -11,11 +11,7 @@ export default test({ checkboxes.forEach((checkbox) => checkbox.click()); - assert.deepEqual(logs, [ - 'getArrayBindings', - 'getObjectBindings', - ...repeatArray(checkboxes.length, ['check', false]) - ]); + assert.deepEqual(logs, ['getBindings', ...repeatArray(checkboxes.length, ['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 7f11eb81f6db..e705fd67fb1b 100644 --- a/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte @@ -7,19 +7,16 @@ check = v; }; const bindings = [get, set]; - const nested = {deep: { - bindings: [get, set],} + const nested = { + deep: { + bindings: [get, set], + }, }; - function getArrayBindings() { - console.log('getArrayBindings'); + function getBindings() { + console.log('getBindings'); return [get, set]; } - - function getObjectBindings() { - console.log('getObjectBindings'); - return { get, set }; - } @@ -28,8 +25,6 @@ - + [get, set])()} /> - - From 2825bcee84c1fd17bff907114d5f2684b8248466 Mon Sep 17 00:00:00 2001 From: Jack Goodall Date: Sat, 13 Sep 2025 16:40:54 +0200 Subject: [PATCH 23/23] update docs --- 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 1772d5b600a4..eafe6859bd11 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 `[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. +If you already have a `[get, set]` tuple, 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