diff --git a/.changeset/dull-cheetahs-watch.md b/.changeset/dull-cheetahs-watch.md new file mode 100644 index 000000000000..be518a65b278 --- /dev/null +++ b/.changeset/dull-cheetahs-watch.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: support `using` keyword diff --git a/.changeset/nasty-hotels-clap.md b/.changeset/nasty-hotels-clap.md new file mode 100644 index 000000000000..a94f2e15ca9f --- /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/package.json b/packages/svelte/package.json index d2fbdb32f74c..2c076c42c713 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -164,9 +164,9 @@ "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", - "@types/estree": "^1.0.5", - "acorn": "^8.12.1", "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.15.0", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 26a09abb66b7..cdcec809bd29 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -36,7 +36,7 @@ export function parse(source, typescript, is_script) { ast = parser.parse(source, { onComment, sourceType: 'module', - ecmaVersion: 16, + ecmaVersion: 'latest', locations: true }); } finally { @@ -64,7 +64,7 @@ export function parse_expression_at(source, typescript, index) { const ast = parser.parseExpressionAt(source, index, { onComment, sourceType: 'module', - ecmaVersion: 16, + ecmaVersion: 'latest', locations: true }); diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index fded183b86c3..23fab6cba917 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 e2e006c14bec..3805c15efda2 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 @@ -350,7 +350,7 @@ export function client_component(analysis, options) { const push_args = [b.id('$$props'), b.literal(analysis.runes)]; if (dev) push_args.push(b.id(analysis.name)); - const component_block = b.block([ + let component_block = b.block([ ...store_setup, ...legacy_reactive_declarations, ...group_binding_declarations, @@ -491,6 +491,16 @@ export function client_component(analysis, options) { body = [...imports, ...state.module_level_snippets, ...body]; + if (analysis.disposable.length > 0) { + component_block = b.block([ + b.declaration( + 'var', + analysis.disposable.map((id) => b.declarator(id)) + ), + b.try(component_block.body, null, [b.stmt(b.call('$.dispose', ...analysis.disposable))]) + ]); + } + const component = b.function_declaration( b.id(analysis.name), should_inject_props ? [b.id('$$anchor'), b.id('$$props')] : [b.id('$$anchor')], 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 53f18d42e43a..a8aea14d5610 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)) + ); + + const assignments = declarations.map((declarator) => { + let init = /** @type {Expression} */ (declarator.init); + if (dev) init = b.call('$.disposable', init); + + return b.assignment('=', declarator.id, init); + }); + + return assignments.length === 1 ? assignments[0] : b.sequence(assignments); + } + 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 67cbd75ff86f..617c96f19446 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/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 736738d19f15..22d2c2c9af78 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -635,6 +635,35 @@ export function throw_error(str) { }; } +/** + * @param {ESTree.Statement[]} body + * @param {ESTree.CatchClause | null} handler + * @param {ESTree.Statement[] | null} finalizer + * @returns {ESTree.TryStatement} + */ +function try_builder(body, handler, finalizer) { + return { + type: 'TryStatement', + block: block(body), + handler, + finalizer: finalizer && block(finalizer) + }; +} + +/** + * + * @param {ESTree.Pattern | null} param + * @param {ESTree.Statement[]} body + * @returns {ESTree.CatchClause} + */ +function catch_clause(param, body) { + return { + type: 'CatchClause', + param, + body: block(body) + }; +} + export { await_builder as await, let_builder as let, @@ -648,7 +677,9 @@ export { if_builder as if, this_instance as this, null_instance as null, - debugger_builder as debugger + debugger_builder as debugger, + try_builder as try, + catch_clause as catch }; /** diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 60f9af912060..7153bbc6fa33 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 000000000000..6738d31de673 --- /dev/null +++ b/packages/svelte/src/internal/client/resource-management/index.js @@ -0,0 +1,28 @@ +import { teardown } from '../reactivity/effects.js'; + +/** + * @param {...any} disposables + */ +export function dispose(...disposables) { + teardown(() => { + for (const disposable of disposables) { + // @ts-ignore Symbol.dispose may or may not exist as far as TypeScript is concerned + 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) { + // @ts-ignore Symbol.dispose may or may not exist as far as TypeScript is concerned + if (value != null && !value[Symbol.dispose]) { + throw new TypeError('Symbol(Symbol.dispose) is not a function'); + } + + return value; +} diff --git a/packages/svelte/tests/parser-legacy/samples/each-block-destructured/output.json b/packages/svelte/tests/parser-legacy/samples/each-block-destructured/output.json index d19f5cbbfd8c..637da24aea98 100644 --- a/packages/svelte/tests/parser-legacy/samples/each-block-destructured/output.json +++ b/packages/svelte/tests/parser-legacy/samples/each-block-destructured/output.json @@ -259,7 +259,8 @@ "kind": "let" }, "specifiers": [], - "source": null + "source": null, + "attributes": [] } ], "sourceType": "module" diff --git a/packages/svelte/tests/parser-legacy/samples/script-context-module-unquoted/output.json b/packages/svelte/tests/parser-legacy/samples/script-context-module-unquoted/output.json index 03a526f04e37..64250cb302e4 100644 --- a/packages/svelte/tests/parser-legacy/samples/script-context-module-unquoted/output.json +++ b/packages/svelte/tests/parser-legacy/samples/script-context-module-unquoted/output.json @@ -169,7 +169,8 @@ "kind": "const" }, "specifiers": [], - "source": null + "source": null, + "attributes": [] } ], "sourceType": "module" 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 000000000000..4598860358c0 --- /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 000000000000..81eafa339b6f --- /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 000000000000..1afc3a5abd2d --- /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 000000000000..ed0ead960bdb --- /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 000000000000..9ee6e8b5b294 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/using-top-level/_expected/client/index.svelte.js @@ -0,0 +1,34 @@ +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); + + var x; + + try { + $.push($$props, true, Using_top_level); + + 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); + return $.pop({ ...$.legacy_api() }); + } finally { + $.dispose(x); + } +} \ 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 000000000000..6411f7431edc --- /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 000000000000..4598860358c0 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/using-top-level/index.svelte @@ -0,0 +1,12 @@ + + +

{x.message}

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfbc54df3363..fa1121c99a3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,13 +67,13 @@ importers: version: 1.5.0 '@sveltejs/acorn-typescript': specifier: ^1.0.5 - version: 1.0.5(acorn@8.14.0) + version: 1.0.5(acorn@8.15.0) '@types/estree': specifier: ^1.0.5 version: 1.0.6 acorn: - specifier: ^8.12.1 - version: 8.14.0 + specifier: ^8.15.0 + version: 8.15.0 aria-query: specifier: ^5.3.1 version: 5.3.1 @@ -908,13 +908,13 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.14.0: - resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + acorn@8.14.1: + resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} engines: {node: '>=0.4.0'} hasBin: true - acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true @@ -2990,9 +2990,9 @@ snapshots: eslint-visitor-keys: 3.4.3 espree: 9.6.1 - '@sveltejs/acorn-typescript@1.0.5(acorn@8.14.0)': + '@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)': dependencies: - acorn: 8.14.0 + acorn: 8.15.0 '@sveltejs/eslint-config@8.1.0(@stylistic/eslint-plugin-js@1.8.0(eslint@9.9.1))(eslint-config-prettier@9.1.0(eslint@9.9.1))(eslint-plugin-n@17.16.1(eslint@9.9.1)(typescript@5.5.4))(eslint-plugin-svelte@2.38.0(eslint@9.9.1)(svelte@packages+svelte))(eslint@9.9.1)(typescript-eslint@8.26.0(eslint@9.9.1)(typescript@5.5.4))(typescript@5.5.4)': dependencies: @@ -3231,18 +3231,14 @@ snapshots: loupe: 3.1.3 tinyrainbow: 1.2.0 - acorn-jsx@5.3.2(acorn@8.14.0): - dependencies: - acorn: 8.14.0 - acorn-jsx@5.3.2(acorn@8.14.1): dependencies: acorn: 8.14.1 - acorn@8.14.0: {} - acorn@8.14.1: {} + acorn@8.15.0: {} + agent-base@7.1.1: dependencies: debug: 4.4.0 @@ -3606,8 +3602,8 @@ snapshots: espree@10.1.0: dependencies: - acorn: 8.14.0 - acorn-jsx: 5.3.2(acorn@8.14.0) + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) eslint-visitor-keys: 4.2.0 espree@9.6.1: @@ -4457,7 +4453,7 @@ snapshots: terser@5.27.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21