fix: better interop of `$state` with actions/`$:` statements (#10543)

Ensure update methods of actions and reactive statements work with fine-grained `$state` by deep-reading them. This is necessary because mutations to `$state` objects don't notify listeners to only the object as a whole, it only notifies the listeners of the property that changed.
fixes #10460
fixes https://github.com/simeydotme/svelte-range-slider-pips/issues/130
pull/10553/head
Simon H 8 months ago committed by GitHub
parent 4c9d71f8cf
commit f5102013af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: ensure update methods of actions and reactive statements work with fine-grained `$state`

@ -167,8 +167,10 @@ export const javascript_visitors_legacy = {
const name = binding.node.name; const name = binding.node.name;
let serialized = serialize_get_binding(b.id(name), state); let serialized = serialize_get_binding(b.id(name), state);
if (name === '$$props' || name === '$$restProps') { // If the binding is a prop, we need to deep read it because it could be fine-grained $state
serialized = b.call('$.access_props', serialized); // from a runes-component, where mutations don't trigger an update on the prop as a whole.
if (name === '$$props' || name === '$$restProps' || binding.kind === 'prop') {
serialized = b.call('$.deep_read', serialized);
} }
sequence.push(serialized); sequence.push(serialized);

@ -43,7 +43,8 @@ import {
managed_effect, managed_effect,
push, push,
current_component_context, current_component_context,
pop pop,
deep_read
} from './runtime.js'; } from './runtime.js';
import { import {
current_hydration_fragment, current_hydration_fragment,
@ -1890,9 +1891,11 @@ export function action(dom, action, value_fn) {
effect(() => { effect(() => {
if (value_fn) { if (value_fn) {
const value = value_fn(); const value = value_fn();
let needs_deep_read = false;
untrack(() => { untrack(() => {
if (payload === undefined) { if (payload === undefined) {
payload = action(dom, value) || {}; payload = action(dom, value) || {};
needs_deep_read = !!payload?.update;
} else { } else {
const update = payload.update; const update = payload.update;
if (typeof update === 'function') { if (typeof update === 'function') {
@ -1900,6 +1903,12 @@ export function action(dom, action, value_fn) {
} }
} }
}); });
// Action's update method is coarse-grained, i.e. when anything in the passed value changes, update.
// This works in legacy mode because of mutable_source being updated as a whole, but when using $state
// together with actions and mutation, it wouldn't notice the change without a deep read.
if (needs_deep_read) {
deep_read(value);
}
} else { } else {
untrack(() => (payload = action(dom))); untrack(() => (payload = action(dom)));
} }
@ -2620,17 +2629,6 @@ export function unmount(component) {
fn?.(); fn?.();
} }
/**
* @param {Record<string, unknown>} props
* @returns {void}
*/
export function access_props(props) {
for (const prop in props) {
// eslint-disable-next-line no-unused-expressions
props[prop];
}
}
/** /**
* @param {Record<string, any>} props * @param {Record<string, any>} props
* @returns {Record<string, any>} * @returns {Record<string, any>}

@ -1985,11 +1985,13 @@ export function init() {
} }
/** /**
* Deeply traverse an object and read all its properties
* so that they're all reactive in case this is `$state`
* @param {any} value * @param {any} value
* @param {Set<any>} visited * @param {Set<any>} visited
* @returns {void} * @returns {void}
*/ */
function deep_read(value, visited = new Set()) { export function deep_read(value, visited = new Set()) {
if (typeof value === 'object' && value !== null && !visited.has(value)) { if (typeof value === 'object' && value !== null && !visited.has(value)) {
visited.add(value); visited.add(value);
for (let key in value) { for (let key in value) {

@ -38,7 +38,8 @@ export {
inspect, inspect,
unwrap, unwrap,
freeze, freeze,
init init,
deep_read
} from './client/runtime.js'; } from './client/runtime.js';
export { await_block as await } from './client/dom/blocks/await.js'; export { await_block as await } from './client/dom/blocks/await.js';
export { if_block as if } from './client/dom/blocks/if.js'; export { if_block as if } from './client/dom/blocks/if.js';

@ -66,6 +66,9 @@ class Svelte4Component {
* }} options * }} options
*/ */
constructor(options) { constructor(options) {
// Using proxy state here isn't completely mirroring the Svelte 4 behavior, because mutations to a property
// cause fine-grained updates to only the places where that property is used, and not the entire property.
// Reactive statements and actions (the things where this matters) are handling this properly regardless, so it should be fine in practise.
const props = $.proxy({ ...(options.props || {}), $$events: this.#events }, false); const props = $.proxy({ ...(options.props || {}), $$events: this.#events }, false);
this.#instance = (options.hydrate ? $.hydrate : $.mount)(options.component, { this.#instance = (options.hydrate ? $.hydrate : $.mount)(options.component, {
target: options.target, target: options.target,

@ -34,7 +34,7 @@ export default test({
p = parents['reactive_mutate']; p = parents['reactive_mutate'];
assert.deepEqual(p.value, { foo: 'kid' }); assert.deepEqual(p.value, { foo: 'kid' });
assert.equal(p.updates.length, 1); assert.equal(p.updates.length, 2);
p = parents['init_update']; p = parents['init_update'];
assert.deepEqual(p.value, { foo: 'kid' }); assert.deepEqual(p.value, { foo: 'kid' });
@ -42,6 +42,6 @@ export default test({
p = parents['init_mutate']; p = parents['init_mutate'];
assert.deepEqual(p.value, { foo: 'kid' }); assert.deepEqual(p.value, { foo: 'kid' });
assert.equal(p.updates.length, 1); assert.equal(p.updates.length, 2);
} }
}); });

@ -0,0 +1,24 @@
import { test } from '../../test';
import { tick } from 'svelte';
export default test({
html: `<button>mutate</button><button>reassign</button><div>0</div>`,
async test({ assert, target }) {
const [btn1, btn2] = target.querySelectorAll('button');
btn1.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>mutate</button><button>reassign</button><div>1</div>`
);
btn2.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>mutate</button><button>reassign</button><div>2</div>`
);
}
});

@ -0,0 +1,16 @@
<script>
let foo = $state({ count: 0 });
let count = $state(0);
function action() {
return {
update(foo) {
count = foo.count;
}
}
}
</script>
<button onclick={() => foo.count++}>mutate</button>
<button onclick={() => foo = {...foo, count: foo.count + 1 }}>reassign</button>
<div use:action={foo}>{count}</div>

@ -0,0 +1,24 @@
import { test } from '../../test';
import { tick } from 'svelte';
export default test({
html: `<button>reassign</button><button>mutate</button><p>0 / 0</p>`,
async test({ assert, target }) {
const [btn1, btn2] = target.querySelectorAll('button');
btn1.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>reassign</button><button>mutate</button><p>1 / 1</p>`
);
btn2.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>reassign</button><button>mutate</button><p>2 / 2</p>`
);
}
});

@ -0,0 +1,9 @@
<script>
import Old from './old.svelte';
let prop = $state({ count: 0 });
</script>
<button onclick={() => prop = {...prop, count: prop.count + 1 }}>reassign</button>
<button onclick={() => prop.count++}>mutate</button>
<Old {prop}></Old>

@ -0,0 +1,11 @@
<svelte:options runes={false} />
<script>
export let prop;
let count_1 = prop.count;
$: {
count_1 = prop.count;
}
$: count_2 = prop.count;
</script>
<p>{count_1} / {count_2}</p>
Loading…
Cancel
Save