diff --git a/.changeset/nasty-hotels-clap.md b/.changeset/nasty-hotels-clap.md new file mode 100644 index 0000000000..a94f2e15ca --- /dev/null +++ b/.changeset/nasty-hotels-clap.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: link top-level `using` declarations in components to lifecycle diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index fded183b86..23fab6cba9 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -472,7 +472,8 @@ export function analyze_component(root, source, options) { source, undefined_exports: new Map(), snippet_renderers: new Map(), - snippets: new Set() + snippets: new Set(), + disposable: [] }; if (!runes) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index e2e006c14b..0e0dc55607 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -362,6 +362,10 @@ export function client_component(analysis, options) { .../** @type {ESTree.Statement[]} */ (template.body) ]); + if (analysis.disposable.length > 0) { + component_block.body.push(b.stmt(b.call('$.dispose', ...analysis.disposable))); + } + if (!analysis.runes) { // Bind static exports to props so that people can access them with bind:x for (const { name, alias } of analysis.exports) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index 53f18d42e4..5d066b0df8 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -16,7 +16,7 @@ import { get_value } from './shared/declarations.js'; */ export function VariableDeclaration(node, context) { /** @type {VariableDeclarator[]} */ - const declarations = []; + let declarations = []; if (context.state.analysis.runes) { for (const declarator of node.declarations) { @@ -343,8 +343,27 @@ export function VariableDeclaration(node, context) { return b.empty; } + let kind = node.kind; + + // @ts-expect-error + if (kind === 'using' && context.state.is_instance && context.path.length === 1) { + context.state.analysis.disposable.push( + ...node.declarations.map((declarator) => /** @type {Identifier} */ (declarator.id)) + ); + + if (dev) { + declarations = declarations.map((declarator) => ({ + ...declarator, + init: b.call('$.disposable', /** @type {Expression} */ (declarator.init)) + })); + } + + kind = 'const'; + } + return { ...node, + kind, declarations }; } diff --git a/packages/svelte/src/compiler/phases/types.d.ts b/packages/svelte/src/compiler/phases/types.d.ts index 67cbd75ff8..617c96f194 100644 --- a/packages/svelte/src/compiler/phases/types.d.ts +++ b/packages/svelte/src/compiler/phases/types.d.ts @@ -100,6 +100,10 @@ export interface ComponentAnalysis extends Analysis { * Every snippet that is declared locally */ snippets: Set; + /** + * An array of any `using` declarations + */ + disposable: Identifier[]; } declare module 'estree' { diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 60f9af9120..7153bbc6fa 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -118,6 +118,7 @@ export { update_pre_prop, update_prop } from './reactivity/props.js'; +export { dispose, disposable } from './resource-management/index.js'; export { invalidate_store, store_mutate, diff --git a/packages/svelte/src/internal/client/resource-management/index.js b/packages/svelte/src/internal/client/resource-management/index.js new file mode 100644 index 0000000000..b0874d5153 --- /dev/null +++ b/packages/svelte/src/internal/client/resource-management/index.js @@ -0,0 +1,26 @@ +import { teardown } from '../reactivity/effects.js'; + +/** + * @param {...any} disposables + */ +export function dispose(...disposables) { + teardown(() => { + for (const disposable of disposables) { + disposable?.[Symbol.dispose](); + } + }); +} + +/** + * In dev, check that a value used with `using` is in fact disposable. We need this + * because we're replacing `using foo = ...` with `const foo = ...` if the + * declaration is at the top level of a component + * @param {any} value + */ +export function disposable(value) { + if (value != null && !value[Symbol.dispose]) { + throw new TypeError('Symbol(Symbol.dispose) is not a function'); + } + + return value; +} diff --git a/packages/svelte/tests/runtime-runes/samples/using-top-level/Child.svelte b/packages/svelte/tests/runtime-runes/samples/using-top-level/Child.svelte new file mode 100644 index 0000000000..4598860358 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/using-top-level/Child.svelte @@ -0,0 +1,12 @@ + + +

{x.message}

diff --git a/packages/svelte/tests/runtime-runes/samples/using-top-level/_config.js b/packages/svelte/tests/runtime-runes/samples/using-top-level/_config.js new file mode 100644 index 0000000000..81eafa339b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/using-top-level/_config.js @@ -0,0 +1,18 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // TODO unskip this for applicable node versions, once supported + skip: true, + + html: `

hello

`, + + test({ assert, target, logs }) { + const [button] = target.querySelectorAll('button'); + + flushSync(() => button.click()); + assert.htmlEqual(target.innerHTML, ``); + + assert.deepEqual(logs, ['disposing hello']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/using-top-level/main.svelte b/packages/svelte/tests/runtime-runes/samples/using-top-level/main.svelte new file mode 100644 index 0000000000..1afc3a5abd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/using-top-level/main.svelte @@ -0,0 +1,13 @@ + + + + +{#if message} + +{/if} diff --git a/packages/svelte/tests/snapshot/samples/using-top-level/_config.js b/packages/svelte/tests/snapshot/samples/using-top-level/_config.js new file mode 100644 index 0000000000..ed0ead960b --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/using-top-level/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + } +}); diff --git a/packages/svelte/tests/snapshot/samples/using-top-level/expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/using-top-level/expected/client/index.svelte.js new file mode 100644 index 0000000000..4f635db261 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/using-top-level/expected/client/index.svelte.js @@ -0,0 +1,28 @@ +import 'svelte/internal/disclose-version'; + +Using_top_level[$.FILENAME] = 'packages/svelte/tests/snapshot/samples/using-top-level/index.svelte'; + +import * as $ from 'svelte/internal/client'; + +var root = $.add_locations($.from_html(`

`), Using_top_level[$.FILENAME], [[12, 0]]); + +export default function Using_top_level($$anchor, $$props) { + $.check_target(new.target); + $.push($$props, true, Using_top_level); + + const x = $.disposable({ + message: $$props.message, + [Symbol.dispose]() { + console.log(...$.log_if_contains_state('log', `disposing ${$$props.message}`)); + } + }); + + var p = root(); + var text = $.child(p, true); + + $.reset(p); + $.template_effect(() => $.set_text(text, x.message)); + $.append($$anchor, p); + $.dispose(x); + return $.pop({ ...$.legacy_api() }); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/using-top-level/expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/using-top-level/expected/server/index.svelte.js new file mode 100644 index 0000000000..6411f7431e --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/using-top-level/expected/server/index.svelte.js @@ -0,0 +1,28 @@ +Using_top_level[$.FILENAME] = 'packages/svelte/tests/snapshot/samples/using-top-level/index.svelte'; + +import * as $ from 'svelte/internal/server'; + +function Using_top_level($$payload, $$props) { + $.push(Using_top_level); + + let { message } = $$props; + + using x = { + message, + [Symbol.dispose]() { + console.log(`disposing ${message}`); + } + }; + + $$payload.out += `

`; + $.push_element($$payload, 'p', 12, 0); + $$payload.out += `${$.escape(x.message)}

`; + $.pop_element(); + $.pop(); +} + +Using_top_level.render = function () { + throw new Error('Component.render(...) is no longer valid in Svelte 5. See https://svelte.dev/docs/svelte/v5-migration-guide#Components-are-no-longer-classes for more information'); +}; + +export default Using_top_level; \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/using-top-level/index.svelte b/packages/svelte/tests/snapshot/samples/using-top-level/index.svelte new file mode 100644 index 0000000000..4598860358 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/using-top-level/index.svelte @@ -0,0 +1,12 @@ + + +

{x.message}