feat: add support for bind getter/setters (#14307)

* feat: add support for bind getters/setters

* different direction

* oops

* oops

* build

* add changeset and tests

* move validation

* add comment

* build

* bind:group error

* simpler to just keep it as a SequenceExpression

* fix

* lint

* fix

* move validation to visitor

* fix

* no longer needed

* fix

* parser changes are no longer needed

* simplify

* simplify

* update messages

* docs

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
pull/14598/head
Dominic Gannaway 12 months ago committed by GitHub
parent 1a0b822f48
commit 5771b455c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: add support for bind getters/setters

@ -12,10 +12,34 @@ The general syntax is `bind:property={expression}`, where `expression` is an _lv
<input bind:value /> <input bind:value />
``` ```
Svelte creates an event listener that updates the bound value. If an element already has a listener for the same event, that listener will be fired before the bound value is updated. Svelte creates an event listener that updates the bound value. If an element already has a listener for the same event, that listener will be fired before the bound value is updated.
Most bindings are _two-way_, meaning that changes to the value will affect the element and vice versa. A few bindings are _readonly_, meaning that changing their value will have no effect on the element. Most bindings are _two-way_, meaning that changes to the value will affect the element and vice versa. A few bindings are _readonly_, meaning that changing their value will have no effect on the element.
## Function bindings
You can also use `bind:property={get, set}`, where `get` and `set` are functions, allowing you to perform validation and transformation:
```svelte
<input bind:value={
() => value,
(v) => value = v.toLowerCase()}
/>
```
In the case of readonly bindings like [dimension bindings](#Dimensions), the `get` value should be `null`:
```svelte
<div
bind:clientWidth={null, redraw}
bind:clientHeight={null, redraw}
>...</div>
```
> [!NOTE]
> Function bindings are available in Svelte 5.9.0 and newer.
## `<input bind:value>` ## `<input bind:value>`
A `bind:value` directive on an `<input>` element binds the input's `value` property: A `bind:value` directive on an `<input>` element binds the input's `value` property:

@ -78,10 +78,16 @@ Sequence expressions are not allowed as attribute/directive values in runes mode
Attribute values containing `{...}` must be enclosed in quote marks, unless the value only contains the expression Attribute values containing `{...}` must be enclosed in quote marks, unless the value only contains the expression
``` ```
### bind_group_invalid_expression
```
`bind:group` can only bind to an Identifier or MemberExpression
```
### bind_invalid_expression ### bind_invalid_expression
``` ```
Can only bind to an Identifier or MemberExpression Can only bind to an Identifier or MemberExpression or a `{get, set}` pair
``` ```
### bind_invalid_name ### bind_invalid_name
@ -94,6 +100,12 @@ Can only bind to an Identifier or MemberExpression
`bind:%name%` is not a valid binding. %explanation% `bind:%name%` is not a valid binding. %explanation%
``` ```
### bind_invalid_parens
```
`bind:%name%={get, set}` must not have surrounding parentheses
```
### bind_invalid_target ### bind_invalid_target
``` ```

@ -50,9 +50,13 @@
> Attribute values containing `{...}` must be enclosed in quote marks, unless the value only contains the expression > Attribute values containing `{...}` must be enclosed in quote marks, unless the value only contains the expression
## bind_group_invalid_expression
> `bind:group` can only bind to an Identifier or MemberExpression
## bind_invalid_expression ## bind_invalid_expression
> Can only bind to an Identifier or MemberExpression > Can only bind to an Identifier or MemberExpression or a `{get, set}` pair
## bind_invalid_name ## bind_invalid_name
@ -60,6 +64,10 @@
> `bind:%name%` is not a valid binding. %explanation% > `bind:%name%` is not a valid binding. %explanation%
## bind_invalid_parens
> `bind:%name%={get, set}` must not have surrounding parentheses
## bind_invalid_target ## bind_invalid_target
> `bind:%name%` can only be used with %elements% > `bind:%name%` can only be used with %elements%

@ -716,12 +716,21 @@ export function attribute_unquoted_sequence(node) {
} }
/** /**
* Can only bind to an Identifier or MemberExpression * `bind:group` can only bind to an Identifier or MemberExpression
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function bind_group_invalid_expression(node) {
e(node, "bind_group_invalid_expression", "`bind:group` can only bind to an Identifier or MemberExpression");
}
/**
* Can only bind to an Identifier or MemberExpression or a `{get, set}` pair
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node
* @returns {never} * @returns {never}
*/ */
export function bind_invalid_expression(node) { export function bind_invalid_expression(node) {
e(node, "bind_invalid_expression", "Can only bind to an Identifier or MemberExpression"); e(node, "bind_invalid_expression", "Can only bind to an Identifier or MemberExpression or a `{get, set}` pair");
} }
/** /**
@ -735,6 +744,16 @@ export function bind_invalid_name(node, name, explanation) {
e(node, "bind_invalid_name", explanation ? `\`bind:${name}\` is not a valid binding. ${explanation}` : `\`bind:${name}\` is not a valid binding`); e(node, "bind_invalid_name", explanation ? `\`bind:${name}\` is not a valid binding. ${explanation}` : `\`bind:${name}\` is not a valid binding`);
} }
/**
* `bind:%name%={get, set}` must not have surrounding parentheses
* @param {null | number | NodeLike} node
* @param {string} name
* @returns {never}
*/
export function bind_invalid_parens(node, name) {
e(node, "bind_invalid_parens", `\`bind:${name}={get, set}\` must not have surrounding parentheses`);
}
/** /**
* `bind:%name%` can only be used with %elements% * `bind:%name%` can only be used with %elements%
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node

@ -17,102 +17,6 @@ import { is_content_editable_binding, is_svg } from '../../../../utils.js';
* @param {Context} context * @param {Context} context
*/ */
export function BindDirective(node, context) { export function BindDirective(node, context) {
validate_no_const_assignment(node, node.expression, context.state.scope, true);
const assignee = node.expression;
const left = object(assignee);
if (left === null) {
e.bind_invalid_expression(node);
}
const binding = context.state.scope.get(left.name);
if (assignee.type === 'Identifier') {
// reassignment
if (
node.name !== 'this' && // bind:this also works for regular variables
(!binding ||
(binding.kind !== 'state' &&
binding.kind !== 'raw_state' &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'each' &&
binding.kind !== 'store_sub' &&
!binding.updated)) // TODO wut?
) {
e.bind_invalid_value(node.expression);
}
if (context.state.analysis.runes && binding?.kind === 'each') {
e.each_item_invalid_assignment(node);
}
if (binding?.kind === 'snippet') {
e.snippet_parameter_assignment(node);
}
}
if (node.name === 'group') {
if (!binding) {
throw new Error('Cannot find declaration for bind:group');
}
// Traverse the path upwards and find all EachBlocks who are (indirectly) contributing to bind:group,
// i.e. one of their declarations is referenced in the binding. This allows group bindings to work
// correctly when referencing a variable declared in an EachBlock by using the index of the each block
// entries as keys.
const each_blocks = [];
const [keypath, expression_ids] = extract_all_identifiers_from_expression(node.expression);
let ids = expression_ids;
let i = context.path.length;
while (i--) {
const parent = context.path[i];
if (parent.type === 'EachBlock') {
const references = ids.filter((id) => parent.metadata.declarations.has(id.name));
if (references.length > 0) {
parent.metadata.contains_group_binding = true;
each_blocks.push(parent);
ids = ids.filter((id) => !references.includes(id));
ids.push(...extract_all_identifiers_from_expression(parent.expression)[1]);
}
}
}
// The identifiers that make up the binding expression form they key for the binding group.
// If the same identifiers in the same order are used in another bind:group, they will be in the same group.
// (there's an edge case where `bind:group={a[i]}` will be in a different group than `bind:group={a[j]}` even when i == j,
// but this is a limitation of the current static analysis we do; it also never worked in Svelte 4)
const bindings = expression_ids.map((id) => context.state.scope.get(id.name));
let group_name;
outer: for (const [[key, b], group] of context.state.analysis.binding_groups) {
if (b.length !== bindings.length || key !== keypath) continue;
for (let i = 0; i < bindings.length; i++) {
if (bindings[i] !== b[i]) continue outer;
}
group_name = group;
}
if (!group_name) {
group_name = context.state.scope.root.unique('binding_group');
context.state.analysis.binding_groups.set([keypath, bindings], group_name);
}
node.metadata = {
binding_group_name: group_name,
parent_each_blocks: each_blocks
};
}
if (binding?.kind === 'each' && binding.metadata?.inside_rest) {
w.bind_invalid_each_rest(binding.node, binding.node.name);
}
const parent = context.path.at(-1); const parent = context.path.at(-1);
if ( if (
@ -218,5 +122,123 @@ export function BindDirective(node, context) {
} }
} }
// When dealing with bind getters/setters skip the specific binding validation
// Group bindings aren't supported for getter/setters so we don't need to handle
// the metadata
if (node.expression.type === 'SequenceExpression') {
if (node.name === 'group') {
e.bind_group_invalid_expression(node);
}
let i = /** @type {number} */ (node.expression.start);
while (context.state.analysis.source[--i] !== '{') {
if (context.state.analysis.source[i] === '(') {
e.bind_invalid_parens(node, node.name);
}
}
if (node.expression.expressions.length !== 2) {
e.bind_invalid_expression(node);
}
return;
}
validate_no_const_assignment(node, node.expression, context.state.scope, true);
const assignee = node.expression;
const left = object(assignee);
if (left === null) {
e.bind_invalid_expression(node);
}
const binding = context.state.scope.get(left.name);
if (assignee.type === 'Identifier') {
// reassignment
if (
node.name !== 'this' && // bind:this also works for regular variables
(!binding ||
(binding.kind !== 'state' &&
binding.kind !== 'raw_state' &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'each' &&
binding.kind !== 'store_sub' &&
!binding.updated)) // TODO wut?
) {
e.bind_invalid_value(node.expression);
}
if (context.state.analysis.runes && binding?.kind === 'each') {
e.each_item_invalid_assignment(node);
}
if (binding?.kind === 'snippet') {
e.snippet_parameter_assignment(node);
}
}
if (node.name === 'group') {
if (!binding) {
throw new Error('Cannot find declaration for bind:group');
}
// Traverse the path upwards and find all EachBlocks who are (indirectly) contributing to bind:group,
// i.e. one of their declarations is referenced in the binding. This allows group bindings to work
// correctly when referencing a variable declared in an EachBlock by using the index of the each block
// entries as keys.
const each_blocks = [];
const [keypath, expression_ids] = extract_all_identifiers_from_expression(node.expression);
let ids = expression_ids;
let i = context.path.length;
while (i--) {
const parent = context.path[i];
if (parent.type === 'EachBlock') {
const references = ids.filter((id) => parent.metadata.declarations.has(id.name));
if (references.length > 0) {
parent.metadata.contains_group_binding = true;
each_blocks.push(parent);
ids = ids.filter((id) => !references.includes(id));
ids.push(...extract_all_identifiers_from_expression(parent.expression)[1]);
}
}
}
// The identifiers that make up the binding expression form they key for the binding group.
// If the same identifiers in the same order are used in another bind:group, they will be in the same group.
// (there's an edge case where `bind:group={a[i]}` will be in a different group than `bind:group={a[j]}` even when i == j,
// but this is a limitation of the current static analysis we do; it also never worked in Svelte 4)
const bindings = expression_ids.map((id) => context.state.scope.get(id.name));
let group_name;
outer: for (const [[key, b], group] of context.state.analysis.binding_groups) {
if (b.length !== bindings.length || key !== keypath) continue;
for (let i = 0; i < bindings.length; i++) {
if (bindings[i] !== b[i]) continue outer;
}
group_name = group;
}
if (!group_name) {
group_name = context.state.scope.root.unique('binding_group');
context.state.analysis.binding_groups.set([keypath, bindings], group_name);
}
node.metadata = {
binding_group_name: group_name,
parent_each_blocks: each_blocks
};
}
if (binding?.kind === 'each' && binding.metadata?.inside_rest) {
w.bind_invalid_each_rest(binding.node, binding.node.name);
}
context.next(); context.next();
} }

@ -1,4 +1,4 @@
/** @import { CallExpression, Expression, MemberExpression } from 'estree' */ /** @import { CallExpression, Expression, MemberExpression, Pattern } from 'estree' */
/** @import { AST, SvelteNode } from '#compiler' */ /** @import { AST, SvelteNode } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import { dev, is_ignored } from '../../../../state.js'; import { dev, is_ignored } from '../../../../state.js';
@ -13,41 +13,50 @@ import { build_bind_this, validate_binding } from './shared/utils.js';
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
export function BindDirective(node, context) { export function BindDirective(node, context) {
const expression = node.expression; const expression = /** @type {Expression} */ (context.visit(node.expression));
const property = binding_properties[node.name]; const property = binding_properties[node.name];
const parent = /** @type {SvelteNode} */ (context.path.at(-1)); const parent = /** @type {SvelteNode} */ (context.path.at(-1));
if ( let get, set;
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,
/**@type {MemberExpression} */ (context.visit(expression))
);
}
const get = b.thunk(/** @type {Expression} */ (context.visit(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} */ get = b.thunk(expression);
let set = b.unthunk(
b.arrow(
[b.id('$$value')],
/** @type {Expression} */ (context.visit(b.assignment('=', expression, b.id('$$value'))))
)
);
if (get === set) { /** @type {Expression | undefined} */
set = 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;
}
} }
/** @type {CallExpression} */ /** @type {CallExpression} */
@ -162,7 +171,7 @@ export function BindDirective(node, context) {
break; break;
case 'this': case 'this':
call = build_bind_this(expression, context.state.node, context); call = build_bind_this(node.expression, context.state.node, context);
break; break;
case 'textContent': case 'textContent':
@ -213,10 +222,7 @@ export function BindDirective(node, context) {
if (value !== undefined) { if (value !== undefined) {
group_getter = b.thunk( group_getter = b.thunk(
b.block([ b.block([b.stmt(build_attribute_value(value, context).value), b.return(expression)])
b.stmt(build_attribute_value(value, context).value),
b.return(/** @type {Expression} */ (context.visit(expression)))
])
); );
} }
} }

