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 5 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;
let serialized = serialize_get_binding(b.id(name), state);
if (name === '$$props' || name === '$$restProps') {
serialized = b.call('$.access_props', serialized);
// If the binding is a prop, we need to deep read it because it could be fine-grained $state
// 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);

@ -43,7 +43,8 @@ import {
managed_effect,
push,
current_component_context,
pop
pop,
deep_read
} from './runtime.js';
import {
current_hydration_fragment,
@ -1890,9 +1891,11 @@ export function action(dom, action, value_fn) {
effect(() => {
if (value_fn) {
const value = value_fn();
let needs_deep_read = false;
untrack(() => {
if (payload === undefined) {
payload = action(dom, value) || {};
needs_deep_read = !!payload?.update;
} else {
const update = payload.update;
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 {
untrack(() => (payload = action(dom)));
}
@ -2620,17 +2629,6 @@ export function unmount(component) {
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
* @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 {Set<any>} visited
* @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)) {
visited.add(value);
for (let key in value) {

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

@ -66,6 +66,9 @@ class Svelte4Component {
* }} 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);
this.#instance = (options.hydrate ? $.hydrate : $.mount)(options.component, {
target: options.target,

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