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: