Skip to content

Commit 578b5df

Browse files
committed
support spreading function bindings
1 parent 2e02868 commit 578b5df

File tree

10 files changed

+179
-45
lines changed

10 files changed

+179
-45
lines changed

packages/svelte/src/compiler/phases/1-parse/state/element.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,15 @@ function read_attribute(parser) {
618618
e.directive_missing_name({ start, end: start + colon_index + 1 }, name);
619619
}
620620

621+
if (
622+
type !== 'BindDirective' &&
623+
value !== true &&
624+
'metadata' in value &&
625+
value.metadata.expression.has_spread
626+
) {
627+
e.directive_invalid_value(value.start);
628+
}
629+
621630
if (type === 'StyleDirective') {
622631
return {
623632
start,
@@ -646,6 +655,17 @@ function read_attribute(parser) {
646655
// TODO throw a parser error in a future version here if this `[ExpressionTag]` instead of `ExpressionTag`,
647656
// which means stringified value, which isn't allowed for some directives?
648657
expression = first_value.expression;
658+
659+
// Handle spread syntax in bind directives
660+
if (type === 'BindDirective' && first_value.metadata.expression.has_spread) {
661+
// Create a SpreadElement to represent ...array syntax
662+
expression = {
663+
type: 'SpreadElement',
664+
start: first_value.start,
665+
end: first_value.end,
666+
argument: expression
667+
};
668+
}
649669
}
650670
}
651671

@@ -812,6 +832,13 @@ function read_sequence(parser, done, location) {
812832
flush(parser.index - 1);
813833

814834
parser.allow_whitespace();
835+
836+
const has_spread = parser.match('...');
837+
if (has_spread) {
838+
parser.eat('...', true);
839+
parser.allow_whitespace();
840+
}
841+
815842
const expression = read_expression(parser);
816843
parser.allow_whitespace();
817844
parser.eat('}', true);
@@ -827,6 +854,8 @@ function read_sequence(parser, done, location) {
827854
}
828855
};
829856

857+
chunk.metadata.expression.has_spread = has_spread;
858+
830859
chunks.push(chunk);
831860

832861
current_chunk = {

packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,27 @@ export function BindDirective(node, context) {
158158
return;
159159
}
160160

161+
// Handle spread syntax for bind directives: bind:value={...bindings}
162+
if (node.expression.type === 'SpreadElement') {
163+
if (node.name === 'group') {
164+
e.bind_group_invalid_expression(node);
165+
}
166+
167+
// Validate that the spread is applied to a valid expression that returns an array
168+
const argument = node.expression.argument;
169+
if (
170+
argument.type !== 'Identifier' &&
171+
argument.type !== 'MemberExpression' &&
172+
argument.type !== 'CallExpression'
173+
) {
174+
e.bind_invalid_expression(node);
175+
}
176+
177+
mark_subtree_dynamic(context.path);
178+
179+
return;
180+
}
181+
161182
validate_assignment(node, node.expression, context);
162183

163184
const assignee = node.expression;

packages/svelte/src/compiler/phases/3-transform/client/visitors/BindDirective.js

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -13,52 +13,67 @@ import { build_bind_this, validate_binding } from './shared/utils.js';
1313
* @param {ComponentContext} context
1414
*/
1515
export function BindDirective(node, context) {
16-
const expression = /** @type {Expression} */ (context.visit(node.expression));
17-
const property = binding_properties[node.name];
18-
19-
const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1));
20-
2116
let get, set;
22-
23-
if (expression.type === 'SequenceExpression') {
24-
[get, set] = expression.expressions;
17+
18+
// Handle SpreadElement by creating a variable declaration before visiting
19+
if (node.expression.type === 'SpreadElement') {
20+
// Generate a unique variable name for this spread binding
21+
const id = b.id(context.state.scope.generate('$$bindings'));
22+
23+
// Store the spread expression in a variable at the component init level
24+
const spread_expression = /** @type {Expression} */ (context.visit(node.expression.argument));
25+
context.state.init.push(b.const(id, spread_expression));
26+
27+
// Use member access to get getter and setter
28+
get = b.member(id, b.literal(0), true);
29+
set = b.member(id, b.literal(1), true);
2530
} else {
26-
if (
27-
dev &&
28-
context.state.analysis.runes &&
29-
expression.type === 'MemberExpression' &&
30-
(node.name !== 'this' ||
31-
context.path.some(
32-
({ type }) =>
33-
type === 'IfBlock' ||
34-
type === 'EachBlock' ||
35-
type === 'AwaitBlock' ||
36-
type === 'KeyBlock'
37-
)) &&
38-
!is_ignored(node, 'binding_property_non_reactive')
39-
) {
40-
validate_binding(context.state, node, expression);
41-
}
31+
const expression = /** @type {Expression} */ (context.visit(node.expression));
4232

43-
get = b.thunk(expression);
33+
if (expression.type === 'SequenceExpression') {
34+
[get, set] = expression.expressions;
35+
} else {
36+
if (
37+
dev &&
38+
context.state.analysis.runes &&
39+
expression.type === 'MemberExpression' &&
40+
(node.name !== 'this' ||
41+
context.path.some(
42+
({ type }) =>
43+
type === 'IfBlock' ||
44+
type === 'EachBlock' ||
45+
type === 'AwaitBlock' ||
46+
type === 'KeyBlock'
47+
)) &&
48+
!is_ignored(node, 'binding_property_non_reactive')
49+
) {
50+
validate_binding(context.state, node, expression);
51+
}
4452

45-
/** @type {Expression | undefined} */
46-
set = b.unthunk(
47-
b.arrow(
48-
[b.id('$$value')],
49-
/** @type {Expression} */ (
50-
context.visit(
51-
b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value'))
53+
get = b.thunk(expression);
54+
55+
/** @type {Expression | undefined} */
56+
set = b.unthunk(
57+
b.arrow(
58+
[b.id('$$value')],
59+
/** @type {Expression} */ (
60+
context.visit(
61+
b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value'))
62+
)
5263
)
5364
)
54-
)
55-
);
65+
);
5666

57-
if (get === set) {
58-
set = undefined;
67+
if (get === set) {
68+
set = undefined;
69+
}
5970
}
6071
}
6172

73+
const property = binding_properties[node.name];
74+
75+
const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1));
76+
6277
/** @type {CallExpression} */
6378
let call;
6479

@@ -222,7 +237,7 @@ export function BindDirective(node, context) {
222237

223238
if (value !== undefined) {
224239
group_getter = b.thunk(
225-
b.block([b.stmt(build_attribute_value(value, context).value), b.return(expression)])
240+
b.block([b.stmt(build_attribute_value(value, context).value), b.return(get)])
226241
);
227242
}
228243
}

packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { AssignmentExpression, Expression, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */
1+
/** @import { AssignmentExpression, Expression, Identifier, MemberExpression, SequenceExpression, SpreadElement, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */
22
/** @import { AST, ExpressionMetadata } from '#compiler' */
33
/** @import { ComponentClientTransformState, ComponentContext, Context } from '../../types' */
44
import { walk } from 'zimmerframe';
@@ -204,11 +204,25 @@ export function parse_directive_name(name) {
204204

205205
/**
206206
* Serializes `bind:this` for components and elements.
207-
* @param {Identifier | MemberExpression | SequenceExpression} expression
207+
* @param {Identifier | MemberExpression | SequenceExpression | SpreadElement} expression
208208
* @param {Expression} value
209209
* @param {import('zimmerframe').Context<AST.SvelteNode, ComponentClientTransformState>} context
210210
*/
211211
export function build_bind_this(expression, value, { state, visit }) {
212+
if (expression.type === 'SpreadElement') {
213+
// Generate a unique variable name for this spread binding
214+
const id = b.id(state.scope.generate('$$bindings'));
215+
216+
// Store the spread expression in a variable at the component init level
217+
const spread_expression = /** @type {Expression} */ (visit(expression.argument));
218+
state.init.push(b.const(id, spread_expression));
219+
220+
// Use member access to get getter and setter
221+
const get = b.member(id, b.literal(0), true);
222+
const set = b.member(id, b.literal(1), true);
223+
return b.call('$.bind_this', value, set, get);
224+
}
225+
212226
if (expression.type === 'SequenceExpression') {
213227
const [get, set] = /** @type {SequenceExpression} */ (visit(expression)).expressions;
214228
return b.call('$.bind_this', value, set, get);
@@ -290,7 +304,7 @@ export function build_bind_this(expression, value, { state, visit }) {
290304
* @param {MemberExpression} expression
291305
*/
292306
export function validate_binding(state, binding, expression) {
293-
if (binding.expression.type === 'SequenceExpression') {
307+
if (binding.expression.type === 'SequenceExpression' || binding.expression.type === 'SpreadElement') {
294308
return;
295309
}
296310
// If we are referencing a $store.foo then we don't need to add validation

packages/svelte/src/compiler/phases/nodes.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ export function create_expression_metadata() {
6767
has_call: false,
6868
has_member_expression: false,
6969
has_assignment: false,
70-
has_await: false
70+
has_await: false,
71+
has_spread: false
7172
};
7273
}
7374

packages/svelte/src/compiler/types/index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,8 @@ export interface ExpressionMetadata {
304304
has_member_expression: boolean;
305305
/** True if the expression includes an assignment or an update */
306306
has_assignment: boolean;
307+
/** True if the expression includes a spread element */
308+
has_spread: boolean;
307309
}
308310

309311
export interface StateField {

packages/svelte/src/compiler/types/template.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import type {
1515
Program,
1616
ChainExpression,
1717
SimpleCallExpression,
18-
SequenceExpression
18+
SequenceExpression,
19+
SpreadElement
1920
} from 'estree';
2021
import type { Scope } from '../phases/scope';
2122
import type { _CSS } from './css';
@@ -211,7 +212,7 @@ export namespace AST {
211212
/** The 'x' in `bind:x` */
212213
name: string;
213214
/** The y in `bind:x={y}` */
214-
expression: Identifier | MemberExpression | SequenceExpression;
215+
expression: Identifier | MemberExpression | SequenceExpression | SpreadElement;
215216
/** @internal */
216217
metadata: {
217218
binding_group_name: Identifier;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
async test({ assert, target, logs }) {
6+
const checkboxes = target.querySelectorAll('input');
7+
8+
// input.value = '2';
9+
// input.dispatchEvent(new window.Event('input'));
10+
11+
flushSync();
12+
13+
assert.htmlEqual(target.innerHTML, `<input type="checkbox" >`.repeat(3));
14+
15+
// assert.deepEqual(logs, ['b', '2', 'a', '2']);
16+
17+
flushSync(() => {
18+
checkboxes.forEach((checkbox) => checkbox.click());
19+
});
20+
assert.deepEqual(logs, ['getBindings', ...repeatArray(3, ['check', false])]);
21+
}
22+
});
23+
24+
/** @template T */
25+
function repeatArray(/** @type {number} */ times = 1, /** @type {T[]} */ array) {
26+
return /** @type {T[]} */ Array.from({ length: times }, () => array).flat();
27+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script>
2+
let check = $state(true);
3+
4+
let check_bindings = [
5+
() => check,
6+
(v) => {
7+
console.log('check', v);
8+
check = v;
9+
}
10+
];
11+
12+
function getBindings() {
13+
console.log('getBindings');
14+
return check_bindings;
15+
}
16+
</script>
17+
18+
19+
<input type="checkbox" bind:checked={check_bindings[0], check_bindings[1]} />
20+
21+
<input type="checkbox" bind:checked={...check_bindings} />
22+
23+
<!-- <input type="checkbox" bind:checked={...check_bindings} /> -->
24+
<input type="checkbox" bind:checked={...getBindings()} />

packages/svelte/types/index.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,7 @@ declare module 'svelte/attachments' {
795795

796796
declare module 'svelte/compiler' {
797797
import type { SourceMap } from 'magic-string';
798-
import type { ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression } from 'estree';
798+
import type { ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression, SpreadElement } from 'estree';
799799
import type { Location } from 'locate-character';
800800
/**
801801
* `compile` converts your `.svelte` source code into a JavaScript module that exports a component
@@ -1268,7 +1268,7 @@ declare module 'svelte/compiler' {
12681268
/** The 'x' in `bind:x` */
12691269
name: string;
12701270
/** The y in `bind:x={y}` */
1271-
expression: Identifier | MemberExpression | SequenceExpression;
1271+
expression: Identifier | MemberExpression | SequenceExpression | SpreadElement;
12721272
}
12731273

12741274
/** A `class:` directive */

0 commit comments

Comments
 (0)