Skip to content

Commit 6ed3ca3

Browse files
authored
fix: make immutable option work more correctly (#13526)
- make sure to not overfire before/afterUpdate - make sure to not fire mutable sources when they were mutated - only show deprecation warning when in runes mode to not clutter up console (this is in line with how we made it in other places) fixes #13454
1 parent 0466e40 commit 6ed3ca3

File tree

11 files changed

+186
-20
lines changed

11 files changed

+186
-20
lines changed

.changeset/giant-ladybugs-thank.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: make immutable option work more correctly

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,15 +522,15 @@ export function analyze_component(root, source, options) {
522522

523523
if (root.options) {
524524
for (const attribute of root.options.attributes) {
525-
if (attribute.name === 'accessors') {
525+
if (attribute.name === 'accessors' && analysis.runes) {
526526
w.options_deprecated_accessors(attribute);
527527
}
528528

529529
if (attribute.name === 'customElement' && !options.customElement) {
530530
w.options_missing_custom_element(attribute);
531531
}
532532

533-
if (attribute.name === 'immutable') {
533+
if (attribute.name === 'immutable' && analysis.runes) {
534534
w.options_deprecated_immutable(attribute);
535535
}
536536
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,9 @@ export function client_component(analysis, options) {
212212

213213
for (const [name, binding] of analysis.instance.scope.declarations) {
214214
if (binding.kind === 'legacy_reactive') {
215-
legacy_reactive_declarations.push(b.const(name, b.call('$.mutable_state')));
215+
legacy_reactive_declarations.push(
216+
b.const(name, b.call('$.mutable_state', undefined, analysis.immutable ? b.true : undefined))
217+
);
216218
}
217219
if (binding.kind === 'store_sub') {
218220
if (store_setup.length === 0) {
@@ -368,7 +370,9 @@ export function client_component(analysis, options) {
368370
...group_binding_declarations,
369371
...analysis.top_level_snippets,
370372
.../** @type {ESTree.Statement[]} */ (instance.body),
371-
analysis.runes || !analysis.needs_context ? b.empty : b.stmt(b.call('$.init')),
373+
analysis.runes || !analysis.needs_context
374+
? b.empty
375+
: b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)),
372376
.../** @type {ESTree.Statement[]} */ (template.body)
373377
]);
374378

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/** @import { CallExpression, Expression, Identifier, Literal, VariableDeclaration, VariableDeclarator } from 'estree' */
22
/** @import { Binding } from '#compiler' */
3-
/** @import { ComponentContext } from '../types' */
4-
/** @import { Scope } from '../../../scope' */
3+
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */
54
import { dev } from '../../../../state.js';
65
import { extract_paths } from '../../../../utils/ast.js';
76
import * as b from '../../../../utils/builders.js';
@@ -267,7 +266,7 @@ export function VariableDeclaration(node, context) {
267266
declarations.push(
268267
...create_state_declarators(
269268
declarator,
270-
context.state.scope,
269+
context.state,
271270
/** @type {Expression} */ (declarator.init && context.visit(declarator.init))
272271
)
273272
);
@@ -287,12 +286,17 @@ export function VariableDeclaration(node, context) {
287286
/**
288287
* Creates the output for a state declaration in legacy mode.
289288
* @param {VariableDeclarator} declarator
290-
* @param {Scope} scope
289+
* @param {ComponentClientTransformState} scope
291290
* @param {Expression} value
292291
*/
293-
function create_state_declarators(declarator, scope, value) {
292+
function create_state_declarators(declarator, { scope, analysis }, value) {
294293
if (declarator.id.type === 'Identifier') {
295-
return [b.declarator(declarator.id, b.call('$.mutable_state', value))];
294+
return [
295+
b.declarator(
296+
declarator.id,
297+
b.call('$.mutable_state', value, analysis.immutable ? b.true : undefined)
298+
)
299+
];
296300
}
297301

298302
const tmp = scope.generate('tmp');
@@ -304,7 +308,9 @@ function create_state_declarators(declarator, scope, value) {
304308
const binding = scope.get(/** @type {Identifier} */ (path.node).name);
305309
return b.declarator(
306310
path.node,
307-
binding?.kind === 'state' ? b.call('$.mutable_state', value) : value
311+
binding?.kind === 'state'
312+
? b.call('$.mutable_state', value, analysis.immutable ? b.true : undefined)
313+
: value
308314
);
309315
})
310316
];

packages/svelte/src/internal/client/dom/legacy/lifecycle.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,46 @@
11
/** @import { ComponentContextLegacy } from '#client' */
22
import { run, run_all } from '../../../shared/utils.js';
3+
import { derived } from '../../reactivity/deriveds.js';
34
import { user_pre_effect, user_effect } from '../../reactivity/effects.js';
45
import { component_context, deep_read_state, get, untrack } from '../../runtime.js';
56

67
/**
78
* Legacy-mode only: Call `onMount` callbacks and set up `beforeUpdate`/`afterUpdate` effects
9+
* @param {boolean} [immutable]
810
*/
9-
export function init() {
11+
export function init(immutable = false) {
1012
const context = /** @type {ComponentContextLegacy} */ (component_context);
1113

1214
const callbacks = context.l.u;
1315
if (!callbacks) return;
1416

17+
let props = () => deep_read_state(context.s);
18+
19+
if (immutable) {
20+
let version = 0;
21+
let prev = /** @type {Record<string, any>} */ ({});
22+
23+
// In legacy immutable mode, before/afterUpdate only fire if the object identity of a prop changes
24+
const d = derived(() => {
25+
let changed = false;
26+
const props = context.s;
27+
for (const key in props) {
28+
if (props[key] !== prev[key]) {
29+
prev[key] = props[key];
30+
changed = true;
31+
}
32+
}
33+
if (changed) version++;
34+
return version;
35+
});
36+
37+
props = () => get(d);
38+
}
39+
1540
// beforeUpdate
1641
if (callbacks.b.length) {
1742
user_pre_effect(() => {
18-
observe_all(context);
43+
observe_all(context, props);
1944
run_all(callbacks.b);
2045
});
2146
}
@@ -35,7 +60,7 @@ export function init() {
3560
// afterUpdate
3661
if (callbacks.a.length) {
3762
user_effect(() => {
38-
observe_all(context);
63+
observe_all(context, props);
3964
run_all(callbacks.a);
4065
});
4166
}
@@ -45,11 +70,12 @@ export function init() {
4570
* Invoke the getter of all signals associated with a component
4671
* so they can be registered to the effect this function is called in.
4772
* @param {ComponentContextLegacy} context
73+
* @param {(() => void)} props
4874
*/
49-
function observe_all(context) {
75+
function observe_all(context, props) {
5076
if (context.l.s) {
5177
for (const signal of context.l.s) get(signal);
5278
}
5379

54-
deep_read_state(context.s);
80+
props();
5581
}

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,15 @@ export function state(v) {
6767
/**
6868
* @template V
6969
* @param {V} initial_value
70+
* @param {boolean} [immutable]
7071
* @returns {Source<V>}
7172
*/
7273
/*#__NO_SIDE_EFFECTS__*/
73-
export function mutable_source(initial_value) {
74+
export function mutable_source(initial_value, immutable = false) {
7475
const s = source(initial_value);
75-
s.equals = safe_equals;
76+
if (!immutable) {
77+
s.equals = safe_equals;
78+
}
7679

7780
// bind the signal to the component context, in case we need to
7881
// track updates to trigger beforeUpdate/afterUpdate callbacks
@@ -86,10 +89,11 @@ export function mutable_source(initial_value) {
8689
/**
8790
* @template V
8891
* @param {V} v
92+
* @param {boolean} [immutable]
8993
* @returns {Source<V>}
9094
*/
91-
export function mutable_state(v) {
92-
return push_derived_source(mutable_source(v));
95+
export function mutable_state(v, immutable = false) {
96+
return push_derived_source(mutable_source(v, immutable));
9397
}
9498

9599
/**
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<svelte:options immutable />
2+
3+
<script>
4+
import { afterUpdate, beforeUpdate } from 'svelte';
5+
6+
export let todo;
7+
8+
let btn;
9+
10+
$: console.log('$:'+ todo.id);
11+
12+
beforeUpdate(() => {
13+
console.log('beforeUpdate:'+ todo.id);
14+
})
15+
16+
afterUpdate(() => {
17+
console.log('afterUpdate:'+ todo.id);
18+
});
19+
</script>
20+
21+
<button bind:this={btn} on:click>
22+
{todo.done ? 'X' : ''}
23+
{todo.id}
24+
</button>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
immutable: true,
6+
7+
html: '<button>1</button> <button>2</button> <button>3</button>',
8+
9+
test({ assert, target, logs }) {
10+
assert.deepEqual(logs, [
11+
'$:1',
12+
'beforeUpdate:1',
13+
'$:2',
14+
'beforeUpdate:2',
15+
'$:3',
16+
'beforeUpdate:3',
17+
'afterUpdate:1',
18+
'afterUpdate:2',
19+
'afterUpdate:3',
20+
'beforeUpdate:1',
21+
'beforeUpdate:2',
22+
'beforeUpdate:3'
23+
]);
24+
25+
const [button1, button2] = target.querySelectorAll('button');
26+
27+
logs.length = 0;
28+
button1.click();
29+
flushSync();
30+
assert.htmlEqual(
31+
target.innerHTML,
32+
'<button>X 1</button> <button>2</button> <button>3</button>'
33+
);
34+
assert.deepEqual(logs, ['$:1', 'beforeUpdate:1', 'afterUpdate:1']);
35+
36+
logs.length = 0;
37+
button2.click();
38+
flushSync();
39+
assert.htmlEqual(
40+
target.innerHTML,
41+
'<button>X 1</button> <button>X 2</button> <button>3</button>'
42+
);
43+
assert.deepEqual(logs, ['$:2', 'beforeUpdate:2', 'afterUpdate:2']);
44+
}
45+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<script>
2+
import ImmutableTodo from './ImmutableTodo.svelte';
3+
4+
let todos = [
5+
{ id: 1, done: false },
6+
{ id: 2, done: false },
7+
{ id: 3, done: false }
8+
];
9+
10+
function toggle(id) {
11+
todos = todos.map((todo) => {
12+
if (todo.id === id) {
13+
return {
14+
id,
15+
done: !todo.done
16+
};
17+
}
18+
19+
return todo;
20+
});
21+
}
22+
</script>
23+
24+
{#each todos as todo}
25+
<ImmutableTodo {todo} on:click={() => toggle(todo.id)} />
26+
{/each}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
immutable: true,
6+
7+
html: '<button>0</button> <button>0</button>',
8+
9+
test({ assert, target }) {
10+
const [button1, button2] = target.querySelectorAll('button');
11+
12+
button1.click();
13+
flushSync();
14+
assert.htmlEqual(target.innerHTML, '<button>0</button> <button>0</button>');
15+
16+
button2.click();
17+
flushSync();
18+
assert.htmlEqual(target.innerHTML, '<button>2</button> <button>2</button>');
19+
}
20+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
let name = { value: 0 };
3+
</script>
4+
5+
<button onclick={() => name.value++}>{name.value}</button>
6+
<button onclick={() => (name = { value: name.value + 1 })}>{name.value}</button>

0 commit comments

Comments
 (0)