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
pull/13532/head
Simon H 11 months ago committed by GitHub
parent 0466e40680
commit 6ed3ca380b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: make immutable option work more correctly

@ -522,7 +522,7 @@ export function analyze_component(root, source, options) {
if (root.options) { if (root.options) {
for (const attribute of root.options.attributes) { for (const attribute of root.options.attributes) {
if (attribute.name === 'accessors') { if (attribute.name === 'accessors' && analysis.runes) {
w.options_deprecated_accessors(attribute); w.options_deprecated_accessors(attribute);
} }
@ -530,7 +530,7 @@ export function analyze_component(root, source, options) {
w.options_missing_custom_element(attribute); w.options_missing_custom_element(attribute);
} }
if (attribute.name === 'immutable') { if (attribute.name === 'immutable' && analysis.runes) {
w.options_deprecated_immutable(attribute); w.options_deprecated_immutable(attribute);
} }
} }

@ -212,7 +212,9 @@ export function client_component(analysis, options) {
for (const [name, binding] of analysis.instance.scope.declarations) { for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind === 'legacy_reactive') { if (binding.kind === 'legacy_reactive') {
legacy_reactive_declarations.push(b.const(name, b.call('$.mutable_state'))); legacy_reactive_declarations.push(
b.const(name, b.call('$.mutable_state', undefined, analysis.immutable ? b.true : undefined))
);
} }
if (binding.kind === 'store_sub') { if (binding.kind === 'store_sub') {
if (store_setup.length === 0) { if (store_setup.length === 0) {
@ -368,7 +370,9 @@ export function client_component(analysis, options) {
...group_binding_declarations, ...group_binding_declarations,
...analysis.top_level_snippets, ...analysis.top_level_snippets,
.../** @type {ESTree.Statement[]} */ (instance.body), .../** @type {ESTree.Statement[]} */ (instance.body),
analysis.runes || !analysis.needs_context ? b.empty : b.stmt(b.call('$.init')), analysis.runes || !analysis.needs_context
? b.empty
: b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)),
.../** @type {ESTree.Statement[]} */ (template.body) .../** @type {ESTree.Statement[]} */ (template.body)
]); ]);

@ -1,7 +1,6 @@
/** @import { CallExpression, Expression, Identifier, Literal, VariableDeclaration, VariableDeclarator } from 'estree' */ /** @import { CallExpression, Expression, Identifier, Literal, VariableDeclaration, VariableDeclarator } from 'estree' */
/** @import { Binding } from '#compiler' */ /** @import { Binding } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */
/** @import { Scope } from '../../../scope' */
import { dev } from '../../../../state.js'; import { dev } from '../../../../state.js';
import { extract_paths } from '../../../../utils/ast.js'; import { extract_paths } from '../../../../utils/ast.js';
import * as b from '../../../../utils/builders.js'; import * as b from '../../../../utils/builders.js';
@ -267,7 +266,7 @@ export function VariableDeclaration(node, context) {
declarations.push( declarations.push(
...create_state_declarators( ...create_state_declarators(
declarator, declarator,
context.state.scope, context.state,
/** @type {Expression} */ (declarator.init && context.visit(declarator.init)) /** @type {Expression} */ (declarator.init && context.visit(declarator.init))
) )
); );
@ -287,12 +286,17 @@ export function VariableDeclaration(node, context) {
/** /**
* Creates the output for a state declaration in legacy mode. * Creates the output for a state declaration in legacy mode.
* @param {VariableDeclarator} declarator * @param {VariableDeclarator} declarator
* @param {Scope} scope * @param {ComponentClientTransformState} scope
* @param {Expression} value * @param {Expression} value
*/ */
function create_state_declarators(declarator, scope, value) { function create_state_declarators(declarator, { scope, analysis }, value) {
if (declarator.id.type === 'Identifier') { if (declarator.id.type === 'Identifier') {
return [b.declarator(declarator.id, b.call('$.mutable_state', value))]; return [
b.declarator(
declarator.id,
b.call('$.mutable_state', value, analysis.immutable ? b.true : undefined)
)
];
} }
const tmp = scope.generate('tmp'); const tmp = scope.generate('tmp');
@ -304,7 +308,9 @@ function create_state_declarators(declarator, scope, value) {
const binding = scope.get(/** @type {Identifier} */ (path.node).name); const binding = scope.get(/** @type {Identifier} */ (path.node).name);
return b.declarator( return b.declarator(
path.node, path.node,
binding?.kind === 'state' ? b.call('$.mutable_state', value) : value binding?.kind === 'state'
? b.call('$.mutable_state', value, analysis.immutable ? b.true : undefined)
: value
); );
}) })
]; ];

@ -1,21 +1,46 @@
/** @import { ComponentContextLegacy } from '#client' */ /** @import { ComponentContextLegacy } from '#client' */
import { run, run_all } from '../../../shared/utils.js'; import { run, run_all } from '../../../shared/utils.js';
import { derived } from '../../reactivity/deriveds.js';
import { user_pre_effect, user_effect } from '../../reactivity/effects.js'; import { user_pre_effect, user_effect } from '../../reactivity/effects.js';
import { component_context, deep_read_state, get, untrack } from '../../runtime.js'; import { component_context, deep_read_state, get, untrack } from '../../runtime.js';
/** /**
* Legacy-mode only: Call `onMount` callbacks and set up `beforeUpdate`/`afterUpdate` effects * Legacy-mode only: Call `onMount` callbacks and set up `beforeUpdate`/`afterUpdate` effects
* @param {boolean} [immutable]
*/ */
export function init() { export function init(immutable = false) {
const context = /** @type {ComponentContextLegacy} */ (component_context); const context = /** @type {ComponentContextLegacy} */ (component_context);
const callbacks = context.l.u; const callbacks = context.l.u;
if (!callbacks) return; if (!callbacks) return;
let props = () => deep_read_state(context.s);
if (immutable) {
let version = 0;
let prev = /** @type {Record<string, any>} */ ({});
// In legacy immutable mode, before/afterUpdate only fire if the object identity of a prop changes
const d = derived(() => {
let changed = false;
const props = context.s;
for (const key in props) {
if (props[key] !== prev[key]) {
prev[key] = props[key];
changed = true;
}
}
if (changed) version++;
return version;
});
props = () => get(d);
}
// beforeUpdate // beforeUpdate
if (callbacks.b.length) { if (callbacks.b.length) {
user_pre_effect(() => { user_pre_effect(() => {
observe_all(context); observe_all(context, props);
run_all(callbacks.b); run_all(callbacks.b);
}); });
} }
@ -35,7 +60,7 @@ export function init() {
// afterUpdate // afterUpdate
if (callbacks.a.length) { if (callbacks.a.length) {
user_effect(() => { user_effect(() => {
observe_all(context); observe_all(context, props);
run_all(callbacks.a); run_all(callbacks.a);
}); });
} }
@ -45,11 +70,12 @@ export function init() {
* Invoke the getter of all signals associated with a component * Invoke the getter of all signals associated with a component
* so they can be registered to the effect this function is called in. * so they can be registered to the effect this function is called in.
* @param {ComponentContextLegacy} context * @param {ComponentContextLegacy} context
* @param {(() => void)} props
*/ */
function observe_all(context) { function observe_all(context, props) {
if (context.l.s) { if (context.l.s) {
for (const signal of context.l.s) get(signal); for (const signal of context.l.s) get(signal);
} }
deep_read_state(context.s); props();
} }

@ -67,12 +67,15 @@ export function state(v) {
/** /**
* @template V * @template V
* @param {V} initial_value * @param {V} initial_value
* @param {boolean} [immutable]
* @returns {Source<V>} * @returns {Source<V>}
*/ */
/*#__NO_SIDE_EFFECTS__*/ /*#__NO_SIDE_EFFECTS__*/
export function mutable_source(initial_value) { export function mutable_source(initial_value, immutable = false) {
const s = source(initial_value); const s = source(initial_value);
if (!immutable) {
s.equals = safe_equals; s.equals = safe_equals;
}
// bind the signal to the component context, in case we need to // bind the signal to the component context, in case we need to
// track updates to trigger beforeUpdate/afterUpdate callbacks // track updates to trigger beforeUpdate/afterUpdate callbacks
@ -86,10 +89,11 @@ export function mutable_source(initial_value) {
/** /**
* @template V * @template V
* @param {V} v * @param {V} v
* @param {boolean} [immutable]
* @returns {Source<V>} * @returns {Source<V>}
*/ */
export function mutable_state(v) { export function mutable_state(v, immutable = false) {
return push_derived_source(mutable_source(v)); return push_derived_source(mutable_source(v, immutable));
} }
/** /**

@ -0,0 +1,24 @@
<svelte:options immutable />
<script>
import { afterUpdate, beforeUpdate } from 'svelte';
export let todo;
let btn;
$: console.log('$:'+ todo.id);
beforeUpdate(() => {
console.log('beforeUpdate:'+ todo.id);
})
afterUpdate(() => {
console.log('afterUpdate:'+ todo.id);
});
</script>
<button bind:this={btn} on:click>
{todo.done ? 'X' : ''}
{todo.id}
</button>

@ -0,0 +1,45 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
immutable: true,
html: '<button>1</button> <button>2</button> <button>3</button>',
test({ assert, target, logs }) {
assert.deepEqual(logs, [
'$:1',
'beforeUpdate:1',
'$:2',
'beforeUpdate:2',
'$:3',
'beforeUpdate:3',
'afterUpdate:1',
'afterUpdate:2',
'afterUpdate:3',
'beforeUpdate:1',
'beforeUpdate:2',
'beforeUpdate:3'
]);
const [button1, button2] = target.querySelectorAll('button');
logs.length = 0;
button1.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
'<button>X 1</button> <button>2</button> <button>3</button>'
);
assert.deepEqual(logs, ['$:1', 'beforeUpdate:1', 'afterUpdate:1']);
logs.length = 0;
button2.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
'<button>X 1</button> <button>X 2</button> <button>3</button>'
);
assert.deepEqual(logs, ['$:2', 'beforeUpdate:2', 'afterUpdate:2']);
}
});

@ -0,0 +1,26 @@
<script>
import ImmutableTodo from './ImmutableTodo.svelte';
let todos = [
{ id: 1, done: false },
{ id: 2, done: false },
{ id: 3, done: false }
];
function toggle(id) {
todos = todos.map((todo) => {
if (todo.id === id) {
return {
id,
done: !todo.done
};
}
return todo;
});
}
</script>
{#each todos as todo}
<ImmutableTodo {todo} on:click={() => toggle(todo.id)} />
{/each}

@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
immutable: true,
html: '<button>0</button> <button>0</button>',
test({ assert, target }) {
const [button1, button2] = target.querySelectorAll('button');
button1.click();
flushSync();
assert.htmlEqual(target.innerHTML, '<button>0</button> <button>0</button>');
button2.click();
flushSync();
assert.htmlEqual(target.innerHTML, '<button>2</button> <button>2</button>');
}
});

@ -0,0 +1,6 @@
<script>
let name = { value: 0 };
</script>
<button onclick={() => name.value++}>{name.value}</button>
<button onclick={() => (name = { value: name.value + 1 })}>{name.value}</button>
Loading…
Cancel
Save