Skip to content

Commit

Permalink
feat: add support for bind getter/setters (#14307)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Dec 8, 2024
1 parent 1a0b822 commit 5771b45
Show file tree
Hide file tree
Showing 23 changed files with 471 additions and 217 deletions.
5 changes: 5 additions & 0 deletions .changeset/slimy-donkeys-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

feat: add support for bind getters/setters
24 changes: 24 additions & 0 deletions documentation/docs/03-template-syntax/11-bind.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

```
Expand Down
10 changes: 9 additions & 1 deletion packages/svelte/messages/compile-errors/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,24 @@

> 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

> `bind:%name%` is not a valid binding
> `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%
Expand Down
23 changes: 21 additions & 2 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

/**
Expand All @@ -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
Expand Down
214 changes: 118 additions & 96 deletions packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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();
}
Loading

0 comments on commit 5771b45

Please sign in to comment.