Skip to content

feat: support for spreading function bindings #16607

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tall-donkeys-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: support for spreading function bindings
16 changes: 16 additions & 0 deletions documentation/docs/03-template-syntax/12-bind.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script>
function bindLowerCase(value) {
return [
() => value.toLowerCase(),
(v) => value = v.toLowerCase()
];
}
</script>

<input bind:value={...bindLowerCase(value)} />
```

## `<input bind:value>`

A `bind:value` directive on an `<input>` element binds the input's `value` property:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/shared-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/messages/compile-errors/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/shared-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}

/**
Expand Down
19 changes: 16 additions & 3 deletions packages/svelte/src/compiler/phases/1-parse/state/element.js
Original file line number Diff line number Diff line change
Expand Up @@ -649,8 +649,7 @@ function read_attribute(parser) {
}
}

/** @type {AST.Directive} */
const directive = {
const directive = /** @type {AST.Directive} */ ({
start,
end,
type,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -827,6 +838,8 @@ function read_sequence(parser, done, location) {
}
};

chunk.metadata.expression.has_spread = has_spread;

chunks.push(chunk);

current_chunk = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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[]} */
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)
Expand Down Expand Up @@ -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<AST.SvelteNode, ComponentClientTransformState>} 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);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -118,15 +119,22 @@ 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]);
}

if (is_content_editable_binding(attribute.name)) {
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')
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
}
3 changes: 2 additions & 1 deletion packages/svelte/src/compiler/phases/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading