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
diff --git a/documentation/docs/03-template-syntax/12-bind.md b/documentation/docs/03-template-syntax/12-bind.md
index de57815687dc..1772d5b600a4 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 `[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
+
+
+
+```
+
## ``
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 957a9f67c7b0..640b0c125cee 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
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 0569f63ad30d..54ecd948069b 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 e763a6e0733a..bb1154466432 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`);
}
/**
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..810513e4f979 100644
--- a/packages/svelte/src/compiler/phases/1-parse/state/element.js
+++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js
@@ -649,8 +649,7 @@ function read_attribute(parser) {
}
}
- /** @type {AST.Directive} */
- const directive = {
+ const directive = /** @type {AST.Directive} */ ({
start,
end,
type,
@@ -659,7 +658,13 @@ function read_attribute(parser) {
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;
@@ -812,6 +817,12 @@ function read_sequence(parser, done, location) {
flush(parser.index - 1);
parser.allow_whitespace();
+
+ const has_spread = parser.eat('...');
+ if (has_spread) {
+ parser.allow_whitespace();
+ }
+
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
@@ -827,6 +838,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..f0efc56f96f3 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,16 @@ export function BindDirective(node, context) {
return;
}
+ if (node.metadata.spread_binding) {
+ if (node.name === 'group') {
+ e.bind_group_invalid_expression(node);
+ }
+
+ mark_subtree_dynamic(context.path);
+
+ return;
+ }
+
validate_assignment(node, node.expression, context);
const assignee = node.expression;
@@ -242,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: node.metadata.spread_binding
};
}
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..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
@@ -7,6 +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 { init_spread_bindings } from '../../shared/spread_bindings.js';
/**
* @param {AST.BindDirective} node
@@ -20,7 +21,11 @@ export function BindDirective(node, context) {
let get, set;
- if (expression.type === 'SequenceExpression') {
+ if (node.metadata.spread_binding) {
+ 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 {
if (
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 4296aa959e87..c305a0cb6a6f 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
@@ -414,7 +414,7 @@ function setup_select_synchronization(value_binding, context) {
let bound = value_binding.expression;
- if (bound.type === 'SequenceExpression') {
+ if (bound.type === 'SequenceExpression' || value_binding.metadata.spread_binding) {
return;
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
index 7feeebdbbc4e..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
@@ -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[]} */
@@ -202,8 +203,10 @@ export function build_component(node, component_name, context) {
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'
+ // 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);
@@ -223,7 +226,19 @@ export function build_component(node, component_name, context) {
}
}
- if (expression.type === 'SequenceExpression') {
+ if (attribute.metadata.spread_binding) {
+ const { get, set } = init_spread_bindings(attribute.expression, context);
+
+ if (attribute.name === 'this') {
+ 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);
+ }
+ } else if (expression.type === 'SequenceExpression') {
if (attribute.name === 'this') {
bind_this = attribute.expression;
} 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 014547cf2de3..7a443693d337 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';
@@ -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 { init_spread_bindings } from '../../../shared/spread_bindings.js';
/**
* A utility for extracting complex expressions (such as call expressions)
@@ -204,11 +205,17 @@ 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 }) {
+export function build_bind_this(expression, value, context) {
+ const { state, visit } = context;
+ if (expression.type === 'SpreadElement') {
+ const { get, set } = init_spread_bindings(expression.argument, context);
+ 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 +297,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.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 9bccf9e05e05..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
@@ -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,12 @@ 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.metadata.spread_binding) {
+ 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')))]));
+ } 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/src/compiler/phases/3-transform/server/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
index 7207564ef983..0bb4ca87a648 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
@@ -21,6 +21,7 @@ import {
is_load_error_element
} from '../../../../../../utils.js';
import { escape_html } from '../../../../../../escaping.js';
+import { init_spread_bindings } from '../../../shared/spread_bindings.js';
const WHITESPACE_INSENSITIVE_ATTRIBUTES = ['class', 'style'];
@@ -118,7 +119,10 @@ export function build_element_attributes(node, context) {
let expression = /** @type {Expression} */ (context.visit(attribute.expression));
- if (expression.type === 'SequenceExpression') {
+ if (attribute.metadata.spread_binding) {
+ const { get } = init_spread_bindings(attribute.expression, context);
+ expression = b.call(get);
+ } else if (expression.type === 'SequenceExpression') {
expression = b.call(expression.expressions[0]);
}
@@ -126,7 +130,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.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
new file mode 100644
index 000000000000..6414be66b5e0
--- /dev/null
+++ b/packages/svelte/src/compiler/phases/3-transform/shared/spread_bindings.js
@@ -0,0 +1,30 @@
+/** @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';
+import { dev, source } from '../../../state.js';
+
+/**
+ * Initializes spread bindings for a SpreadElement in a bind directive.
+ * @param {Expression} spread_expression
+ * @param {ClientContext | ServerContext} context
+ * @returns {{ get: Expression, set: Expression }}
+ */
+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 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', expression, expression_text)
+ )
+ );
+
+ return { get, set };
+}
diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js
index 4874554ff0fb..6e29c23be8ee 100644
--- a/packages/svelte/src/compiler/phases/nodes.js
+++ b/packages/svelte/src/compiler/phases/nodes.js
@@ -67,7 +67,8 @@ export function create_expression_metadata() {
has_call: false,
has_member_expression: false,
has_assignment: false,
- has_await: false
+ has_await: false,
+ has_spread: false
};
}
diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts
index 6211e69bd3e1..7d8c51db0f6d 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/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 060df2dcb2a4..6c8701d6b97e 100644
--- a/packages/svelte/src/compiler/types/template.d.ts
+++ b/packages/svelte/src/compiler/types/template.d.ts
@@ -216,6 +216,7 @@ export namespace AST {
metadata: {
binding_group_name: Identifier;
parent_each_blocks: EachBlock[];
+ spread_binding: boolean;
};
}
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index c094c9e04449..34781d10391b 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -172,7 +172,8 @@ export {
validate_dynamic_element_tag,
validate_store,
validate_void_dynamic_element,
- prevent_snippet_stringification
+ prevent_snippet_stringification,
+ validate_spread_bindings
} from '../shared/validate.js';
export { strict_equals, equals } from './dev/equality.js';
export { log_if_contains_state } from './dev/console-log.js';
diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js
index 62ee22d6fcda..37aadabc321a 100644
--- a/packages/svelte/src/internal/server/index.js
+++ b/packages/svelte/src/internal/server/index.js
@@ -517,7 +517,8 @@ 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..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
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-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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
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..2161bdea2d7e
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/_config.js
@@ -0,0 +1,25 @@
+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, [
+ 'getArrayBindings',
+ 'getObjectBindings',
+ ...repeatArray(checkboxes.length, ['check', false])
+ ]);
+ }
+});
+
+/** @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/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte
new file mode 100644
index 000000000000..7f11eb81f6db
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-spread/main.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+ [get, set])()} />
+
+