Skip to content

Commit c8b75fa

Browse files
committed
fix: better interop of $state with actions/$: statements
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 simeydotme/svelte-range-slider-pips#130
1 parent 9c8e643 commit c8b75fa

File tree

10 files changed

+118
-7
lines changed

10 files changed

+118
-7
lines changed

Diff for: .changeset/light-days-clean.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: ensure update methods of actions and reactive statements work with fine-grained `$state`

Diff for: packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,10 @@ export const javascript_visitors_legacy = {
167167
const name = binding.node.name;
168168
let serialized = serialize_get_binding(b.id(name), state);
169169

170-
if (name === '$$props' || name === '$$restProps') {
171-
serialized = b.call('$.access_props', serialized);
170+
// If the binding is a prop, we need to deep read it because it could be fine-grained $state
171+
// from a runes-component, where mutations don't trigger an update on the prop as a whole.
172+
if (name === '$$props' || name === '$$restProps' || binding.kind === 'prop') {
173+
serialized = b.call('$.deep_read', serialized);
172174
}
173175

174176
sequence.push(serialized);

Diff for: packages/svelte/src/internal/client/render.js

+20-3
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ import {
4949
managed_effect,
5050
push,
5151
current_component_context,
52-
pop
52+
pop,
53+
deep_read
5354
} from './runtime.js';
5455
import {
5556
current_hydration_fragment,
@@ -2312,16 +2313,24 @@ export function action(dom, action, value_fn) {
23122313
effect(() => {
23132314
if (value_fn) {
23142315
const value = value_fn();
2316+
let needs_deep_read = false;
23152317
untrack(() => {
23162318
if (payload === undefined) {
23172319
payload = action(dom, value) || {};
23182320
} else {
23192321
const update = payload.update;
23202322
if (typeof update === 'function') {
2323+
needs_deep_read = true;
23212324
update(value);
23222325
}
23232326
}
23242327
});
2328+
// Action's update method is coarse-grained, i.e. when anything in the passed value changes, update.
2329+
// This works in legacy mode because of mutable_source being updated as a whole, but when using $state
2330+
// together with actions and mutation, it wouldn't notice the change without a deep read.
2331+
if (needs_deep_read) {
2332+
deep_read(value);
2333+
}
23252334
} else {
23262335
untrack(() => (payload = action(dom)));
23272336
}
@@ -3048,8 +3057,16 @@ export function unmount(component) {
30483057
*/
30493058
export function access_props(props) {
30503059
for (const prop in props) {
3051-
// eslint-disable-next-line no-unused-expressions
3052-
props[prop];
3060+
deep_read(props[prop]);
3061+
}
3062+
}
3063+
3064+
/**
3065+
* @param {any[]} values
3066+
*/
3067+
export function deep_read_all(...values) {
3068+
for (const value of values) {
3069+
deep_read(value);
30533070
}
30543071
}
30553072

Diff for: packages/svelte/src/internal/client/runtime.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -1985,11 +1985,13 @@ export function init() {
19851985
}
19861986

19871987
/**
1988+
* Deeply traverse an object and read all its properties
1989+
* so that they're all reactive in case this is `$state`
19881990
* @param {any} value
19891991
* @param {Set<any>} visited
19901992
* @returns {void}
19911993
*/
1992-
function deep_read(value, visited = new Set()) {
1994+
export function deep_read(value, visited = new Set()) {
19931995
if (typeof value === 'object' && value !== null && !visited.has(value)) {
19941996
visited.add(value);
19951997
for (let key in value) {

Diff for: packages/svelte/src/internal/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export {
3838
inspect,
3939
unwrap,
4040
freeze,
41-
init
41+
init,
42+
deep_read
4243
} from './client/runtime.js';
4344
export * from './client/each.js';
4445
export * from './client/render.js';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { test } from '../../test';
2+
import { tick } from 'svelte';
3+
4+
export default test({
5+
html: `<button>reassign</button><button>mutate</button><div>0</div>`,
6+
7+
async test({ assert, target }) {
8+
const [btn1, btn2] = target.querySelectorAll('button');
9+
10+
btn1.click();
11+
await tick();
12+
assert.htmlEqual(
13+
target.innerHTML,
14+
`<button>reassign</button><button>mutate</button><div>1</div>`
15+
);
16+
17+
btn2.click();
18+
await tick();
19+
assert.htmlEqual(
20+
target.innerHTML,
21+
`<button>reassign</button><button>mutate</button><div>2</div>`
22+
);
23+
}
24+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script>
2+
let foo = $state({ count: 0 });
3+
let count = $state(0);
4+
5+
function action() {
6+
return {
7+
update(foo) {
8+
count = foo.count;
9+
}
10+
}
11+
}
12+
</script>
13+
14+
<button onclick={() => foo = {...foo, count: foo.count + 1 }}>reassign</button>
15+
<button onclick={() => foo.count++}>mutate</button>
16+
<div use:action={foo}>{count}</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { test } from '../../test';
2+
import { tick } from 'svelte';
3+
4+
export default test({
5+
html: `<button>reassign</button><button>mutate</button><p>0 / 0</p>`,
6+
7+
async test({ assert, target }) {
8+
const [btn1, btn2] = target.querySelectorAll('button');
9+
10+
btn1.click();
11+
await tick();
12+
assert.htmlEqual(
13+
target.innerHTML,
14+
`<button>reassign</button><button>mutate</button><p>1 / 1</p>`
15+
);
16+
17+
btn2.click();
18+
await tick();
19+
assert.htmlEqual(
20+
target.innerHTML,
21+
`<button>reassign</button><button>mutate</button><p>2 / 2</p>`
22+
);
23+
}
24+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
import Old from './old.svelte';
3+
4+
let prop = $state({ count: 0 });
5+
</script>
6+
7+
<button onclick={() => prop = {...prop, count: prop.count + 1 }}>reassign</button>
8+
<button onclick={() => prop.count++}>mutate</button>
9+
<Old {prop}></Old>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<svelte:options runes={false} />
2+
<script>
3+
export let prop;
4+
let count_1 = prop.count;
5+
$: {
6+
count_1 = prop.count;
7+
}
8+
$: count_2 = prop.count;
9+
</script>
10+
11+
<p>{count_1} / {count_2}</p>

0 commit comments

Comments
 (0)