Skip to content

Commit bc6f1ce

Browse files
committed
fix: improve action support for nested $effect
1 parent 326e2b4 commit bc6f1ce

File tree

6 files changed

+130
-19
lines changed

6 files changed

+130
-19
lines changed

.changeset/itchy-bulldogs-tan.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: improve action support for nested $effect

packages/svelte/src/internal/client/dom/elements/actions.js

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { effect } from '../../reactivity/effects.js';
2-
import { deep_read_state, untrack } from '../../runtime.js';
2+
import { source } from '../../reactivity/sources.js';
3+
import { deep_read_state, get, untrack, update } from '../../runtime.js';
34

45
/**
56
* @template P
@@ -11,33 +12,54 @@ import { deep_read_state, untrack } from '../../runtime.js';
1112
export function action(dom, action, value_fn) {
1213
/** @type {undefined | import('#client').ActionPayload<P>} */
1314
var payload = undefined;
14-
var needs_deep_read = false;
15+
var version = source(0);
16+
/**
17+
* @type {P}
18+
*/
19+
var value;
20+
var init = false;
21+
22+
// The value needs to be calculated independent of the action body, as we only want to evaluate
23+
// the payload of the action once – not when the value changes.
24+
effect(() => {
25+
get(version);
26+
if (value_fn) {
27+
value = value_fn();
28+
}
29+
if (payload !== undefined) {
30+
var update = payload.update;
31+
if (typeof update === 'function') {
32+
update(value);
33+
}
34+
}
35+
});
1536

1637
// Action could come from a prop, therefore could be a signal, therefore untrack
1738
// TODO we could take advantage of this and enable https://github.com/sveltejs/svelte/issues/6942
1839
effect(() => {
19-
if (value_fn) {
20-
var value = value_fn();
21-
untrack(() => {
40+
untrack(() => {
41+
if (value_fn) {
2242
if (payload === undefined) {
2343
payload = action(dom, value) || {};
24-
needs_deep_read = !!payload?.update;
25-
} else {
26-
var update = payload.update;
27-
if (typeof update === 'function') {
28-
update(value);
44+
if (payload?.update) {
45+
// Action's update method is coarse-grained, i.e. when anything in the passed value changes, update.
46+
// This works in legacy mode because of mutable_source being updated as a whole, but when using $state
47+
// together with actions and mutation, it wouldn't notice the change without a deep read.
48+
effect(() => {
49+
deep_read_state(value);
50+
if (init) {
51+
untrack(() => {
52+
update(version, 1);
53+
});
54+
}
55+
init = true;
56+
});
2957
}
3058
}
31-
});
32-
// Action's update method is coarse-grained, i.e. when anything in the passed value changes, update.
33-
// This works in legacy mode because of mutable_source being updated as a whole, but when using $state
34-
// together with actions and mutation, it wouldn't notice the change without a deep read.
35-
if (needs_deep_read) {
36-
deep_read_state(value);
59+
} else {
60+
payload = action(dom);
3761
}
38-
} else {
39-
untrack(() => (payload = action(dom)));
40-
}
62+
});
4163
});
4264

4365
effect(() => {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script>
2+
import { log } from './log.js';
3+
4+
const { prop } = $props()
5+
6+
let trackedState = $state(0)
7+
const getTrackedState = () => trackedState
8+
9+
function dummyAction(el, { getTrackedState, propFromComponent }) {
10+
$effect(() => {
11+
log.push("action $effect: ", { buttonClicked: getTrackedState() })
12+
})
13+
}
14+
</script>
15+
16+
<div
17+
class="container"
18+
use:dummyAction={{ getTrackedState, propFromComponent: prop }}
19+
>
20+
{JSON.stringify(prop)}
21+
</div>
22+
23+
<button
24+
onclick={() => {
25+
trackedState += 1
26+
}}
27+
>
28+
update tracked state
29+
</button>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { test } from '../../test';
2+
import { flushSync, tick } from 'svelte';
3+
import { log } from './log.js';
4+
5+
export default test({
6+
before_test() {
7+
log.length = 0;
8+
},
9+
10+
html: `<div class="container">{"text":"initial"}</div><button>update tracked state</button><button>Update prop</button>`,
11+
12+
async test({ assert, target }) {
13+
const [btn1, btn2] = target.querySelectorAll('button');
14+
15+
flushSync(() => {
16+
btn2.click();
17+
});
18+
19+
assert.htmlEqual(
20+
target.innerHTML,
21+
`<div class="container">{"text":"updated"}</div><button>update tracked state</button><button>Update prop</button>`
22+
);
23+
24+
flushSync(() => {
25+
btn1.click();
26+
});
27+
28+
assert.htmlEqual(
29+
target.innerHTML,
30+
`<div class="container">{"text":"updated"}</div><button>update tracked state</button><button>Update prop</button>`
31+
);
32+
33+
assert.deepEqual(log, [
34+
'action $effect: ',
35+
{ buttonClicked: 0 },
36+
'action $effect: ',
37+
{ buttonClicked: 1 }
38+
]);
39+
}
40+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script>
2+
import Task from "./Task.svelte"
3+
4+
let task = $state({ text: "initial" })
5+
</script>
6+
7+
<Task prop={task} />
8+
9+
<button onclick={() => {
10+
task = { text: "updated" }
11+
}}>
12+
Update prop
13+
</button>

0 commit comments

Comments
 (0)