diff --git a/.changeset/light-days-clean.md b/.changeset/light-days-clean.md new file mode 100644 index 000000000000..361e2c2c47fa --- /dev/null +++ b/.changeset/light-days-clean.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: ensure update methods of actions and reactive statements work with fine-grained `$state` diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js index ba09d5f04e74..02f4895ae9d4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js @@ -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); diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index db2cee8e0159..31520b7bb361 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -49,7 +49,8 @@ import { managed_effect, push, current_component_context, - pop + pop, + deep_read } from './runtime.js'; import { current_hydration_fragment, @@ -2312,9 +2313,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') { @@ -2322,6 +2325,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))); } @@ -3042,17 +3051,6 @@ export function unmount(component) { fn?.(); } -/** - * @param {Record} props - * @returns {void} - */ -export function access_props(props) { - for (const prop in props) { - // eslint-disable-next-line no-unused-expressions - props[prop]; - } -} - /** * @param {Record} props * @returns {Record} diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 8db5cfa54f91..429fd95d3461 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -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} 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) { diff --git a/packages/svelte/src/internal/index.js b/packages/svelte/src/internal/index.js index 9077585a3380..5ee100cc9097 100644 --- a/packages/svelte/src/internal/index.js +++ b/packages/svelte/src/internal/index.js @@ -38,7 +38,8 @@ export { inspect, unwrap, freeze, - init + init, + deep_read } from './client/runtime.js'; export * from './client/each.js'; export * from './client/render.js'; diff --git a/packages/svelte/src/legacy/legacy-client.js b/packages/svelte/src/legacy/legacy-client.js index 312a62e24bf0..5b962d309915 100644 --- a/packages/svelte/src/legacy/legacy-client.js +++ b/packages/svelte/src/legacy/legacy-client.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, diff --git a/packages/svelte/tests/runtime-legacy/samples/binding-backflow/_config.js b/packages/svelte/tests/runtime-legacy/samples/binding-backflow/_config.js index 7eefd6a75ca6..9e1561f40e17 100644 --- a/packages/svelte/tests/runtime-legacy/samples/binding-backflow/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/binding-backflow/_config.js @@ -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); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/action-state-arg/_config.js b/packages/svelte/tests/runtime-runes/samples/action-state-arg/_config.js new file mode 100644 index 000000000000..e2418a6cb8c5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/action-state-arg/_config.js @@ -0,0 +1,24 @@ +import { test } from '../../test'; +import { tick } from 'svelte'; + +export default test({ + html: `
0
`, + + async test({ assert, target }) { + const [btn1, btn2] = target.querySelectorAll('button'); + + btn1.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `
1
` + ); + + btn2.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `
2
` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/action-state-arg/main.svelte b/packages/svelte/tests/runtime-runes/samples/action-state-arg/main.svelte new file mode 100644 index 000000000000..efdea8d55096 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/action-state-arg/main.svelte @@ -0,0 +1,16 @@ + + + + +
{count}
diff --git a/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/_config.js b/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/_config.js new file mode 100644 index 000000000000..67f75b1cf5e2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/_config.js @@ -0,0 +1,24 @@ +import { test } from '../../test'; +import { tick } from 'svelte'; + +export default test({ + html: `

0 / 0

`, + + async test({ assert, target }) { + const [btn1, btn2] = target.querySelectorAll('button'); + + btn1.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

1 / 1

` + ); + + btn2.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2 / 2

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/main.svelte b/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/main.svelte new file mode 100644 index 000000000000..96df04df59fa --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/main.svelte @@ -0,0 +1,9 @@ + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/old.svelte b/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/old.svelte new file mode 100644 index 000000000000..0bee43ce5c07 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fine-grained-prop-reactive-statement/old.svelte @@ -0,0 +1,11 @@ + + + +

{count_1} / {count_2}