Jack Goodall 4 days ago committed by GitHub
commit af5c1a8472
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: support for spreading function bindings

@ -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:

@ -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

@ -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
```

@ -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

@ -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

@ -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`);
}
/**

@ -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 = {

@ -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
};
}

@ -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 (

@ -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;
}

@ -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 {

@ -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<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);
@ -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

@ -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'));

@ -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')
);

@ -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 };
}

@ -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
};
}

@ -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 {

@ -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 {

@ -216,6 +216,7 @@ export namespace AST {
metadata: {
binding_group_name: Identifier;
parent_each_blocks: EachBlock[];
spread_binding: boolean;
};
}

@ -174,7 +174,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';

@ -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 };

@ -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

@ -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];
}

@ -0,0 +1,16 @@
<script>
let { a = $bindable() } = $props();
const bindings = $derived([
() => a,
(v) => {
console.log('b', v);
a = v;
}
]);
</script>
<input
type="value"
bind:value={...bindings}
/>

@ -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,
`<button>a: 2</button><input type="value"><div><input type="checkbox" ></div>`
);
assert.deepEqual(logs, ['b', '2', 'a', '2']);
flushSync(() => {
checkbox.click();
});
assert.deepEqual(logs, ['b', '2', 'a', '2', 'check', false]);
}
});

@ -0,0 +1,30 @@
<script>
import Child from './Child.svelte';
let a = $state(0);
const a_bindings = [
() => a,
(v) => {
console.log('a', v);
a = v;
}
]
let check = $state(true);
const check_bindings = [
() => check,
(v) => {
console.log('check', v);
check = v;
}
]
</script>
<button onclick={() => a++}>a: {a}</button>
<Child
bind:a={...a_bindings}
/>
<div>
<input type="checkbox" bind:checked={...check_bindings} />
</div>

@ -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, `<input type="checkbox" >`.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();
}

@ -0,0 +1,37 @@
<script>
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);
}
</script>
<input type="checkbox" {onchange} />
<input type="checkbox" bind:checked={...empty_bindings_array} {onchange} />
<input type="checkbox" bind:checked={...empty_bindings_object} {onchange} />
<input type="checkbox" bind:checked={...incompatible_bindings_object} {onchange} />
<input type="checkbox" bind:checked={...undefined_bindings_array} {onchange} />
<input type="checkbox" bind:checked={...undefined_bindings_object} {onchange} />
<input type="checkbox" bind:checked={...null_bindings_array} {onchange} />
<input type="checkbox" bind:checked={...null_bindings_object} {onchange} />

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
expect_unhandled_rejections: true,
compileOptions: {
dev: true
},
error: 'invalid_spread_bindings'
});

@ -0,0 +1,7 @@
<script>
function getInvalidBindings() {
return { get: 'not a function', set: 'not a function' };
}
</script>
<input type="checkbox" bind:checked={...getInvalidBindings()} />

@ -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, `<input type="checkbox" >`.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();
}

@ -0,0 +1,35 @@
<script>
let check = $state(true);
const get = () => check;
const set = (v) => {
console.log('check', v);
check = v;
};
const bindings = [get, set];
const nested = {deep: {
bindings: [get, set],}
};
function getArrayBindings() {
console.log('getArrayBindings');
return [get, set];
}
function getObjectBindings() {
console.log('getObjectBindings');
return { get, set };
}
</script>
<input type="checkbox" bind:checked={get, set} />
<input type="checkbox" bind:checked={...bindings} />
<input type="checkbox" bind:checked={...nested.deep.bindings} />
<input type="checkbox" bind:checked={...getArrayBindings()} />
<input type="checkbox" bind:checked={...(() => [get, set])()} />
<input type="checkbox" bind:checked={...getObjectBindings()} />
Loading…
Cancel
Save