Skip to content

Commit 81d3e47

Browse files
trueadmRich-Harris
andauthored
feat: add $effect.root rune (#9638)
* feat: effect-root-rune feat: add $effect.root rune update doc update doc fix validation * cleanup logic * Update sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md * address feedback --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 2660727 commit 81d3e47

File tree

10 files changed

+150
-5
lines changed

10 files changed

+150
-5
lines changed

.changeset/rare-pears-whisper.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+
feat: add $effect.root rune

packages/svelte/src/compiler/phases/2-analyze/validation.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,12 @@ function validate_call_expression(node, scope, path) {
519519
error(node, 'invalid-rune-args-length', '$effect.active', [0]);
520520
}
521521
}
522+
523+
if (rune === '$effect.root') {
524+
if (node.arguments.length !== 1) {
525+
error(node, 'invalid-rune-args-length', '$effect.root', [1]);
526+
}
527+
}
522528
}
523529

524530
/**

packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export const javascript_visitors_runes = {
135135
for (const declarator of node.declarations) {
136136
const init = declarator.init;
137137
const rune = get_rune(init, state.scope);
138-
if (!rune || rune === '$effect.active') {
138+
if (!rune || rune === '$effect.active' || rune === '$effect.root') {
139139
if (init != null && is_hoistable_function(init)) {
140140
const hoistable_function = visit(init);
141141
state.hoisted.push(
@@ -208,7 +208,6 @@ export const javascript_visitors_runes = {
208208
// TODO
209209
continue;
210210
}
211-
212211
const args = /** @type {import('estree').CallExpression} */ (declarator.init).arguments;
213212
const value =
214213
args.length === 0
@@ -292,13 +291,20 @@ export const javascript_visitors_runes = {
292291

293292
context.next();
294293
},
295-
CallExpression(node, { state, next }) {
294+
CallExpression(node, { state, next, visit }) {
296295
const rune = get_rune(node, state.scope);
297296

298297
if (rune === '$effect.active') {
299298
return b.call('$.effect_active');
300299
}
301300

301+
if (rune === '$effect.root') {
302+
const args = /** @type {import('estree').Expression[]} */ (
303+
node.arguments.map((arg) => visit(arg))
304+
);
305+
return b.call('$.user_root_effect', ...args);
306+
}
307+
302308
next();
303309
}
304310
};

packages/svelte/src/compiler/phases/constants.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,15 @@ export const ElementBindings = [
7070
'indeterminate'
7171
];
7272

73-
export const Runes = ['$state', '$props', '$derived', '$effect', '$effect.pre', '$effect.active'];
73+
export const Runes = [
74+
'$state',
75+
'$props',
76+
'$derived',
77+
'$effect',
78+
'$effect.pre',
79+
'$effect.active',
80+
'$effect.root'
81+
];
7482

7583
/**
7684
* Whitespace inside one of these elements will not result in

packages/svelte/src/internal/client/runtime.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,17 @@ export function user_effect(init) {
11841184
return effect;
11851185
}
11861186

1187+
/**
1188+
* @param {() => void | (() => void)} init
1189+
* @returns {() => void}
1190+
*/
1191+
export function user_root_effect(init) {
1192+
const effect = managed_render_effect(init);
1193+
return () => {
1194+
destroy_signal(effect);
1195+
};
1196+
}
1197+
11871198
/**
11881199
* @param {() => void | (() => void)} init
11891200
* @returns {import('./types.js').EffectSignal}

packages/svelte/src/internal/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export {
3636
pop,
3737
push,
3838
reactive_import,
39-
effect_active
39+
effect_active,
40+
user_root_effect
4041
} from './client/runtime.js';
4142

4243
export * from './client/validate.js';

packages/svelte/src/main/ambient.d.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,34 @@ declare namespace $effect {
9090
* https://svelte-5-preview.vercel.app/docs/runes#$effect-active
9191
*/
9292
export function active(): boolean;
93+
94+
/**
95+
* The `$effect.root` rune is an advanced feature that creates a non-tracked scope that doesn't auto-cleanup. This is useful for
96+
* nested effects that you want to manually control. This rune also allows for creation of effects outside of the component
97+
* initialisation phase.
98+
*
99+
* Example:
100+
* ```svelte
101+
* <script>
102+
* let count = $state(0);
103+
*
104+
* const cleanup = $effect.root(() => {
105+
* $effect(() => {
106+
* console.log(count);
107+
* })
108+
*
109+
* return () => {
110+
* console.log('effect root cleanup');
111+
* }
112+
* });
113+
* </script>
114+
*
115+
* <button onclick={() => cleanup()}>cleanup</button>
116+
* ```
117+
*
118+
* https://svelte-5-preview.vercel.app/docs/runes#$effect-root
119+
*/
120+
export function root(fn: () => void | (() => void)): () => void;
93121
}
94122

95123
/**
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
get props() {
6+
return { log: [] };
7+
},
8+
9+
async test({ assert, target, component }) {
10+
const [b1, b2, b3] = target.querySelectorAll('button');
11+
12+
flushSync(() => {
13+
b1.click();
14+
b2.click();
15+
});
16+
17+
assert.deepEqual(component.log, [0, 1]);
18+
19+
flushSync(() => {
20+
b3.click();
21+
});
22+
23+
assert.deepEqual(component.log, [0, 1, 'cleanup 1', 'cleanup 2']);
24+
25+
flushSync(() => {
26+
b1.click();
27+
b2.click();
28+
});
29+
30+
assert.deepEqual(component.log, [0, 1, 'cleanup 1', 'cleanup 2']);
31+
}
32+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<script>
2+
let { log} = $props();
3+
4+
let x = $state(0);
5+
let y = $state(0);
6+
7+
const cleanup = $effect.root(() => {
8+
$effect(() => {
9+
log.push(x);
10+
});
11+
12+
const nested_cleanup = $effect.root(() => {
13+
return () => {
14+
log.push('cleanup 2') ;
15+
}
16+
});
17+
18+
return () => {
19+
log.push('cleanup 1');
20+
nested_cleanup();
21+
}
22+
});
23+
</script>
24+
25+
<button on:click={() => x++}>{x}</button>
26+
<button on:click={() => y++}>{y}</button>
27+
<button on:click={() => cleanup()}>cleanup</button>

sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,27 @@ The `$effect.active` rune is an advanced feature that tells you whether or not t
186186

187187
This allows you to (for example) add things like subscriptions without causing memory leaks, by putting them in child effects.
188188

189+
## `$effect.root`
190+
191+
The `$effect.root` rune is an advanced feature that creates a non-tracked scope that doesn't auto-cleanup. This is useful for
192+
nested effects that you want to manually control. This rune also allows for creation of effects outside of the component initialisation phase.
193+
194+
```svelte
195+
<script>
196+
let count = $state(0);
197+
198+
const cleanup = $effect.root(() => {
199+
$effect(() => {
200+
console.log(count);
201+
});
202+
203+
return () => {
204+
console.log('effect root cleanup');
205+
};
206+
});
207+
</script>
208+
```
209+
189210
## `$props`
190211

191212
To declare component props, use the `$props` rune:

0 commit comments

Comments
 (0)