@ -450,6 +450,11 @@ function setup_select_synchronization(value_binding, context) {
if (context.state.analysis.runes) return; if (context.state.analysis.runes) return;
let bound = value_binding.expression; let bound = value_binding.expression;
if (bound.type === 'SequenceExpression') {
return;
}
while (bound.type === 'MemberExpression') { while (bound.type === 'MemberExpression') {
bound = /** @type {Identifier | MemberExpression} */ (bound.object); bound = /** @type {Identifier | MemberExpression} */ (bound.object);
} }
@ -484,10 +489,7 @@ function setup_select_synchronization(value_binding, context) {
b.call( b.call(
'$.template_effect', '$.template_effect',
b.thunk( b.thunk(
b.block([ b.block([b.stmt(/** @type {Expression} */ (context.visit(bound))), b.stmt(invalidator)])
b.stmt(/** @type {Expression} */ (context.visit(value_binding.expression))),
b.stmt(invalidator)
])
) )
) )
) )

@ -1,4 +1,4 @@
/** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Property, Statement } from 'estree' */ /** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, Statement } from 'estree' */
/** @import { AST, TemplateNode } from '#compiler' */ /** @import { AST, TemplateNode } from '#compiler' */
/** @import { ComponentContext } from '../../types.js' */ /** @import { ComponentContext } from '../../types.js' */
import { dev, is_ignored } from '../../../../../state.js'; import { dev, is_ignored } from '../../../../../state.js';
@ -44,7 +44,7 @@ export function build_component(node, component_name, context, anchor = context.
/** @type {Property[]} */ /** @type {Property[]} */
const custom_css_props = []; const custom_css_props = [];
/** @type {Identifier | MemberExpression | null} */ /** @type {Identifier | MemberExpression | SequenceExpression | null} */
let bind_this = null; let bind_this = null;
/** @type {ExpressionStatement[]} */ /** @type {ExpressionStatement[]} */
@ -174,60 +174,83 @@ export function build_component(node, component_name, context, anchor = context.
} else if (attribute.type === 'BindDirective') { } else if (attribute.type === 'BindDirective') {
const expression = /** @type {Expression} */ (context.visit(attribute.expression)); const expression = /** @type {Expression} */ (context.visit(attribute.expression));
if ( if (dev && attribute.name !== 'this' && attribute.expression.type !== 'SequenceExpression') {
dev && const left = object(attribute.expression);
expression.type === 'MemberExpression' && let binding;
context.state.analysis.runes &&
!is_ignored(node, 'binding_property_non_reactive') if (left?.type === 'Identifier') {
) { binding = context.state.scope.get(left.name);
validate_binding(context.state, attribute, expression); }
// Only run ownership addition on $state fields.
// Theoretically someone could create a `$state` while creating `$state.raw` or inside a `$derived.by`,
// but that feels so much of an edge case that it doesn't warrant a perf hit for the common case.
if (binding?.kind !== 'derived' && binding?.kind !== 'raw_state') {
binding_initializers.push(
b.stmt(
b.call(
b.id('$.add_owner_effect'),
b.thunk(expression),
b.id(component_name),
is_ignored(node, 'ownership_invalid_binding') && b.true
)
)
);
}
} }
if (attribute.name === 'this') { if (expression.type === 'SequenceExpression') {
bind_this = attribute.expression; 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 { } else {
if (dev) { if (
const left = object(attribute.expression); dev &&
let binding; expression.type === 'MemberExpression' &&
if (left?.type === 'Identifier') { context.state.analysis.runes &&
binding = context.state.scope.get(left.name); !is_ignored(node, 'binding_property_non_reactive')
} ) {
// Only run ownership addition on $state fields. validate_binding(context.state, attribute, expression);
// Theoretically someone could create a `$state` while creating `$state.raw` or inside a `$derived.by`, }
// but that feels so much of an edge case that it doesn't warrant a perf hit for the common case.
if (binding?.kind !== 'derived' && binding?.kind !== 'raw_state') { if (attribute.name === 'this') {
binding_initializers.push( bind_this = attribute.expression;
b.stmt( } else {
b.call( const is_store_sub =
b.id('$.add_owner_effect'), attribute.expression.type === 'Identifier' &&
b.thunk(expression), context.state.scope.get(attribute.expression.name)?.kind === 'store_sub';
b.id(component_name),
is_ignored(node, 'ownership_invalid_binding') && b.true // 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 is_store_sub = const assignment = b.assignment(
attribute.expression.type === 'Identifier' && '=',
context.state.scope.get(attribute.expression.name)?.kind === 'store_sub'; /** @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( 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 true
); );
} else {
push_prop(b.get(attribute.name, [b.return(expression)]), true);
} }
const assignment = b.assignment('=', attribute.expression, b.id('$$value'));
push_prop(
b.set(attribute.name, [b.stmt(/** @type {Expression} */ (context.visit(assignment)))]),
true
);
} }
} }
} }

@ -1,4 +1,4 @@
/** @import { Expression, ExpressionStatement, Identifier, MemberExpression, Statement, Super } from 'estree' */ /** @import { Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Statement, Super } from 'estree' */
/** @import { AST, SvelteNode } from '#compiler' */ /** @import { AST, SvelteNode } from '#compiler' */
/** @import { ComponentClientTransformState } from '../../types' */ /** @import { ComponentClientTransformState } from '../../types' */
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
@ -143,11 +143,16 @@ export function build_update_assignment(state, id, init, value, update) {
/** /**
* Serializes `bind:this` for components and elements. * Serializes `bind:this` for components and elements.
* @param {Identifier | MemberExpression} expression * @param {Identifier | MemberExpression | SequenceExpression} expression
* @param {Expression} value * @param {Expression} value
* @param {import('zimmerframe').Context<SvelteNode, ComponentClientTransformState>} context * @param {import('zimmerframe').Context<SvelteNode, ComponentClientTransformState>} context
*/ */
export function build_bind_this(expression, value, { state, visit }) { export function build_bind_this(expression, value, { state, visit }) {
if (expression.type === 'SequenceExpression') {
const [get, set] = /** @type {SequenceExpression} */ (visit(expression)).expressions;
return b.call('$.bind_this', value, set, get);
}
/** @type {Identifier[]} */ /** @type {Identifier[]} */
const ids = []; const ids = [];
@ -224,6 +229,9 @@ export function build_bind_this(expression, value, { state, visit }) {
* @param {MemberExpression} expression * @param {MemberExpression} expression
*/ */
export function validate_binding(state, binding, expression) { export function validate_binding(state, binding, expression) {
if (binding.expression.type === 'SequenceExpression') {
return;
}
// If we are referencing a $store.foo then we don't need to add validation // If we are referencing a $store.foo then we don't need to add validation
const left = object(binding.expression); const left = object(binding.expression);
const left_binding = left && state.scope.get(left.name); const left_binding = left && state.scope.get(left.name);

@ -1,4 +1,4 @@
/** @import { BlockStatement, Expression, Pattern, Property, Statement } from 'estree' */ /** @import { BlockStatement, Expression, Pattern, Property, SequenceExpression, Statement } from 'estree' */
/** @import { AST, TemplateNode } from '#compiler' */ /** @import { AST, TemplateNode } from '#compiler' */
/** @import { ComponentContext } from '../../types.js' */ /** @import { ComponentContext } from '../../types.js' */
import { empty_comment, build_attribute_value } from './utils.js'; import { empty_comment, build_attribute_value } from './utils.js';
@ -92,24 +92,38 @@ export function build_inline_component(node, expression, context) {
const value = build_attribute_value(attribute.value, context, false, true); const value = build_attribute_value(attribute.value, context, false, true);
push_prop(b.prop('init', b.key(attribute.name), value)); push_prop(b.prop('init', b.key(attribute.name), value));
} else if (attribute.type === 'BindDirective' && attribute.name !== 'this') { } else if (attribute.type === 'BindDirective' && attribute.name !== 'this') {
// Delay prop pushes so bindings come at the end, to avoid spreads overwriting them if (attribute.expression.type === 'SequenceExpression') {
push_prop( const [get, set] = /** @type {SequenceExpression} */ (context.visit(attribute.expression))
b.get(attribute.name, [ .expressions;
b.return(/** @type {Expression} */ (context.visit(attribute.expression))) const get_id = b.id(context.state.scope.generate('bind_get'));
]), const set_id = b.id(context.state.scope.generate('bind_set'));
true
); context.state.init.push(b.var(get_id, get));
push_prop( context.state.init.push(b.var(set_id, set));
b.set(attribute.name, [
b.stmt( push_prop(b.get(attribute.name, [b.return(b.call(get_id))]));
/** @type {Expression} */ ( push_prop(b.set(attribute.name, [b.stmt(b.call(set_id, b.id('$$value')))]));
context.visit(b.assignment('=', attribute.expression, b.id('$$value'))) } else {
) // Delay prop pushes so bindings come at the end, to avoid spreads overwriting them
), push_prop(
b.stmt(b.assignment('=', b.id('$$settled'), b.false)) b.get(attribute.name, [
]), b.return(/** @type {Expression} */ (context.visit(attribute.expression)))
true ]),
); true
);
push_prop(
b.set(attribute.name, [
b.stmt(
/** @type {Expression} */ (
context.visit(b.assignment('=', attribute.expression, b.id('$$value')))
)
),
b.stmt(b.assignment('=', b.id('$$settled'), b.false))
]),
true
);
}
} }
} }

@ -110,14 +110,17 @@ export function build_element_attributes(node, context) {
const binding = binding_properties[attribute.name]; const binding = binding_properties[attribute.name];
if (binding?.omit_in_ssr) continue; if (binding?.omit_in_ssr) continue;
let expression = /** @type {Expression} */ (context.visit(attribute.expression));
if (expression.type === 'SequenceExpression') {
expression = b.call(expression.expressions[0]);
}
if (is_content_editable_binding(attribute.name)) { if (is_content_editable_binding(attribute.name)) {
content = /** @type {Expression} */ (context.visit(attribute.expression)); content = expression;
} else if (attribute.name === 'value' && node.name === 'textarea') { } else if (attribute.name === 'value' && node.name === 'textarea') {
content = b.call( content = b.call('$.escape', expression);
'$.escape', } else if (attribute.name === 'group' && attribute.expression.type !== 'SequenceExpression') {
/** @type {Expression} */ (context.visit(attribute.expression))
);
} else if (attribute.name === 'group') {
const value_attribute = /** @type {AST.Attribute | undefined} */ ( const value_attribute = /** @type {AST.Attribute | undefined} */ (
node.attributes.find((attr) => attr.type === 'Attribute' && attr.name === 'value') node.attributes.find((attr) => attr.type === 'Attribute' && attr.name === 'value')
); );
@ -130,6 +133,7 @@ export function build_element_attributes(node, context) {
is_text_attribute(attr) && is_text_attribute(attr) &&
attr.value[0].data === 'checkbox' attr.value[0].data === 'checkbox'
); );
attributes.push( attributes.push(
create_attribute('checked', -1, -1, [ create_attribute('checked', -1, -1, [
{ {
@ -159,7 +163,7 @@ export function build_element_attributes(node, context) {
type: 'ExpressionTag', type: 'ExpressionTag',
start: -1, start: -1,
end: -1, end: -1,
expression: attribute.expression, expression,
metadata: { metadata: {
expression: create_expression_metadata() expression: create_expression_metadata()
} }

@ -6,7 +6,8 @@ import type {
Identifier, Identifier,
MemberExpression, MemberExpression,
ObjectExpression, ObjectExpression,
Pattern Pattern,
SequenceExpression
} from 'estree'; } from 'estree';
interface BaseNode { interface BaseNode {
@ -49,7 +50,7 @@ export interface LegacyBinding extends BaseNode {
/** The 'x' in `bind:x` */ /** The 'x' in `bind:x` */
name: string; name: string;
/** The y in `bind:x={y}` */ /** The y in `bind:x={y}` */
expression: Identifier | MemberExpression; expression: Identifier | MemberExpression | SequenceExpression;
} }
export interface LegacyBody extends BaseElement { export interface LegacyBody extends BaseElement {

@ -14,7 +14,8 @@ import type {
Pattern, Pattern,
Program, Program,
ChainExpression, ChainExpression,
SimpleCallExpression SimpleCallExpression,
SequenceExpression
} from 'estree'; } from 'estree';
import type { Scope } from '../phases/scope'; import type { Scope } from '../phases/scope';
@ -187,7 +188,7 @@ export namespace AST {
/** The 'x' in `bind:x` */ /** The 'x' in `bind:x` */
name: string; name: string;
/** The y in `bind:x={y}` */ /** The y in `bind:x={y}` */
expression: Identifier | MemberExpression; expression: Identifier | MemberExpression | SequenceExpression;
/** @internal */ /** @internal */
metadata: { metadata: {
binding_group_name: Identifier; binding_group_name: Identifier;

@ -0,0 +1,11 @@
<script>
let div = $state();
$effect(() => {
console.log(div?.textContent);
})
export const someData = '123';
</script>
<div bind:this={() => div, v => div = v}>123</div>

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
assert.htmlEqual(target.innerHTML, `<div>123</div>`);
assert.deepEqual(logs, ['123', '123']);
}
});

@ -0,0 +1,11 @@
<script>
import Child from './Child.svelte';
let child = $state();
$effect(() => {
console.log(child.someData);
});
</script>
<Child bind:this={() => child, v => child = v} />

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

@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
import { assert_ok } from '../../../suite';
export default test({
async test({ assert, target, logs }) {
const input = target.querySelector('input');
assert_ok(input);
input.value = '2';
input.dispatchEvent(new window.Event('input'));
flushSync();
assert.htmlEqual(target.innerHTML, `<button>a: 2</button><input type="value">`);
assert.deepEqual(logs, ['b', '2', 'a', '2']);
}
});

@ -0,0 +1,16 @@
<script>
import Child from './Child.svelte';
let a = $state(0);
</script>
<button onclick={() => a++}>a: {a}</button>
<Child
bind:a={() => a,
(v) => {
console.log('a', v);
a = v;
}}
/>

@ -0,0 +1,14 @@
[
{
"code": "bind_group_invalid_expression",
"message": "`bind:group` can only bind to an Identifier or MemberExpression",
"start": {
"line": 8,
"column": 38
},
"end": {
"line": 8,
"column": 84
}
}
]

@ -0,0 +1,12 @@
<script>
let values = $state([{ name: 'Alpha' }, { name: 'Beta' }, { name: 'Gamma' }]);
let selected = $state(values[1]);
</script>
{#each values as value}
<label>
<input type="radio" value="{value}" bind:group={() => selected, v => selected = v} /> {value.name}
</label>
{/each}
<p>{selected.name}</p>

@ -606,7 +606,7 @@ declare module 'svelte/animate' {
} }
declare module 'svelte/compiler' { declare module 'svelte/compiler' {
import type { Expression, Identifier, ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, MemberExpression, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression } from 'estree'; import type { Expression, Identifier, ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, MemberExpression, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression } from 'estree';
import type { SourceMap } from 'magic-string'; import type { SourceMap } from 'magic-string';
import type { Location } from 'locate-character'; import type { Location } from 'locate-character';
/** /**
@ -1047,7 +1047,7 @@ declare module 'svelte/compiler' {
/** The 'x' in `bind:x` */ /** The 'x' in `bind:x` */
name: string; name: string;
/** The y in `bind:x={y}` */ /** The y in `bind:x={y}` */
expression: Identifier | MemberExpression; expression: Identifier | MemberExpression | SequenceExpression;
} }
/** A `class:` directive */ /** A `class:` directive */

Loading…
Cancel
Save