fix: untrack reads of mutated object

prevents infinite loops when mutation happens inside an effect
came up in #9639
pull/9685/head
Simon Holthausen 3 years ago
parent a31b2e1b8e
commit 82f3be2a66

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: untrack reads of mutated object

@ -1,5 +1,5 @@
import { error } from '../../errors.js';
import { extract_identifiers, is_text_attribute } from '../../utils/ast.js';
import { extract_identifiers, is_text_attribute, object } from '../../utils/ast.js';
import { warn } from '../../warnings.js';
import fuzzymatch from '../1-parse/utils/fuzzymatch.js';
import { binding_properties } from '../bindings.js';
@ -248,12 +248,8 @@ export const validation = {
BindDirective(node, context) {
validate_no_const_assignment(node, node.expression, context.state.scope, true);
let left = node.expression;
while (left.type === 'MemberExpression') {
left = /** @type {import('estree').MemberExpression} */ (left.object);
}
if (left.type !== 'Identifier') {
const left = object(node.expression);
if (left === null) {
error(node, 'invalid-binding-expression');
}

@ -205,27 +205,31 @@ export function serialize_set_binding(node, context, fallback) {
return b.call('$.set', b.id(left_name), value);
}
} else {
const left = /** @type {import('estree').MemberExpression} */ (visit(node.left));
// When reading the object that's mutated we need to untrack that read in order
// to avoid infinite loops. $.mutate(_store) does that by accepting a callback
// function it hands the value to. For this, we need to adjust the code to
// reference that passed value instead of reading the object again.
let id = left;
while (id.type === 'MemberExpression') {
if (id.object.type !== 'MemberExpression') {
id.object = b.id('$$to_mutate');
break;
} else {
id = id.object;
}
}
const update = b.arrow([b.id('$$to_mutate')], b.assignment(node.operator, left, value));
if (is_store) {
return b.call(
'$.mutate_store',
serialize_get_binding(b.id(left_name), state),
b.assignment(
node.operator,
/** @type {import('estree').Pattern} */ (visit(node.left)),
value
),
b.call('$' + left_name)
b.id('$' + left_name),
update
);
} else {
return b.call(
'$.mutate',
b.id(left_name),
b.assignment(
node.operator,
/** @type {import('estree').Pattern} */ (visit(node.left)),
value
)
);
return b.call('$.mutate', b.id(left_name), update);
}
}
};

@ -206,13 +206,7 @@ function collect_transitive_dependencies(binding, seen = new Set()) {
* @param {import('../types.js').ComponentContext} context
*/
function setup_select_synchronization(value_binding, context) {
let bound = value_binding.expression;
while (bound.type === 'MemberExpression') {
bound = /** @type {import('estree').Identifier | import('estree').MemberExpression} */ (
bound.object
);
}
const bound = /** @type {import('estree').Identifier} */ (object(value_binding.expression));
/** @type {string[]} */
const names = [];

@ -1,6 +1,11 @@
import { walk } from 'zimmerframe';
import { set_scope, get_rune } from '../../scope.js';
import { extract_identifiers, extract_paths, is_event_attribute } from '../../../utils/ast.js';
import {
extract_identifiers,
extract_paths,
is_event_attribute,
object
} from '../../../utils/ast.js';
import * as b from '../../../utils/builders.js';
import is_reference from 'is-reference';
import {
@ -417,14 +422,8 @@ function serialize_set_binding(node, context, fallback) {
error(node, 'INTERNAL', `Unexpected assignment type ${node.left.type}`);
}
let left = node.left;
while (left.type === 'MemberExpression') {
// @ts-expect-error
left = left.object;
}
if (left.type !== 'Identifier') {
const left = object(node.left);
if (left === null) {
return fallback();
}

@ -862,7 +862,7 @@ export function invalidate_inner_signals(fn) {
}
let signal;
for (signal of captured_signals) {
mutate(signal, null /* doesnt matter */);
mutate(signal, () => {} /* doesnt matter */);
}
return captured_signals;
}
@ -870,26 +870,27 @@ export function invalidate_inner_signals(fn) {
/**
* @template V
* @param {import('./types.js').Signal<V>} source
* @param {V} value
* @param {(v: V) => any} update
*/
export function mutate(source, value) {
set_signal_value(
source,
untrack(() => get(source))
);
return value;
export function mutate(source, update) {
var value = untrack(() => get(source));
var updated = update(value);
set_signal_value(source, value);
return updated;
}
/**
* Updates a store with a new value.
* @param {import('./types.js').Store<V>} store the store to update
* @param {any} expression the expression that mutates the store
* @param {V} new_value the new store value
* @param {() => V} get_value function to retrieve the current store value
* @param {(v: V) => any} update the expression that mutates the store
* @template V
*/
export function mutate_store(store, expression, new_value) {
store.set(new_value);
return expression;
export function mutate_store(store, get_value, update) {
var updated = update(untrack(get_value));
// mutation could result in a new store being tracked, therefore call get_value again
store.set(untrack(get_value));
return updated;
}
/**

@ -0,0 +1,54 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
// Test ensures that reading the state that's mutated is done in a manner
// so that the read is untracked so it doesn't trigger infinite loops
export default test({
html: `
<input>
<input>
<p>text: foo</p>
<p>wrapped.contents: foo</p>
`,
skip_if_ssr: true,
test({ assert, target }) {
const [input1, input2] = target.querySelectorAll('input');
input1.value = 'bar';
flushSync(() => input1.dispatchEvent(new Event('input')));
assert.htmlEqual(
target.innerHTML,
`
<input>
<input>
<p>text: bar</p>
<p>wrapped.contents: bar</p>
`
);
input2.value = 'baz';
flushSync(() => input2.dispatchEvent(new Event('input')));
assert.htmlEqual(
target.innerHTML,
`
<input>
<input>
<p>text: bar</p>
<p>wrapped.contents: baz</p>
`
);
input1.value = 'foo';
flushSync(() => input1.dispatchEvent(new Event('input')));
assert.htmlEqual(
target.innerHTML,
`
<input>
<input>
<p>text: foo</p>
<p>wrapped.contents: foo</p>
`
);
}
});

@ -0,0 +1,10 @@
<script>
let text = $state('foo');
let wrapped = $state({});
$effect(() => { wrapped.contents = text; })
</script>
<input bind:value={text} />
<input bind:value={wrapped.contents} />
<p>text: {text}</p>
<p>wrapped.contents: {wrapped.contents}</p>

@ -0,0 +1,54 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
// Test ensures that reading the state that's mutated is done in a manner
// so that the read is untracked so it doesn't trigger infinite loops
export default test({
html: `
<input>
<input>
<p>text: foo</p>
<p>wrapped.contents: foo</p>
`,
skip_if_ssr: true,
test({ assert, target }) {
const [input1, input2] = target.querySelectorAll('input');
input1.value = 'bar';
flushSync(() => input1.dispatchEvent(new Event('input')));
assert.htmlEqual(
target.innerHTML,
`
<input>
<input>
<p>text: bar</p>
<p>wrapped.contents: bar</p>
`
);
input2.value = 'baz';
flushSync(() => input2.dispatchEvent(new Event('input')));
assert.htmlEqual(
target.innerHTML,
`
<input>
<input>
<p>text: bar</p>
<p>wrapped.contents: baz</p>
`
);
input1.value = 'foo';
flushSync(() => input1.dispatchEvent(new Event('input')));
assert.htmlEqual(
target.innerHTML,
`
<input>
<input>
<p>text: foo</p>
<p>wrapped.contents: foo</p>
`
);
}
});

@ -0,0 +1,12 @@
<script>
import { writable } from "svelte/store";
let text = writable('foo');
let wrapped = writable({});
$effect(() => { $wrapped.contents = $text; })
</script>
<input bind:value={$text} />
<input bind:value={$wrapped.contents} />
<p>text: {$text}</p>
<p>wrapped.contents: {$wrapped.contents}</p>
Loading…
Cancel
Save