Skip to content

Commit 54990f2

Browse files
authored
fix: use local mutable sources for props in legacy mode in case they are indirectly invalidated (#16038)
* fix: use local mutable sources for props in legacy mode in case they are indirectly invalidated * rename test * add another test * fix * more conservative * Update .changeset/orange-tips-pull.md * skip work in runes mode * remove comment * revert whitespace change
1 parent 2f90dd8 commit 54990f2

File tree

10 files changed

+288
-95
lines changed

10 files changed

+288
-95
lines changed

.changeset/orange-tips-pull.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: treat transitive dependencies of each blocks as mutable in legacy mode if item is mutated

packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
/** @import { AST } from '#compiler' */
1+
/** @import { AST, Binding } from '#compiler' */
22
/** @import { Context } from '../types' */
33
/** @import { Scope } from '../../scope' */
44
import * as e from '../../../errors.js';
5+
import { extract_identifiers } from '../../../utils/ast.js';
56
import { mark_subtree_dynamic } from './shared/fragment.js';
67
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
78

@@ -38,5 +39,49 @@ export function EachBlock(node, context) {
3839
if (node.key) context.visit(node.key);
3940
if (node.fallback) context.visit(node.fallback);
4041

42+
if (!context.state.analysis.runes) {
43+
let mutated =
44+
!!node.context &&
45+
extract_identifiers(node.context).some((id) => {
46+
const binding = context.state.scope.get(id.name);
47+
return !!binding?.mutated;
48+
});
49+
50+
// collect transitive dependencies...
51+
for (const binding of node.metadata.expression.dependencies) {
52+
collect_transitive_dependencies(binding, node.metadata.transitive_deps);
53+
}
54+
55+
// ...and ensure they are marked as state, so they can be turned
56+
// into mutable sources and invalidated
57+
if (mutated) {
58+
for (const binding of node.metadata.transitive_deps) {
59+
if (
60+
binding.kind === 'normal' &&
61+
(binding.declaration_kind === 'const' ||
62+
binding.declaration_kind === 'let' ||
63+
binding.declaration_kind === 'var')
64+
) {
65+
binding.kind = 'state';
66+
}
67+
}
68+
}
69+
}
70+
4171
mark_subtree_dynamic(context.path);
4272
}
73+
74+
/**
75+
* @param {Binding} binding
76+
* @param {Set<Binding>} bindings
77+
* @returns {void}
78+
*/
79+
function collect_transitive_dependencies(binding, bindings) {
80+
bindings.add(binding);
81+
82+
if (binding.kind === 'legacy_reactive') {
83+
for (const dep of binding.legacy_dependencies) {
84+
collect_transitive_dependencies(dep, bindings);
85+
}
86+
}
87+
}

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

Lines changed: 40 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { BlockStatement, Expression, Identifier, Pattern, Statement } from 'estree' */
1+
/** @import { BlockStatement, Expression, Identifier, Pattern, SequenceExpression, Statement } from 'estree' */
22
/** @import { AST, Binding } from '#compiler' */
33
/** @import { ComponentContext } from '../types' */
44
/** @import { Scope } from '../../../scope' */
@@ -106,16 +106,6 @@ export function EachBlock(node, context) {
106106
}
107107
}
108108

109-
// Legacy mode: find the parent each blocks which contain the arrays to invalidate
110-
const indirect_dependencies = collect_parent_each_blocks(context).flatMap((block) => {
111-
const array = /** @type {Expression} */ (context.visit(block.expression));
112-
const transitive_dependencies = build_transitive_dependencies(
113-
block.metadata.expression.dependencies,
114-
context
115-
);
116-
return [array, ...transitive_dependencies];
117-
});
118-
119109
/** @type {Identifier | null} */
120110
let collection_id = null;
121111

@@ -129,18 +119,6 @@ export function EachBlock(node, context) {
129119
}
130120
}
131121

132-
if (collection_id) {
133-
indirect_dependencies.push(b.call(collection_id));
134-
} else {
135-
indirect_dependencies.push(collection);
136-
137-
const transitive_dependencies = build_transitive_dependencies(
138-
each_node_meta.expression.dependencies,
139-
context
140-
);
141-
indirect_dependencies.push(...transitive_dependencies);
142-
}
143-
144122
const child_state = {
145123
...context.state,
146124
transform: { ...context.state.transform },
@@ -181,19 +159,51 @@ export function EachBlock(node, context) {
181159
/** @type {Statement[]} */
182160
const declarations = [];
183161

184-
const invalidate = b.call(
185-
'$.invalidate_inner_signals',
186-
b.thunk(b.sequence(indirect_dependencies))
187-
);
188-
189162
const invalidate_store = store_to_invalidate
190163
? b.call('$.invalidate_store', b.id('$$stores'), b.literal(store_to_invalidate))
191164
: undefined;
192165

193166
/** @type {Expression[]} */
194167
const sequence = [];
195-
if (!context.state.analysis.runes) sequence.push(invalidate);
196-
if (invalidate_store) sequence.push(invalidate_store);
168+
169+
if (!context.state.analysis.runes) {
170+
/** @type {Set<Identifier>} */
171+
const transitive_deps = new Set();
172+
173+
if (collection_id) {
174+
transitive_deps.add(collection_id);
175+
child_state.transform[collection_id.name] = { read: b.call };
176+
} else {
177+
for (const binding of each_node_meta.transitive_deps) {
178+
transitive_deps.add(binding.node);
179+
}
180+
}
181+
182+
for (const block of collect_parent_each_blocks(context)) {
183+
for (const binding of block.metadata.transitive_deps) {
184+
transitive_deps.add(binding.node);
185+
}
186+
}
187+
188+
if (transitive_deps.size > 0) {
189+
const invalidate = b.call(
190+
'$.invalidate_inner_signals',
191+
b.thunk(
192+
b.sequence(
193+
[...transitive_deps].map(
194+
(node) => /** @type {Expression} */ (context.visit({ ...node }, child_state))
195+
)
196+
)
197+
)
198+
);
199+
200+
sequence.push(invalidate);
201+
}
202+
}
203+
204+
if (invalidate_store) {
205+
sequence.push(invalidate_store);
206+
}
197207

198208
if (node.context?.type === 'Identifier') {
199209
const binding = /** @type {Binding} */ (context.state.scope.get(node.context.name));
@@ -329,41 +339,3 @@ export function EachBlock(node, context) {
329339
function collect_parent_each_blocks(context) {
330340
return /** @type {AST.EachBlock[]} */ (context.path.filter((node) => node.type === 'EachBlock'));
331341
}
332-
333-
/**
334-
* @param {Set<Binding>} references
335-
* @param {ComponentContext} context
336-
*/
337-
function build_transitive_dependencies(references, context) {
338-
/** @type {Set<Binding>} */
339-
const dependencies = new Set();
340-
341-
for (const ref of references) {
342-
const deps = collect_transitive_dependencies(ref);
343-
for (const dep of deps) {
344-
dependencies.add(dep);
345-
}
346-
}
347-
348-
return [...dependencies].map((dep) => build_getter({ ...dep.node }, context.state));
349-
}
350-
351-
/**
352-
* @param {Binding} binding
353-
* @param {Set<Binding>} seen
354-
* @returns {Binding[]}
355-
*/
356-
function collect_transitive_dependencies(binding, seen = new Set()) {
357-
if (binding.kind !== 'legacy_reactive') return [];
358-
359-
for (const dep of binding.legacy_dependencies) {
360-
if (!seen.has(dep)) {
361-
seen.add(dep);
362-
for (const transitive_dep of collect_transitive_dependencies(dep, seen)) {
363-
seen.add(transitive_dep);
364-
}
365-
}
366-
}
367-
368-
return [...seen];
369-
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1169,7 +1169,9 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
11691169
contains_group_binding: false,
11701170
index: scope.root.unique('$$index'),
11711171
declarations: scope.declarations,
1172-
is_controlled: false
1172+
is_controlled: false,
1173+
// filled in during analysis
1174+
transitive_deps: new Set()
11731175
};
11741176
},
11751177

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,11 @@ export namespace AST {
432432
* This saves us from creating an extra comment and insertion being faster.
433433
*/
434434
is_controlled: boolean;
435+
/**
436+
* Bindings this each block transitively depends on. In legacy mode, we
437+
* invalidate these bindings when mutations happen to each block items
438+
*/
439+
transitive_deps: Set<Binding>;
435440
};
436441
}
437442

packages/svelte/src/internal/client/reactivity/props.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ export function prop(props, key, flags, fallback) {
335335
}
336336

337337
// easy mode — prop is never written to
338-
if ((flags & PROPS_IS_UPDATED) === 0) {
338+
if ((flags & PROPS_IS_UPDATED) === 0 && runes) {
339339
return getter;
340340
}
341341

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
get props() {
6+
return {
7+
items: [
8+
{ done: false, text: 'one' },
9+
{ done: true, text: 'two' },
10+
{ done: false, text: 'three' }
11+
]
12+
};
13+
},
14+
15+
html: `
16+
<div>
17+
<input type="checkbox">
18+
<input type="text"><p>one</p>
19+
</div>
20+
<div>
21+
<input type="checkbox">
22+
<input type="text"><p>two</p>
23+
</div>
24+
<div>
25+
<input type="checkbox">
26+
<input type="text"><p>three</p>
27+
</div>
28+
29+
<p>remaining:one / done:two / remaining:three</p>
30+
`,
31+
32+
ssrHtml: `
33+
<div>
34+
<input type="checkbox">
35+
<input type="text" value=one><p>one</p>
36+
</div>
37+
<div>
38+
<input type="checkbox" checked="">
39+
<input type="text" value=two><p>two</p>
40+
</div>
41+
<div>
42+
<input type="checkbox">
43+
<input type="text" value=three><p>three</p>
44+
</div>
45+
46+
<p>remaining:one / done:two / remaining:three</p>
47+
`,
48+
49+
test({ assert, component, target, window }) {
50+
/**
51+
* @param {number} i
52+
* @param {string} text
53+
*/
54+
function set_text(i, text) {
55+
const input = /** @type {HTMLInputElement} */ (
56+
target.querySelectorAll('input[type="text"]')[i]
57+
);
58+
input.value = text;
59+
input.dispatchEvent(new window.Event('input'));
60+
}
61+
62+
/**
63+
* @param {number} i
64+
* @param {boolean} done
65+
*/
66+
function set_done(i, done) {
67+
const input = /** @type {HTMLInputElement} */ (
68+
target.querySelectorAll('input[type="checkbox"]')[i]
69+
);
70+
input.checked = done;
71+
input.dispatchEvent(new window.Event('change'));
72+
}
73+
74+
component.filter = 'remaining';
75+
76+
assert.htmlEqual(
77+
target.innerHTML,
78+
`
79+
<div>
80+
<input type="checkbox">
81+
<input type="text"><p>one</p>
82+
</div>
83+
<div>
84+
<input type="checkbox">
85+
<input type="text"><p>three</p>
86+
</div>
87+
88+
<p>remaining:one / done:two / remaining:three</p>
89+
`
90+
);
91+
92+
set_text(1, 'four');
93+
flushSync();
94+
95+
assert.htmlEqual(
96+
target.innerHTML,
97+
`
98+
<div>
99+
<input type="checkbox">
100+
<input type="text"><p>one</p>
101+
</div>
102+
<div>
103+
<input type="checkbox">
104+
<input type="text"><p>four</p>
105+
</div>
106+
107+
<p>remaining:one / done:two / remaining:four</p>
108+
`
109+
);
110+
111+
assert.deepEqual(component.items, [
112+
{ done: false, text: 'one' },
113+
{ done: true, text: 'two' },
114+
{ done: false, text: 'four' }
115+
]);
116+
117+
set_done(0, true);
118+
flushSync();
119+
120+
assert.htmlEqual(
121+
target.innerHTML,
122+
`
123+
<div>
124+
<input type="checkbox">
125+
<input type="text"><p>four</p>
126+
</div>
127+
128+
<p>done:one / done:two / remaining:four</p>
129+
`
130+
);
131+
132+
assert.deepEqual(component.items, [
133+
{ done: true, text: 'one' },
134+
{ done: true, text: 'two' },
135+
{ done: false, text: 'four' }
136+
]);
137+
138+
component.filter = 'done';
139+
140+
assert.htmlEqual(
141+
target.innerHTML,
142+
`
143+
<div>
144+
<input type="checkbox">
145+
<input type="text"><p>one</p>
146+
</div>
147+
<div>
148+
<input type="checkbox">
149+
<input type="text"><p>two</p>
150+
</div>
151+
152+
<p>done:one / done:two / remaining:four</p>
153+
`
154+
);
155+
}
156+
});

0 commit comments

Comments
 (0)