diff --git a/.changeset/light-camels-push.md b/.changeset/light-camels-push.md new file mode 100644 index 0000000000..cac7f5a51e --- /dev/null +++ b/.changeset/light-camels-push.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: allow async `{@const}` in more places diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 957a9f67c7..b9c44163c9 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -196,6 +196,51 @@ Cyclical dependency detected: %cycle% `{@const}` must be the immediate child of `{#snippet}`, `{#if}`, `{:else if}`, `{:else}`, `{#each}`, `{:then}`, `{:catch}`, ``, `` ``` +### const_tag_invalid_reference + +``` +The `{@const %name% = ...}` declaration is not available in this snippet +``` + +The following is an error: + +```svelte + + {@const foo = 'bar'} + + {#snippet failed()} + {foo} + {/snippet} + +``` + +Here, `foo` is not available inside `failed`. The top level code inside `` becomes part of the implicit `children` snippet, in other words the above code is equivalent to this: + +```svelte + + {#snippet children()} + {@const foo = 'bar'} + {/snippet} + + {#snippet failed()} + {foo} + {/snippet} + +``` + +The same applies to components: + +```svelte + + {@const foo = 'bar'} + + {#snippet someProp()} + + {foo} + {/snippet} + +``` + ### constant_assignment ``` diff --git a/packages/svelte/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md index 0569f63ad3..dc26a02767 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -124,6 +124,49 @@ > `{@const}` must be the immediate child of `{#snippet}`, `{#if}`, `{:else if}`, `{:else}`, `{#each}`, `{:then}`, `{:catch}`, ``, `` +## const_tag_invalid_reference + +> The `{@const %name% = ...}` declaration is not available in this snippet + +The following is an error: + +```svelte + + {@const foo = 'bar'} + + {#snippet failed()} + {foo} + {/snippet} + +``` + +Here, `foo` is not available inside `failed`. The top level code inside `` becomes part of the implicit `children` snippet, in other words the above code is equivalent to this: + +```svelte + + {#snippet children()} + {@const foo = 'bar'} + {/snippet} + + {#snippet failed()} + {foo} + {/snippet} + +``` + +The same applies to components: + +```svelte + + {@const foo = 'bar'} + + {#snippet someProp()} + + {foo} + {/snippet} + +``` + ## debug_tag_invalid_arguments > {@debug ...} arguments must be identifiers, not arbitrary expressions diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index e763a6e073..44fc641ee5 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -985,6 +985,16 @@ export function const_tag_invalid_placement(node) { e(node, 'const_tag_invalid_placement', `\`{@const}\` must be the immediate child of \`{#snippet}\`, \`{#if}\`, \`{:else if}\`, \`{:else}\`, \`{#each}\`, \`{:then}\`, \`{:catch}\`, \`\`, \`\`\nhttps://svelte.dev/e/const_tag_invalid_placement`); } +/** + * The `{@const %name% = ...}` declaration is not available in this snippet + * @param {null | number | NodeLike} node + * @param {string} name + * @returns {never} + */ +export function const_tag_invalid_reference(node, name) { + e(node, 'const_tag_invalid_reference', `The \`{@const ${name} = ...}\` declaration is not available in this snippet \nhttps://svelte.dev/e/const_tag_invalid_reference`); +} + /** * {@debug ...} arguments must be identifiers, not arbitrary expressions * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js index 4dfdfe5af1..1c98a95e63 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js @@ -7,6 +7,7 @@ import * as w from '../../../warnings.js'; import { is_rune } from '../../../../utils.js'; import { mark_subtree_dynamic } from './shared/fragment.js'; import { get_rune } from '../../scope.js'; +import { is_component_node } from '../../nodes.js'; /** * @param {Identifier} node @@ -155,5 +156,37 @@ export function Identifier(node, context) { ) { w.reactive_declaration_module_script_dependency(node); } + + if (binding.metadata?.is_template_declaration && context.state.options.experimental.async) { + let snippet_name; + + // Find out if this references a {@const ...} declaration of an implicit children snippet + // when it is itself inside a snippet block at the same level. If so, error. + for (let i = context.path.length - 1; i >= 0; i--) { + const parent = context.path[i]; + const grand_parent = context.path[i - 1]; + + if (parent.type === 'SnippetBlock') { + snippet_name = parent.expression.name; + } else if ( + snippet_name && + grand_parent && + parent.type === 'Fragment' && + (is_component_node(grand_parent) || + (grand_parent.type === 'SvelteBoundary' && + (snippet_name === 'failed' || snippet_name === 'pending'))) + ) { + if ( + is_component_node(grand_parent) + ? grand_parent.metadata.scopes.default === binding.scope + : context.state.scopes.get(parent) === binding.scope + ) { + e.const_tag_invalid_reference(node, node.name); + } else { + break; + } + } + } + } } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index c7c576101e..85d8e3caff 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -51,7 +51,6 @@ export function Fragment(node, context) { const has_await = context.state.init !== null && (node.metadata.has_await || false); const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent - const unsuspend = b.id('$$unsuspend'); /** @type {Statement[]} */ const body = []; @@ -151,10 +150,6 @@ export function Fragment(node, context) { } } - if (has_await) { - body.push(b.var(unsuspend, b.call('$.suspend'))); - } - body.push(...state.consts); if (has_await) { @@ -182,8 +177,8 @@ export function Fragment(node, context) { } if (has_await) { - body.push(b.stmt(b.call(unsuspend))); + return b.block([b.stmt(b.call('$.async_body', b.arrow([], b.block(body), true)))]); + } else { + return b.block(body); } - - return b.block(body); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js index 70df022355..49c89bc438 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js @@ -39,40 +39,60 @@ export function SvelteBoundary(node, context) { /** @type {Statement[]} */ const hoisted = []; + let has_const = false; + // const tags need to live inside the boundary, but might also be referenced in hoisted snippets. // to resolve this we cheat: we duplicate const tags inside snippets + // We'll revert this behavior in the future, it was a mistake to allow this (Component snippets also don't do this). for (const child of node.fragment.nodes) { if (child.type === 'ConstTag') { - context.visit(child, { ...context.state, consts: const_tags }); + has_const = true; + if (!context.state.options.experimental.async) { + context.visit(child, { ...context.state, consts: const_tags }); + } } } for (const child of node.fragment.nodes) { if (child.type === 'ConstTag') { + if (context.state.options.experimental.async) { + nodes.push(child); + } continue; } if (child.type === 'SnippetBlock') { - /** @type {Statement[]} */ - const statements = []; - - context.visit(child, { ...context.state, init: statements }); - - const snippet = /** @type {VariableDeclaration} */ (statements[0]); - - const snippet_fn = dev - ? // @ts-expect-error we know this shape is correct - snippet.declarations[0].init.arguments[1] - : snippet.declarations[0].init; - - snippet_fn.body.body.unshift( - ...const_tags.filter((node) => node.type === 'VariableDeclaration') - ); - - hoisted.push(snippet); - - if (['failed', 'pending'].includes(child.expression.name)) { - props.properties.push(b.prop('init', child.expression, child.expression)); + if ( + context.state.options.experimental.async && + has_const && + !['failed', 'pending'].includes(child.expression.name) + ) { + // we can't hoist snippets as they may reference const tags, so we just keep them in the fragment + nodes.push(child); + } else { + /** @type {Statement[]} */ + const statements = []; + + context.visit(child, { ...context.state, init: statements }); + + const snippet = /** @type {VariableDeclaration} */ (statements[0]); + + const snippet_fn = dev + ? // @ts-expect-error we know this shape is correct + snippet.declarations[0].init.arguments[1] + : snippet.declarations[0].init; + + if (!context.state.options.experimental.async) { + snippet_fn.body.body.unshift( + ...const_tags.filter((node) => node.type === 'VariableDeclaration') + ); + } + + if (['failed', 'pending'].includes(child.expression.name)) { + props.properties.push(b.prop('init', child.expression, child.expression)); + } + + hoisted.push(snippet); } continue; @@ -83,7 +103,9 @@ export function SvelteBoundary(node, context) { const block = /** @type {BlockStatement} */ (context.visit({ ...node.fragment, nodes })); - block.body.unshift(...const_tags); + if (!context.state.options.experimental.async) { + block.body.unshift(...const_tags); + } const boundary = b.stmt( b.call('$.boundary', context.state.node, props, b.arrow([b.id('$$anchor')], block)) diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 4874554ff0..f4127db359 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -23,6 +23,15 @@ export function is_element_node(node) { return element_nodes.includes(node.type); } +/** + * Returns true for all component-like nodes + * @param {AST.SvelteNode} node + * @returns {node is AST.Component | AST.SvelteComponent | AST.SvelteSelf} + */ +export function is_component_node(node) { + return ['Component', 'SvelteComponent', 'SvelteSelf'].includes(node.type); +} + /** * @param {AST.RegularElement | AST.SvelteElement} node * @returns {boolean} diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index f88f5ef8b1..76157d406f 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -122,7 +122,7 @@ export class Binding { /** * Additional metadata, varies per binding type - * @type {null | { inside_rest?: boolean }} + * @type {null | { inside_rest?: boolean; is_template_declaration?: boolean }} */ metadata = null; @@ -1121,6 +1121,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) { node.kind, declarator.init ); + binding.metadata = { is_template_declaration: true }; bindings.push(binding); } } diff --git a/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/_config.js b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/_config.js new file mode 100644 index 0000000000..7424278180 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/_config.js @@ -0,0 +1,10 @@ +import { test } from '../../test'; + +export default test({ + async: true, + error: { + code: 'const_tag_invalid_reference', + message: 'The `{@const foo = ...}` declaration is not available in this snippet ', + position: [376, 379] + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/main.svelte b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/main.svelte new file mode 100644 index 0000000000..a2533e33b0 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/main.svelte @@ -0,0 +1,32 @@ + + + + + {@const foo = 'bar'} + + {#snippet other()} + {foo} + {/snippet} + + {foo} + + + {#snippet failed()} + {foo} + {/snippet} + + + {#snippet failed()} + {@const foo = 'bar'} + {foo} + {/snippet} + + + + + {@const foo = 'bar'} + + {#snippet failed()} + {foo} + {/snippet} + diff --git a/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/_config.js b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/_config.js new file mode 100644 index 0000000000..7ff71a61f9 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/_config.js @@ -0,0 +1,10 @@ +import { test } from '../../test'; + +export default test({ + async: true, + error: { + code: 'const_tag_invalid_reference', + message: 'The `{@const foo = ...}` declaration is not available in this snippet ', + position: [298, 301] + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/main.svelte b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/main.svelte new file mode 100644 index 0000000000..c59df28ec9 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/main.svelte @@ -0,0 +1,27 @@ + + + + + {@const foo = 'bar'} + {foo} + + + {#snippet prop()} + {foo} + {/snippet} + + + {#snippet prop()} + {@const foo = 'bar'} + {foo} + {/snippet} + + + + + {@const foo = 'bar'} + + {#snippet prop()} + {foo} + {/snippet} + diff --git a/packages/svelte/tests/compiler-errors/test.ts b/packages/svelte/tests/compiler-errors/test.ts index 13b9280dde..b3a2d4af31 100644 --- a/packages/svelte/tests/compiler-errors/test.ts +++ b/packages/svelte/tests/compiler-errors/test.ts @@ -5,6 +5,7 @@ import { suite, type BaseTest } from '../suite'; import { read_file } from '../helpers.js'; interface CompilerErrorTest extends BaseTest { + async?: boolean; error: { code: string; message: string; @@ -29,7 +30,8 @@ const { test, run } = suite((config, cwd) => { try { compile(read_file(`${cwd}/main.svelte`), { - generate: 'client' + generate: 'client', + experimental: { async: config.async ?? false } }); } catch (e) { const error = e as CompileError; diff --git a/packages/svelte/tests/runtime-runes/samples/async-const/_config.js b/packages/svelte/tests/runtime-runes/samples/async-const/_config.js index 084d9c3874..8aeca875f3 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-const/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-const/_config.js @@ -7,6 +7,6 @@ export default test({ async test({ assert, target }) { await tick(); - assert.htmlEqual(target.innerHTML, `

Hello, world!

`); + assert.htmlEqual(target.innerHTML, `

Hello, world!

5 01234`); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte index 9321bd7929..7410ff6a6f 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte @@ -3,6 +3,8 @@ + {@const number = await Promise.resolve(5)} + {#snippet pending()}

Loading...

{/snippet} @@ -10,6 +12,14 @@ {#snippet greet()} {@const greeting = await `Hello, ${name}!`}

{greeting}

+ {number} + {#if number > 4} + {@const length = await number} + {#each { length }, index} + {@const i = await index} + {i} + {/each} + {/if} {/snippet} {@render greet()} diff --git a/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/FlakyComponent.svelte b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/FlakyComponent.svelte similarity index 74% rename from packages/svelte/tests/runtime-runes/samples/const-tag-boundary/FlakyComponent.svelte rename to packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/FlakyComponent.svelte index 8bbec90de4..ea60542af9 100644 --- a/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/FlakyComponent.svelte +++ b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/FlakyComponent.svelte @@ -1,3 +1,3 @@ \ No newline at end of file + diff --git a/packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/_config.js b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/_config.js new file mode 100644 index 0000000000..915bda91f3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/_config.js @@ -0,0 +1,18 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip_async: true, + html: '

2

', + mode: ['client'], + test({ target, assert }) { + const btn = target.querySelector('button'); + const p = target.querySelector('p'); + + flushSync(() => { + btn?.click(); + }); + + assert.equal(p?.innerHTML, '4'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/main.svelte b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/main.svelte new file mode 100644 index 0000000000..25ea8a3ffc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/main.svelte @@ -0,0 +1,14 @@ + + + + + + {@const double = test * 2} + {#snippet failed()} +

{double}

+ {/snippet} + +
\ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/_config.js b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/_config.js index 4338969a48..e4ffb4a850 100644 --- a/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/_config.js @@ -2,7 +2,7 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ - html: '

2

', + html: '

2

', mode: ['client'], test({ target, assert }) { const btn = target.querySelector('button'); diff --git a/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/main.svelte b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/main.svelte index 25ea8a3ffc..9605e12070 100644 --- a/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/const-tag-boundary/main.svelte @@ -1,14 +1,10 @@ - + - {@const double = test * 2} - {#snippet failed()} -

{double}

- {/snippet} - -
\ No newline at end of file + {@const double = count * 2} +

{double}

+
diff --git a/packages/svelte/tests/validator/samples/const-tag-placement-svelte-boundary/input.svelte b/packages/svelte/tests/validator/samples/const-tag-placement-svelte-boundary/input.svelte index 5708cc36ca..c965a379e5 100644 --- a/packages/svelte/tests/validator/samples/const-tag-placement-svelte-boundary/input.svelte +++ b/packages/svelte/tests/validator/samples/const-tag-placement-svelte-boundary/input.svelte @@ -4,8 +4,6 @@ {@const x = a} - {#snippet failed()} - {x} - {/snippet} + {x} - \ No newline at end of file +