diff --git a/.changeset/rare-pears-whisper.md b/.changeset/rare-pears-whisper.md
new file mode 100644
index 000000000000..05dc333b319b
--- /dev/null
+++ b/.changeset/rare-pears-whisper.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+feat: add $effect.root rune
diff --git a/packages/svelte/src/compiler/phases/2-analyze/validation.js b/packages/svelte/src/compiler/phases/2-analyze/validation.js
index e799c38851a7..e871808c2cc0 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/validation.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/validation.js
@@ -519,6 +519,12 @@ function validate_call_expression(node, scope, path) {
error(node, 'invalid-rune-args-length', '$effect.active', [0]);
}
}
+
+ if (rune === '$effect.root') {
+ if (node.arguments.length !== 1) {
+ error(node, 'invalid-rune-args-length', '$effect.root', [1]);
+ }
+ }
}
/**
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js
index 1aa78b14af20..f3b0d2b90842 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js
@@ -135,7 +135,7 @@ export const javascript_visitors_runes = {
for (const declarator of node.declarations) {
const init = declarator.init;
const rune = get_rune(init, state.scope);
- if (!rune || rune === '$effect.active') {
+ if (!rune || rune === '$effect.active' || rune === '$effect.root') {
if (init != null && is_hoistable_function(init)) {
const hoistable_function = visit(init);
state.hoisted.push(
@@ -208,7 +208,6 @@ export const javascript_visitors_runes = {
// TODO
continue;
}
-
const args = /** @type {import('estree').CallExpression} */ (declarator.init).arguments;
const value =
args.length === 0
@@ -292,13 +291,20 @@ export const javascript_visitors_runes = {
context.next();
},
- CallExpression(node, { state, next }) {
+ CallExpression(node, { state, next, visit }) {
const rune = get_rune(node, state.scope);
if (rune === '$effect.active') {
return b.call('$.effect_active');
}
+ if (rune === '$effect.root') {
+ const args = /** @type {import('estree').Expression[]} */ (
+ node.arguments.map((arg) => visit(arg))
+ );
+ return b.call('$.user_root_effect', ...args);
+ }
+
next();
}
};
diff --git a/packages/svelte/src/compiler/phases/constants.js b/packages/svelte/src/compiler/phases/constants.js
index 0b06c5eda09c..c646016f8d6e 100644
--- a/packages/svelte/src/compiler/phases/constants.js
+++ b/packages/svelte/src/compiler/phases/constants.js
@@ -70,7 +70,15 @@ export const ElementBindings = [
'indeterminate'
];
-export const Runes = ['$state', '$props', '$derived', '$effect', '$effect.pre', '$effect.active'];
+export const Runes = [
+ '$state',
+ '$props',
+ '$derived',
+ '$effect',
+ '$effect.pre',
+ '$effect.active',
+ '$effect.root'
+];
/**
* Whitespace inside one of these elements will not result in
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index db5cf7be30e3..961051556355 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -1184,6 +1184,17 @@ export function user_effect(init) {
return effect;
}
+/**
+ * @param {() => void | (() => void)} init
+ * @returns {() => void}
+ */
+export function user_root_effect(init) {
+ const effect = managed_render_effect(init);
+ return () => {
+ destroy_signal(effect);
+ };
+}
+
/**
* @param {() => void | (() => void)} init
* @returns {import('./types.js').EffectSignal}
diff --git a/packages/svelte/src/internal/index.js b/packages/svelte/src/internal/index.js
index be6459b7dff6..d179a3ec9e9f 100644
--- a/packages/svelte/src/internal/index.js
+++ b/packages/svelte/src/internal/index.js
@@ -36,7 +36,8 @@ export {
pop,
push,
reactive_import,
- effect_active
+ effect_active,
+ user_root_effect
} from './client/runtime.js';
export * from './client/validate.js';
diff --git a/packages/svelte/src/main/ambient.d.ts b/packages/svelte/src/main/ambient.d.ts
index f32f1863e94a..214d89e4f31a 100644
--- a/packages/svelte/src/main/ambient.d.ts
+++ b/packages/svelte/src/main/ambient.d.ts
@@ -90,6 +90,34 @@ declare namespace $effect {
* https://svelte-5-preview.vercel.app/docs/runes#$effect-active
*/
export function active(): boolean;
+
+ /**
+ * The `$effect.root` rune is an advanced feature that creates a non-tracked scope that doesn't auto-cleanup. This is useful for
+ * nested effects that you want to manually control. This rune also allows for creation of effects outside of the component
+ * initialisation phase.
+ *
+ * Example:
+ * ```svelte
+ *
+ *
+ *
+ * ```
+ *
+ * https://svelte-5-preview.vercel.app/docs/runes#$effect-root
+ */
+ export function root(fn: () => void | (() => void)): () => void;
}
/**
diff --git a/packages/svelte/tests/runtime-runes/samples/effect-root/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-root/_config.js
new file mode 100644
index 000000000000..b5e2a1a8086f
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/effect-root/_config.js
@@ -0,0 +1,32 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ get props() {
+ return { log: [] };
+ },
+
+ async test({ assert, target, component }) {
+ const [b1, b2, b3] = target.querySelectorAll('button');
+
+ flushSync(() => {
+ b1.click();
+ b2.click();
+ });
+
+ assert.deepEqual(component.log, [0, 1]);
+
+ flushSync(() => {
+ b3.click();
+ });
+
+ assert.deepEqual(component.log, [0, 1, 'cleanup 1', 'cleanup 2']);
+
+ flushSync(() => {
+ b1.click();
+ b2.click();
+ });
+
+ assert.deepEqual(component.log, [0, 1, 'cleanup 1', 'cleanup 2']);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/effect-root/main.svelte b/packages/svelte/tests/runtime-runes/samples/effect-root/main.svelte
new file mode 100644
index 000000000000..d646bea2c49e
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/effect-root/main.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md
index 7c3ee0953aef..058a480d1ac7 100644
--- a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md
+++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md
@@ -186,6 +186,27 @@ The `$effect.active` rune is an advanced feature that tells you whether or not t
This allows you to (for example) add things like subscriptions without causing memory leaks, by putting them in child effects.
+## `$effect.root`
+
+The `$effect.root` rune is an advanced feature that creates a non-tracked scope that doesn't auto-cleanup. This is useful for
+nested effects that you want to manually control. This rune also allows for creation of effects outside of the component initialisation phase.
+
+```svelte
+
+```
+
## `$props`
To declare component props, use the `$props` rune: