Skip to content

Commit 7a2d201

Browse files
committed
fix: correctly determine bind:group members
- Previously, any each block parents where used as keys for the sub group. That's wrong, it should only be using each blocks whos declarations contribute to the `bind:group` expression. Fixes #10345 - Only the left-most identifier of the expression was used to determine the binding group. This is wrong, all identifiers within the expression need to be taken into account. Fixes #9947
1 parent 283366b commit 7a2d201

File tree

12 files changed

+184
-36
lines changed

12 files changed

+184
-36
lines changed

.changeset/cyan-flowers-destroy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: correctly determine `bind:group` members

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import is_reference from 'is-reference';
22
import { walk } from 'zimmerframe';
33
import { error } from '../../errors.js';
4-
import * as assert from '../../utils/assert.js';
54
import {
65
extract_identifiers,
6+
extract_all_identifiers_from_expression,
77
extract_paths,
88
is_event_attribute,
99
is_text_attribute,
@@ -1008,39 +1008,52 @@ const common_visitors = {
10081008

10091009
if (node.name !== 'group') return;
10101010

1011+
// Traverse the path upwards and find all EachBlocks who are (indirectly) contributing to bind:group,
1012+
// i.e. one of their declarations is referenced in the binding. This allows group bindings to work
1013+
// correctly when referencing a variable declared in an EachBlock by using the index of the each block
1014+
// entries as keys.
10111015
i = context.path.length;
1016+
const each_blocks = [];
1017+
const expression_ids = extract_all_identifiers_from_expression(node.expression);
1018+
let ids = expression_ids;
10121019
while (i--) {
10131020
const parent = context.path[i];
10141021
if (parent.type === 'EachBlock') {
1015-
parent.metadata.contains_group_binding = true;
1016-
for (const binding of parent.metadata.references) {
1017-
binding.mutated = true;
1022+
const references = ids.filter((id) => parent.metadata.declarations.has(id.name));
1023+
if (references.length > 0) {
1024+
parent.metadata.contains_group_binding = true;
1025+
for (const binding of parent.metadata.references) {
1026+
binding.mutated = true;
1027+
}
1028+
each_blocks.push(parent);
1029+
ids = ids.filter((id) => !references.includes(id));
1030+
ids.push(...extract_all_identifiers_from_expression(parent.expression));
10181031
}
10191032
}
10201033
}
10211034

1022-
const id = object(node.expression);
1023-
1024-
const binding = id === null ? null : context.state.scope.get(id.name);
1025-
assert.ok(binding);
1026-
1027-
let group = context.state.analysis.binding_groups.get(binding);
1028-
if (!group) {
1029-
group = {
1030-
name: context.state.scope.root.unique('binding_group'),
1031-
directives: []
1032-
};
1033-
1034-
context.state.analysis.binding_groups.set(binding, group);
1035+
// The identifiers that make up the binding expression form they key for the binding group.
1036+
// If the same identifiers in the same order are used in another bind:group, they will be in the same group.
1037+
// (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,
1038+
// but this is a limitation of the current static analysis we do; it also never worked in Svelte 4)
1039+
const bindings = expression_ids.map((id) => context.state.scope.get(id.name));
1040+
let group_name;
1041+
outer: for (const [b, group] of context.state.analysis.binding_groups) {
1042+
if (b.length !== bindings.length) continue;
1043+
for (let i = 0; i < bindings.length; i++) {
1044+
if (bindings[i] !== b[i]) continue outer;
1045+
}
1046+
group_name = group;
10351047
}
10361048

1037-
group.directives.push(node);
1049+
if (!group_name) {
1050+
group_name = context.state.scope.root.unique('binding_group');
1051+
context.state.analysis.binding_groups.set(bindings, group_name);
1052+
}
10381053

10391054
node.metadata = {
1040-
binding_group_name: group.name,
1041-
parent_each_blocks: /** @type {import('#compiler').EachBlock[]} */ (
1042-
context.path.filter((p) => p.type === 'EachBlock')
1043-
)
1055+
binding_group_name: group_name,
1056+
parent_each_blocks: each_blocks
10441057
};
10451058
},
10461059
ArrowFunctionExpression: function_visitor,

packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2720,8 +2720,7 @@ export const template_visitors = {
27202720
case 'group': {
27212721
/** @type {import('estree').CallExpression[]} */
27222722
const indexes = [];
2723-
// we only care about the indexes above the first each block
2724-
for (const parent_each_block of node.metadata.parent_each_blocks.slice(0, -1)) {
2723+
for (const parent_each_block of node.metadata.parent_each_blocks) {
27252724
indexes.push(b.call('$.unwrap', parent_each_block.metadata.index));
27262725
}
27272726

packages/svelte/src/compiler/phases/scope.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { walk } from 'zimmerframe';
33
import { is_element_node } from './nodes.js';
44
import * as b from '../utils/builders.js';
55
import { error } from '../errors.js';
6-
import { extract_identifiers, extract_identifiers_from_expression } from '../utils/ast.js';
6+
import { extract_identifiers, extract_identifiers_from_destructuring } from '../utils/ast.js';
77
import { JsKeywords, Runes } from './constants.js';
88

99
export class Scope {
@@ -306,7 +306,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
306306
scopes.set(attribute, scope);
307307

308308
if (attribute.expression) {
309-
for (const id of extract_identifiers_from_expression(attribute.expression)) {
309+
for (const id of extract_identifiers_from_destructuring(attribute.expression)) {
310310
const binding = scope.declare(id, 'derived', 'const');
311311
bindings.push(binding);
312312
}
@@ -548,6 +548,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
548548
array_name: needs_array_deduplication ? state.scope.root.unique('$$array') : null,
549549
index: scope.root.unique('$$index'),
550550
item_name: node.context.type === 'Identifier' ? node.context.name : '$$item',
551+
declarations: scope.declarations,
551552
references: [...references_within]
552553
.map((id) => /** @type {import('#compiler').Binding} */ (state.scope.get(id.name)))
553554
.filter(Boolean),

packages/svelte/src/compiler/phases/types.d.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,6 @@ export interface ReactiveStatement {
2828
dependencies: Set<Binding>;
2929
}
3030

31-
export interface BindingGroup {
32-
name: Identifier;
33-
directives: BindDirective[];
34-
}
35-
3631
export interface RawWarning {
3732
code: string;
3833
message: string;
@@ -72,7 +67,8 @@ export interface ComponentAnalysis extends Analysis {
7267
/** If `true`, should append styles through JavaScript */
7368
inject_styles: boolean;
7469
reactive_statements: Map<LabeledStatement, ReactiveStatement>;
75-
binding_groups: Map<Binding, BindingGroup>;
70+
/** Identifiers that make up the `bind:group` expression -> internal group binding name */
71+
binding_groups: Map<Array<Binding | null>, Identifier>;
7672
slot_names: Set<string>;
7773
}
7874

packages/svelte/src/compiler/types/template.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ export interface EachBlock extends BaseNode {
379379
array_name: Identifier | null;
380380
index: Identifier;
381381
item_name: string;
382+
declarations: Map<string, Binding>;
382383
/** List of bindings that are referenced within the expression */
383384
references: Binding[];
384385
/**

packages/svelte/src/compiler/utils/ast.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { walk } from 'zimmerframe';
12
import * as b from '../utils/builders.js';
23

34
/**
@@ -96,11 +97,36 @@ export function extract_identifiers(param, nodes = []) {
9697

9798
/**
9899
* Extracts all identifiers from an expression.
100+
* @param {import('estree').Expression} expr
101+
* @returns {import('estree').Identifier[]}
102+
*/
103+
export function extract_all_identifiers_from_expression(expr) {
104+
/** @type {import('estree').Identifier[]} */
105+
let nodes = [];
106+
107+
walk(
108+
expr,
109+
{},
110+
{
111+
Identifier(node, { path }) {
112+
const parent = path.at(-1);
113+
if (parent?.type !== 'MemberExpression' || parent.property !== node || parent.computed) {
114+
nodes.push(node);
115+
}
116+
}
117+
}
118+
);
119+
120+
return nodes;
121+
}
122+
123+
/**
124+
* Extracts all leaf identifiers from a destructuring expression.
99125
* @param {import('estree').Identifier | import('estree').ObjectExpression | import('estree').ArrayExpression} node
100126
* @param {import('estree').Identifier[]} [nodes]
101127
* @returns
102128
*/
103-
export function extract_identifiers_from_expression(node, nodes = []) {
129+
export function extract_identifiers_from_destructuring(node, nodes = []) {
104130
// TODO This isn't complete, but it should be enough for our purposes
105131
switch (node.type) {
106132
case 'Identifier':
@@ -110,17 +136,17 @@ export function extract_identifiers_from_expression(node, nodes = []) {
110136
case 'ObjectExpression':
111137
for (const prop of node.properties) {
112138
if (prop.type === 'Property') {
113-
extract_identifiers_from_expression(/** @type {any} */ (prop.value), nodes);
139+
extract_identifiers_from_destructuring(/** @type {any} */ (prop.value), nodes);
114140
} else {
115-
extract_identifiers_from_expression(/** @type {any} */ (prop.argument), nodes);
141+
extract_identifiers_from_destructuring(/** @type {any} */ (prop.argument), nodes);
116142
}
117143
}
118144

119145
break;
120146

121147
case 'ArrayExpression':
122148
for (const element of node.elements) {
123-
if (element) extract_identifiers_from_expression(/** @type {any} */ (element), nodes);
149+
if (element) extract_identifiers_from_destructuring(/** @type {any} */ (element), nodes);
124150
}
125151

126152
break;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
async test({ assert, target, window }) {
5+
const [input1, input2, input3, input4] = target.querySelectorAll('input');
6+
const [p] = target.querySelectorAll('p');
7+
const event = new window.Event('change');
8+
9+
input1.checked = true;
10+
await input1.dispatchEvent(event);
11+
await Promise.resolve();
12+
assert.htmlEqual(p.innerHTML, '["1a"]');
13+
14+
input2.checked = true;
15+
await input1.dispatchEvent(event);
16+
await Promise.resolve();
17+
assert.htmlEqual(p.innerHTML, '["1a","1b"]');
18+
19+
input3.checked = true;
20+
await input1.dispatchEvent(event);
21+
await Promise.resolve();
22+
assert.htmlEqual(p.innerHTML, '["1a","1b","2a"]');
23+
24+
input4.checked = true;
25+
await input1.dispatchEvent(event);
26+
await Promise.resolve();
27+
assert.htmlEqual(p.innerHTML, '["1a","1b","2a","2b"]');
28+
29+
input1.checked = false;
30+
await input1.dispatchEvent(event);
31+
await Promise.resolve();
32+
assert.htmlEqual(p.innerHTML, '["1b","2a","2b"]');
33+
34+
input3.checked = false;
35+
await input1.dispatchEvent(event);
36+
await Promise.resolve();
37+
assert.htmlEqual(p.innerHTML, '["1b","2b"]');
38+
}
39+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<script>
2+
let group = [];
3+
4+
const options = [
5+
["1", ["1a", "1b"]],
6+
["2", ["2a", "2b"]],
7+
];
8+
</script>
9+
10+
{#each options as [prefix, arr]}
11+
{prefix}
12+
<div>
13+
{#each arr as item}
14+
<label>
15+
<input
16+
type="checkbox"
17+
bind:group
18+
value={item}
19+
/>
20+
{item}
21+
</label>
22+
{/each}
23+
</div>
24+
{/each}
25+
26+
<p>{JSON.stringify(group)}</p>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
async test({ assert, target }) {
5+
const checkboxes = /** @type {NodeListOf<HTMLInputElement>} */ (
6+
target.querySelectorAll('input[type="checkbox"]')
7+
);
8+
9+
assert.isFalse(checkboxes[0].checked);
10+
assert.isTrue(checkboxes[1].checked);
11+
assert.isFalse(checkboxes[2].checked);
12+
13+
await checkboxes[1].click();
14+
15+
const noChecked = target.querySelector('#output')?.innerHTML;
16+
assert.equal(noChecked, '');
17+
18+
await checkboxes[1].click();
19+
20+
const oneChecked = target.querySelector('#output')?.innerHTML;
21+
assert.equal(oneChecked, 'Mint choc chip');
22+
}
23+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script lang="ts">
2+
import { writable } from 'svelte/store';
3+
let menu = ['Cookies and cream', 'Mint choc chip', 'Raspberry ripple'];
4+
let order = writable({ iceCream: [{flavours: ['Mint choc chip']}], scoops: 1 });
5+
let index = 0
6+
</script>
7+
8+
<form method="POST">
9+
<input type="radio" bind:group={$order.scoops} name="scoops" value={1} /> One scoop
10+
<input type="radio" bind:group={$order.scoops} name="scoops" value={2} /> Two scoops
11+
<input type="radio" bind:group={$order.scoops} name="scoops" value={3} /> Three scoops
12+
13+
{#each menu as flavour}
14+
<input type="checkbox" bind:group={$order.iceCream[index].flavours} name="flavours" value={flavour} /> {flavour}
15+
{/each}
16+
</form>
17+
18+
<div id="output">{$order.iceCream[index].flavours.join('+')}</div>

packages/svelte/types/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,7 @@ declare module 'svelte/compiler' {
14081408
array_name: Identifier | null;
14091409
index: Identifier;
14101410
item_name: string;
1411+
declarations: Map<string, Binding>;
14111412
/** List of bindings that are referenced within the expression */
14121413
references: Binding[];
14131414
/**

0 commit comments

Comments
 (0)