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 3 weeks 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 />
```
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.
## 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>`
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
```
### bind_group_invalid_expression
```
`bind:group` can only bind to an Identifier or MemberExpression
```
### 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
@ -94,6 +100,12 @@ Can only bind to an Identifier or MemberExpression
`bind:%name%` is not a valid binding. %explanation%
```
### bind_invalid_parens
```
`bind:%name%={get, set}` must not have surrounding parentheses
```
### bind_invalid_target
```

@ -50,9 +50,13 @@
> 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
> Can only bind to an Identifier or MemberExpression
> Can only bind to an Identifier or MemberExpression or a `{get, set}` pair
## bind_invalid_name
@ -60,6 +64,10 @@
> `bind:%name%` is not a valid binding. %explanation%
## bind_invalid_parens
> `bind:%name%={get, set}` must not have surrounding parentheses
## bind_invalid_target
> `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
* @returns {never}
*/
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`);
}
/**
* `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%
* @param {null | number | NodeLike} node

@ -17,102 +17,6 @@ import { is_content_editable_binding, is_svg } from '../../../../utils.js';
* @param {Context} 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);
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();
}

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

@ -450,6 +450,11 @@ function setup_select_synchronization(value_binding, context) {
if (context.state.analysis.runes) return;
let bound = value_binding.expression;
if (bound.type === 'SequenceExpression') {
return;
}
while (bound.type === 'MemberExpression') {
bound = /** @type {Identifier | MemberExpression} */ (bound.object);
}
@ -484,10 +489,7 @@ function setup_select_synchronization(value_binding, context) {
b.call(
'$.template_effect',
b.thunk(
b.block([
b.stmt(/** @type {Expression} */ (context.visit(value_binding.expression))),
b.stmt(invalidator)
])
b.block([b.stmt(/** @type {Expression} */ (context.visit(bound))), 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 { ComponentContext } from '../../types.js' */
import { dev, is_ignored } from '../../../../../state.js';
@ -44,7 +44,7 @@ export function build_component(node, component_name, context, anchor = context.
/** @type {Property[]} */
const custom_css_props = [];
/** @type {Identifier | MemberExpression | null} */
/** @type {Identifier | MemberExpression | SequenceExpression | null} */
let bind_this = null;
/** @type {ExpressionStatement[]} */
@ -174,24 +174,14 @@ export function build_component(node, component_name, context, anchor = context.
} else if (attribute.type === 'BindDirective') {
const expression = /** @type {Expression} */ (context.visit(attribute.expression));
if (
dev &&
expression.type === 'MemberExpression' &&
context.state.analysis.runes &&
!is_ignored(node, 'binding_property_non_reactive')
) {
validate_binding(context.state, attribute, expression);
}
if (attribute.name === 'this') {
bind_this = attribute.expression;
} else {
if (dev) {
if (dev && attribute.name !== 'this' && attribute.expression.type !== 'SequenceExpression') {
const left = object(attribute.expression);
let binding;
if (left?.type === 'Identifier') {
binding = context.state.scope.get(left.name);
}
// 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.
@ -209,6 +199,33 @@ export function build_component(node, component_name, context, anchor = context.
}
}
if (expression.type === 'SequenceExpression') {
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 {
if (
dev &&
expression.type === 'MemberExpression' &&
context.state.analysis.runes &&
!is_ignored(node, 'binding_property_non_reactive')
) {
validate_binding(context.state, attribute, expression);
}
if (attribute.name === 'this') {
bind_this = attribute.expression;
} else {
const is_store_sub =
attribute.expression.type === 'Identifier' &&
context.state.scope.get(attribute.expression.name)?.kind === 'store_sub';
@ -223,7 +240,12 @@ export function build_component(node, component_name, context, anchor = context.
push_prop(b.get(attribute.name, [b.return(expression)]), true);
}
const assignment = b.assignment('=', attribute.expression, b.id('$$value'));
const assignment = b.assignment(
'=',
/** @type {Pattern} */ (attribute.expression),
b.id('$$value')
);
push_prop(
b.set(attribute.name, [b.stmt(/** @type {Expression} */ (context.visit(assignment)))]),
true
@ -231,6 +253,7 @@ export function build_component(node, component_name, context, anchor = context.
}
}
}
}
delayed_props.forEach((fn) => fn());

@ -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 { ComponentClientTransformState } from '../../types' */
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.
* @param {Identifier | MemberExpression} expression
* @param {Identifier | MemberExpression | SequenceExpression} expression
* @param {Expression} value
* @param {import('zimmerframe').Context<SvelteNode, ComponentClientTransformState>} context
*/
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[]} */
const ids = [];
@ -224,6 +229,9 @@ export function build_bind_this(expression, value, { state, visit }) {
* @param {MemberExpression} 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
const left = object(binding.expression);
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 { ComponentContext } from '../../types.js' */
import { empty_comment, build_attribute_value } from './utils.js';
@ -92,6 +92,18 @@ 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') {
const [get, set] = /** @type {SequenceExpression} */ (context.visit(attribute.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 {
// Delay prop pushes so bindings come at the end, to avoid spreads overwriting them
push_prop(
b.get(attribute.name, [
@ -99,6 +111,7 @@ export function build_inline_component(node, expression, context) {
]),
true
);
push_prop(
b.set(attribute.name, [
b.stmt(
@ -112,6 +125,7 @@ export function build_inline_component(node, expression, context) {
);
}
}
}
delayed_props.forEach((fn) => fn());

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

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

@ -14,7 +14,8 @@ import type {
Pattern,
Program,
ChainExpression,
SimpleCallExpression
SimpleCallExpression,
SequenceExpression
} from 'estree';
import type { Scope } from '../phases/scope';
@ -187,7 +188,7 @@ export namespace AST {
/** The 'x' in `bind:x` */
name: string;
/** The y in `bind:x={y}` */
expression: Identifier | MemberExpression;
expression: Identifier | MemberExpression | SequenceExpression;
/** @internal */
metadata: {
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' {
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 { Location } from 'locate-character';
/**
@ -1047,7 +1047,7 @@ declare module 'svelte/compiler' {
/** The 'x' in `bind:x` */
name: string;
/** The y in `bind:x={y}` */
expression: Identifier | MemberExpression;
expression: Identifier | MemberExpression | SequenceExpression;
}
/** A `class:` directive */

Loading…
Cancel
Save