Skip to content

Commit ad64744

Browse files
committed
feat: effect-root-rune
feat: add $effect.root rune update doc
1 parent b7af2ff commit ad64744

File tree

11 files changed

+183
-4
lines changed

11 files changed

+183
-4
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/errors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ const runes = {
176176
'invalid-state-location': () =>
177177
`$state() can only be used as a variable declaration initializer or a class field`,
178178
'invalid-effect-location': () => `$effect() can only be used as an expression statement`,
179+
'invalid-effect-root-location': () =>
180+
`$effect.root() can only be used as a variable declaration initializer`,
179181
/**
180182
* @param {boolean} is_binding
181183
* @param {boolean} show_details

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,14 @@ 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 || node.arguments.length > 2) {
525+
error(node, 'invalid-rune-args-length', '$effect.root', [0]);
526+
}
527+
if (parent.type === 'VariableDeclarator') return;
528+
error(node, 'invalid-effect-root-location');
529+
}
522530
}
523531

524532
/**

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,18 @@ export const javascript_visitors_runes = {
208208
// TODO
209209
continue;
210210
}
211-
212211
const args = /** @type {import('estree').CallExpression} */ (declarator.init).arguments;
212+
213+
if (rune === '$effect.root') {
214+
const serialized_args = /** @type {import('estree').Expression[]} */ (
215+
args.map((arg) => visit(arg))
216+
);
217+
declarations.push(
218+
b.declarator(declarator.id, b.call('$.user_root_effect', ...serialized_args))
219+
);
220+
continue;
221+
}
222+
213223
const value =
214224
args.length === 0
215225
? b.id('undefined')
@@ -292,13 +302,20 @@ export const javascript_visitors_runes = {
292302

293303
context.next();
294304
},
295-
CallExpression(node, { state, next }) {
305+
CallExpression(node, { state, next, visit }) {
296306
const rune = get_rune(node, state.scope);
297307

298308
if (rune === '$effect.active') {
299309
return b.call('$.effect_active');
300310
}
301311

312+
if (rune === '$effect.root') {
313+
const args = /** @type {import('estree').Expression[]} */ (
314+
node.arguments.map((arg) => visit(arg))
315+
);
316+
return b.call('$.user_root_effect', ...args);
317+
}
318+
302319
next();
303320
}
304321
};

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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,21 @@ export function user_effect(init) {
11841184
return effect;
11851185
}
11861186

1187+
/**
1188+
* @param {() => void | (() => void)} init
1189+
* @param {() => void} [on_parent_cleanup]
1190+
* @returns {() => void}
1191+
*/
1192+
export function user_root_effect(init, on_parent_cleanup) {
1193+
const effect = managed_render_effect(init);
1194+
if (current_effect !== null && typeof on_parent_cleanup === 'function') {
1195+
push_destroy_fn(current_effect, on_parent_cleanup);
1196+
}
1197+
return () => {
1198+
destroy_signal(effect);
1199+
};
1200+
}
1201+
11871202
/**
11881203
* @param {() => void | (() => void)} init
11891204
* @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), fn2?: () => 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
log.push('cleanup 1');
18+
nested_cleanup();
19+
});
20+
});
21+
</script>
22+
23+
<button on:click={() => x++}>{x}</button>
24+
<button on:click={() => y++}>{y}</button>
25+
<button on:click={() => cleanup()}>cleanup</button>

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,44 @@ 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+
210+
If the `$effect.root` was created within within another active effect (such as during component initialisation) then it might
211+
be desirable to know when that active effect gets disposed and cleaned up. `$effect.root` takes an optional second arugment,
212+
which is a function callback for when this happens in case. This allows you to cleanup the effect root too if needed.
213+
214+
```svelte
215+
<script>
216+
let count = $state(0);
217+
218+
const cleanup = $effect.root(() => {
219+
// ...
220+
}. () => {
221+
console.log('parent effect is cleaned up');
222+
cleanup();
223+
});
224+
</script>
225+
```
226+
189227
## `$props`
190228

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

0 commit comments

Comments
 (0)