Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: add support for bind getter/setters #14307

Merged
merged 28 commits into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/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
Loading