From a60995abefc52a88493580e8b7f390efeb84456c Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 22 Aug 2025 19:28:43 +0200 Subject: [PATCH 01/25] fix: allow async `{@const}` in more places (#16643) Implemented by reusing the `async_body` function inside `Fragment.js`. Also removes the ability to reference a `{@const ...}` of an implicit child inside a boundary pending/failed snippet: - existing duplication of consts can have unintended side effects, e.g. async consts would unexpectedly called multiple times - what if a const is the reason for the failure of a boundary, but is then referenced in the failed snippet? - what if an async const is referenced in a pending snippet? deadlock - inconsistent with how it behaves for components where this already does not work Implemented via the experimental flag so the behavior change only applies there as this is a breaking change strictly speaking. Also added a compiler error for this. closes #16462 --- .changeset/light-camels-push.md | 5 ++ .../98-reference/.generated/compile-errors.md | 45 +++++++++++++ .../messages/compile-errors/template.md | 43 ++++++++++++ packages/svelte/src/compiler/errors.js | 10 +++ .../phases/2-analyze/visitors/Identifier.js | 33 ++++++++++ .../3-transform/client/visitors/Fragment.js | 11 +--- .../client/visitors/SvelteBoundary.js | 66 ++++++++++++------- packages/svelte/src/compiler/phases/nodes.js | 9 +++ packages/svelte/src/compiler/phases/scope.js | 3 +- .../_config.js | 10 +++ .../main.svelte | 32 +++++++++ .../_config.js | 10 +++ .../main.svelte | 27 ++++++++ packages/svelte/tests/compiler-errors/test.ts | 4 +- .../samples/async-const/_config.js | 2 +- .../samples/async-const/main.svelte | 10 +++ .../FlakyComponent.svelte | 2 +- .../_config.js | 18 +++++ .../main.svelte | 14 ++++ .../samples/const-tag-boundary/_config.js | 2 +- .../samples/const-tag-boundary/main.svelte | 14 ++-- .../input.svelte | 6 +- 22 files changed, 328 insertions(+), 48 deletions(-) create mode 100644 .changeset/light-camels-push.md create mode 100644 packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/main.svelte create mode 100644 packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/main.svelte rename packages/svelte/tests/runtime-runes/samples/{const-tag-boundary => const-tag-boundary-deprecated-usage}/FlakyComponent.svelte (74%) create mode 100644 packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/const-tag-boundary-deprecated-usage/main.svelte 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 +
From 11a2d8e9371b2a1b456f05af7cd4adc0bf212e62 Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:33:48 -0700 Subject: [PATCH 02/25] fix: only emit `for_await_track_reactivity_loss` in async mode (#16644) Helps with #16610 (but does not fix it yet) --- .changeset/tender-masks-bow.md | 5 +++++ .../phases/3-transform/client/visitors/ForOfStatement.js | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/tender-masks-bow.md diff --git a/.changeset/tender-masks-bow.md b/.changeset/tender-masks-bow.md new file mode 100644 index 0000000000..224db5fc37 --- /dev/null +++ b/.changeset/tender-masks-bow.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: only emit `for_await_track_reactivity_loss` in async mode diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ForOfStatement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ForOfStatement.js index a5d2751812..8ae67f49d1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ForOfStatement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ForOfStatement.js @@ -8,7 +8,12 @@ import { dev, is_ignored } from '../../../../state.js'; * @param {ComponentContext} context */ export function ForOfStatement(node, context) { - if (node.await && dev && !is_ignored(node, 'await_reactivity_loss')) { + if ( + node.await && + dev && + !is_ignored(node, 'await_reactivity_loss') && + context.state.options.experimental.async + ) { const left = /** @type {VariableDeclaration | Pattern} */ (context.visit(node.left)); const argument = /** @type {Expression} */ (context.visit(node.right)); const body = /** @type {Statement} */ (context.visit(node.body)); From acd9eaf2ec8d964fa5770fdbcd76e41809197848 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 22 Aug 2025 22:37:57 +0200 Subject: [PATCH 03/25] fix: ensure correct order of template effect values (#16655) Compiler does sync then async but `memoizer.apply` did it the other way around --- .changeset/healthy-crabs-marry.md | 5 +++++ .../3-transform/client/visitors/shared/utils.js | 2 +- .../async-template-async-sync-mixed/_config.js | 9 +++++++++ .../async-template-async-sync-mixed/main.svelte | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .changeset/healthy-crabs-marry.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-template-async-sync-mixed/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-template-async-sync-mixed/main.svelte diff --git a/.changeset/healthy-crabs-marry.md b/.changeset/healthy-crabs-marry.md new file mode 100644 index 0000000000..eab29dae4a --- /dev/null +++ b/.changeset/healthy-crabs-marry.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure correct order of template effect values diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index 014547cf2d..ba140a153e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -34,7 +34,7 @@ export class Memoizer { } apply() { - return [...this.#async, ...this.#sync].map((memo, i) => { + return [...this.#sync, ...this.#async].map((memo, i) => { memo.id.name = `$${i}`; return memo.id; }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-async-sync-mixed/_config.js b/packages/svelte/tests/runtime-runes/samples/async-template-async-sync-mixed/_config.js new file mode 100644 index 0000000000..709b88578f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-async-sync-mixed/_config.js @@ -0,0 +1,9 @@ +import { tick } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, '

foo bar

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-async-sync-mixed/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-template-async-sync-mixed/main.svelte new file mode 100644 index 0000000000..2e0ae46f1f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-async-sync-mixed/main.svelte @@ -0,0 +1,17 @@ + + + +

{foo()} {await bar()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
From d3cb1482fe45a2b80b189856d8ab8a1f2c446bc6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 23 Aug 2025 16:29:10 -0400 Subject: [PATCH 04/25] perf: better effect pruning (#16625) * tweak * prune effects where possible * tweak * simplify * simplify * changeset * reset parent if necessary --- .changeset/tasty-lizards-care.md | 5 ++ .../src/internal/client/reactivity/effects.js | 57 +++++++++++-------- 2 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 .changeset/tasty-lizards-care.md diff --git a/.changeset/tasty-lizards-care.md b/.changeset/tasty-lizards-care.md new file mode 100644 index 0000000000..b6aff07ffb --- /dev/null +++ b/.changeset/tasty-lizards-care.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: prune effects without dependencies diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index df3dd75808..2c9e4db911 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -133,29 +133,40 @@ function create_effect(type, fn, sync, push = true) { schedule_effect(effect); } - // if an effect has no dependencies, no DOM and no teardown function, - // don't bother adding it to the effect tree - var inert = - sync && - effect.deps === null && - effect.first === null && - effect.nodes_start === null && - effect.teardown === null && - (effect.f & EFFECT_PRESERVED) === 0; - - if (!inert && push) { - if (parent !== null) { - push_effect(effect, parent); - } + if (push) { + /** @type {Effect | null} */ + var e = effect; - // if we're in a derived, add the effect there too + // if an effect has already ran and doesn't need to be kept in the tree + // (because it won't re-run, has no DOM, and has no teardown etc) + // then we skip it and go to its child (if any) if ( - active_reaction !== null && - (active_reaction.f & DERIVED) !== 0 && - (type & ROOT_EFFECT) === 0 + sync && + e.deps === null && + e.teardown === null && + e.nodes_start === null && + e.first === e.last && // either `null`, or a singular child + (e.f & EFFECT_PRESERVED) === 0 ) { - var derived = /** @type {Derived} */ (active_reaction); - (derived.effects ??= []).push(effect); + e = e.first; + } + + if (e !== null) { + e.parent = parent; + + if (parent !== null) { + push_effect(e, parent); + } + + // if we're in a derived, add the effect there too + if ( + active_reaction !== null && + (active_reaction.f & DERIVED) !== 0 && + (type & ROOT_EFFECT) === 0 + ) { + var derived = /** @type {Derived} */ (active_reaction); + (derived.effects ??= []).push(e); + } } } @@ -242,7 +253,7 @@ export function inspect_effect(fn) { */ export function effect_root(fn) { Batch.ensure(); - const effect = create_effect(ROOT_EFFECT, fn, true); + const effect = create_effect(ROOT_EFFECT | EFFECT_PRESERVED, fn, true); return () => { destroy_effect(effect); @@ -256,7 +267,7 @@ export function effect_root(fn) { */ export function component_root(fn) { Batch.ensure(); - const effect = create_effect(ROOT_EFFECT, fn, true); + const effect = create_effect(ROOT_EFFECT | EFFECT_PRESERVED, fn, true); return (options = {}) => { return new Promise((fulfil) => { @@ -375,7 +386,7 @@ export function block(fn, flags = 0) { * @param {boolean} [push] */ export function branch(fn, push = true) { - return create_effect(BRANCH_EFFECT, fn, true, push); + return create_effect(BRANCH_EFFECT | EFFECT_PRESERVED, fn, true, push); } /** From e883cd086bd5f93b086220c7f2e2304bcb958eb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 23 Aug 2025 14:24:51 -0700 Subject: [PATCH 05/25] Version Packages (#16642) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/healthy-crabs-marry.md | 5 ----- .changeset/light-camels-push.md | 5 ----- .changeset/silent-suns-whisper.md | 5 ----- .changeset/tasty-lizards-care.md | 5 ----- .changeset/tender-masks-bow.md | 5 ----- packages/svelte/CHANGELOG.md | 14 ++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 8 files changed, 16 insertions(+), 27 deletions(-) delete mode 100644 .changeset/healthy-crabs-marry.md delete mode 100644 .changeset/light-camels-push.md delete mode 100644 .changeset/silent-suns-whisper.md delete mode 100644 .changeset/tasty-lizards-care.md delete mode 100644 .changeset/tender-masks-bow.md diff --git a/.changeset/healthy-crabs-marry.md b/.changeset/healthy-crabs-marry.md deleted file mode 100644 index eab29dae4a..0000000000 --- a/.changeset/healthy-crabs-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure correct order of template effect values diff --git a/.changeset/light-camels-push.md b/.changeset/light-camels-push.md deleted file mode 100644 index cac7f5a51e..0000000000 --- a/.changeset/light-camels-push.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: allow async `{@const}` in more places diff --git a/.changeset/silent-suns-whisper.md b/.changeset/silent-suns-whisper.md deleted file mode 100644 index 7ee7d74abc..0000000000 --- a/.changeset/silent-suns-whisper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: properly catch top level await errors diff --git a/.changeset/tasty-lizards-care.md b/.changeset/tasty-lizards-care.md deleted file mode 100644 index b6aff07ffb..0000000000 --- a/.changeset/tasty-lizards-care.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -perf: prune effects without dependencies diff --git a/.changeset/tender-masks-bow.md b/.changeset/tender-masks-bow.md deleted file mode 100644 index 224db5fc37..0000000000 --- a/.changeset/tender-masks-bow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: only emit `for_await_track_reactivity_loss` in async mode diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index cd6a4a916c..fb6b20c489 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,19 @@ # svelte +## 5.38.3 + +### Patch Changes + +- fix: ensure correct order of template effect values ([#16655](https://github.com/sveltejs/svelte/pull/16655)) + +- fix: allow async `{@const}` in more places ([#16643](https://github.com/sveltejs/svelte/pull/16643)) + +- fix: properly catch top level await errors ([#16619](https://github.com/sveltejs/svelte/pull/16619)) + +- perf: prune effects without dependencies ([#16625](https://github.com/sveltejs/svelte/pull/16625)) + +- fix: only emit `for_await_track_reactivity_loss` in async mode ([#16644](https://github.com/sveltejs/svelte/pull/16644)) + ## 5.38.2 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index fc7db9598d..b7effe35bd 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.38.2", + "version": "5.38.3", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 2aa62504a5..815b25bf1f 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.38.2'; +export const VERSION = '5.38.3'; export const PUBLIC_VERSION = '5'; From 57fed6a267b719ff6ac49f9204a48e754f07fb3b Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Mon, 25 Aug 2025 03:21:38 -0700 Subject: [PATCH 06/25] fix: place instance-level snippets inside async body (#16666) * fix: place instance-level snippets inside async body * lint --- .changeset/cool-garlics-fail.md | 5 +++++ .../phases/3-transform/client/transform-client.js | 9 ++++++--- .../samples/async-reference-in-snippet/_config.js | 9 +++++++++ .../samples/async-reference-in-snippet/app.svelte | 9 +++++++++ .../samples/async-reference-in-snippet/main.svelte | 8 ++++++++ 5 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .changeset/cool-garlics-fail.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/app.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/main.svelte diff --git a/.changeset/cool-garlics-fail.md b/.changeset/cool-garlics-fail.md new file mode 100644 index 0000000000..cabff1840d --- /dev/null +++ b/.changeset/cool-garlics-fail.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: place instance-level snippets inside async body 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 940d6a9e00..bdd7eb3f17 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,12 +362,12 @@ export function client_component(analysis, options) { store_init, ...store_setup, ...legacy_reactive_declarations, - ...group_binding_declarations, - ...state.instance_level_snippets + ...group_binding_declarations ]); if (analysis.instance.has_await) { const body = b.block([ + ...state.instance_level_snippets, .../** @type {ESTree.Statement[]} */ (instance.body), b.if(b.call('$.aborted'), b.return()), .../** @type {ESTree.Statement[]} */ (template.body) @@ -375,7 +375,10 @@ export function client_component(analysis, options) { component_block.body.push(b.stmt(b.call(`$.async_body`, b.arrow([], body, true)))); } else { - component_block.body.push(.../** @type {ESTree.Statement[]} */ (instance.body)); + component_block.body.push( + ...state.instance_level_snippets, + .../** @type {ESTree.Statement[]} */ (instance.body) + ); if (!analysis.runes && analysis.needs_context) { component_block.body.push(b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined))); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/_config.js new file mode 100644 index 0000000000..c6903c3eed --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/_config.js @@ -0,0 +1,9 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, 'value'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/app.svelte b/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/app.svelte new file mode 100644 index 0000000000..27b29cfe50 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/app.svelte @@ -0,0 +1,9 @@ + + +{#snippet valueSnippet()} + {value} +{/snippet} + +{@render valueSnippet()} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/main.svelte new file mode 100644 index 0000000000..c251a5645b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reference-in-snippet/main.svelte @@ -0,0 +1,8 @@ + + + {#snippet pending()} + {/snippet} + + \ No newline at end of file From 71057368289e146e825327751a0a47a6f854eed7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 25 Aug 2025 14:29:39 -0400 Subject: [PATCH 07/25] fix: restore batch along with effect context (#16668) Fixes https://github.com/sveltejs/svelte/issues/16596 Fixes https://github.com/sveltejs/kit/issues/14124 --- .changeset/silent-pigs-relax.md | 5 +++ .../src/internal/client/reactivity/async.js | 6 ++- .../src/internal/client/reactivity/batch.js | 4 +- .../samples/async-nested-top-level/Bar.svelte | 7 ++++ .../samples/async-nested-top-level/Foo.svelte | 10 +++++ .../samples/async-nested-top-level/_config.js | 42 +++++++++++++++++++ .../async-nested-top-level/main.svelte | 31 ++++++++++++++ .../samples/async-redirect/_config.js | 2 + .../samples/async-redirect/main.svelte | 4 ++ .../async-top-level-deriveds/Foo.svelte | 8 ++++ .../async-top-level-deriveds/_config.js | 41 ++++++++++++++++++ .../async-top-level-deriveds/main.svelte | 31 ++++++++++++++ 12 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 .changeset/silent-pigs-relax.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Bar.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Foo.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-nested-top-level/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-nested-top-level/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/Foo.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/main.svelte diff --git a/.changeset/silent-pigs-relax.md b/.changeset/silent-pigs-relax.md new file mode 100644 index 0000000000..5acf185ffe --- /dev/null +++ b/.changeset/silent-pigs-relax.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: restore batch along with effect context diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 1ea1bbe561..65d004137f 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -73,11 +73,13 @@ function capture() { var previous_effect = active_effect; var previous_reaction = active_reaction; var previous_component_context = component_context; + var previous_batch = current_batch; return function restore() { set_active_effect(previous_effect); set_active_reaction(previous_reaction); set_component_context(previous_component_context); + previous_batch?.activate(); if (DEV) { set_from_async_derived(null); @@ -176,8 +178,8 @@ export function unset_context() { * @param {() => Promise} fn */ export async function async_body(fn) { - const unsuspend = suspend(); - const active = /** @type {Effect} */ (active_effect); + var unsuspend = suspend(); + var active = /** @type {Effect} */ (active_effect); try { await fn(); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 60fa03c56c..2c60fc8313 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -76,8 +76,8 @@ let queued_root_effects = []; let last_scheduled_effect = null; let is_flushing = false; - let is_flushing_sync = false; + export class Batch { /** * The current values of any sources that are updated in this batch @@ -678,6 +678,8 @@ export function suspend() { if (!pending) { batch.activate(); batch.decrement(); + } else { + batch.deactivate(); } unset_context(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Bar.svelte b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Bar.svelte new file mode 100644 index 0000000000..f1ac9ab760 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Bar.svelte @@ -0,0 +1,7 @@ + + +

bar: {bar}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Foo.svelte b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Foo.svelte new file mode 100644 index 0000000000..e2029a3033 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Foo.svelte @@ -0,0 +1,10 @@ + + +

foo: {foo}

+ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/_config.js b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/_config.js new file mode 100644 index 0000000000..ca7965bf79 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/_config.js @@ -0,0 +1,42 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [show, resolve] = target.querySelectorAll('button'); + + show.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

pending...

+ ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

pending...

+ ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

foo: foo

+

bar: bar

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/main.svelte new file mode 100644 index 0000000000..bd0efaa4f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + + + {#if show} + + {/if} + + {#if $effect.pending()} +

pending...

+ {/if} + + {#snippet pending()} +

initializing...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-redirect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-redirect/_config.js index ebbe642860..fe92977c21 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-redirect/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-redirect/_config.js @@ -29,6 +29,7 @@ export default test({

c

+

b or c

` ); @@ -46,6 +47,7 @@ export default test({

b

+

b or c

` ); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-redirect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-redirect/main.svelte index bf5fdf9ed3..aead1b00e5 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-redirect/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-redirect/main.svelte @@ -33,6 +33,10 @@

c

{/if} + {#if route === 'b' || route === 'c'} +

b or c

+ {/if} + {#snippet pending()}

pending...

{/snippet} diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/Foo.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/Foo.svelte new file mode 100644 index 0000000000..e8a7c84137 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/Foo.svelte @@ -0,0 +1,8 @@ + + +

{foo} {bar}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/_config.js b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/_config.js new file mode 100644 index 0000000000..2c7ffd3952 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/_config.js @@ -0,0 +1,41 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [show, resolve] = target.querySelectorAll('button'); + + show.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

pending...

+ ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

pending...

+ ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

foo bar

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/main.svelte new file mode 100644 index 0000000000..bd0efaa4f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + + + {#if show} + + {/if} + + {#if $effect.pending()} +

pending...

+ {/if} + + {#snippet pending()} +

initializing...

+ {/snippet} +
From 6534aa08e32b3c2d09c2ffb06e4ae7a18fdffa30 Mon Sep 17 00:00:00 2001 From: Edoardo Cavazza Date: Mon, 25 Aug 2025 21:06:53 +0200 Subject: [PATCH 08/25] fix: Add check for builtin custom elements in `set_custom_element_data` (#16592) Fixes #16591 This PR introduces a check for builtin custom elements (is attribute) inside the set_custom_element_data helper in order to correctly set properties that have a setter. --- .changeset/fuzzy-shrimps-dream.md | 5 +++++ .../client/dom/elements/attributes.js | 9 +++++---- .../custom-element-attributes/_config.js | 3 +++ .../custom-element-attributes/main.svelte | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 .changeset/fuzzy-shrimps-dream.md diff --git a/.changeset/fuzzy-shrimps-dream.md b/.changeset/fuzzy-shrimps-dream.md new file mode 100644 index 0000000000..0ddab531ac --- /dev/null +++ b/.changeset/fuzzy-shrimps-dream.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: Add check for builtin custom elements in `set_custom_element_data` diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index 2fa5d4541c..a5b7140f25 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -238,10 +238,10 @@ export function set_custom_element_data(node, prop, value) { // Don't compute setters for custom elements while they aren't registered yet, // because during their upgrade/instantiation they might add more setters. // Instead, fall back to a simple "an object, then set as property" heuristic. - (setters_cache.has(node.nodeName) || + (setters_cache.has(node.getAttribute('is') || node.nodeName) || // customElements may not be available in browser extension contexts !customElements || - customElements.get(node.tagName.toLowerCase()) + customElements.get(node.getAttribute('is') || node.tagName.toLowerCase()) ? get_setters(node).includes(prop) : value && typeof value === 'object') ) { @@ -546,9 +546,10 @@ var setters_cache = new Map(); /** @param {Element} element */ function get_setters(element) { - var setters = setters_cache.get(element.nodeName); + var cache_key = element.getAttribute('is') || element.nodeName; + var setters = setters_cache.get(cache_key); if (setters) return setters; - setters_cache.set(element.nodeName, (setters = [])); + setters_cache.set(cache_key, (setters = [])); var descriptors; var proto = element; // In the case of custom elements there might be setters on the instance diff --git a/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/_config.js b/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/_config.js index 7f406d8f0d..3d8917c147 100644 --- a/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/_config.js @@ -20,5 +20,8 @@ export default test({ const [value1, value2] = target.querySelectorAll('value-element'); assert.equal(value1.shadowRoot?.innerHTML, 'test'); assert.equal(value2.shadowRoot?.innerHTML, 'test'); + + const value_builtin = target.querySelector('div'); + assert.equal(value_builtin?.shadowRoot?.innerHTML, 'test'); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/main.svelte b/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/main.svelte index 82774f160d..badb8f96c7 100644 --- a/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/main.svelte @@ -15,6 +15,24 @@ } }); } + if(!customElements.get('value-builtin')) { + customElements.define('value-builtin', class extends HTMLDivElement { + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + set value(v) { + if (this.__value !== v) { + this.__value = v; + this.shadowRoot.innerHTML = `${v}`; + } + } + }, { + extends: 'div' + }); + } @@ -22,3 +40,4 @@ +
From 0d48916e020cb99f5573925a47b4231d0fb6ac5a Mon Sep 17 00:00:00 2001 From: hariharan <36292275+fabhari@users.noreply.github.com> Date: Mon, 25 Aug 2025 22:14:17 +0100 Subject: [PATCH 09/25] fix: cursor jumps in input two way binding (#16649) * fix : remove cursor manipulation for input bindings Old Fix: Restore input binding selection position (#14649) Current Fix: Remove unnecessary cursor manipulation as the presence of runes no longer requires special handling. * fix : add change set to my previous commit * Revert "fix : add change set to my previous commit" This reverts commit 6ca8ef3f97941bb8d8e0675b36d0c14af452364d. * fix: revert previous changeset added new to fix lint errors * chore : resolve lint error to fix pipeline issue * Revert "fix: revert previous changeset added new to fix lint errors" This reverts commit 91094949a616898729b85a95b5e1a8880b450170. * fix: input binding to handle code in a synchronous manner Introduced Promise.resolve to ensure that the 'set' operation completes before the 'get' operation Minimizing update delays. * Fix: resolve cursor jumps and change sets * better fix * test * changeset * simplify * failing test * gah we can't fix the input in an effect, need to do it here, but after a tick so that changes have been flushed through each blocks * add explanatory comment * fix test * this seems to work? --------- Co-authored-by: Hariharan Srinivasan Co-authored-by: Rich Harris --- .changeset/rare-cups-fold.md | 5 ++++ .changeset/tasty-chicken-care.md | 5 ++++ .../client/dom/elements/bindings/input.js | 16 +++++----- .../samples/binding-update-in-each/_config.js | 28 +++++++++++++++++ .../binding-update-in-each/main.svelte | 8 +++++ .../binding-update-while-focused-3/_config.js | 30 +++++++++++++++++++ .../main.svelte | 6 ++++ 7 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 .changeset/rare-cups-fold.md create mode 100644 .changeset/tasty-chicken-care.md create mode 100644 packages/svelte/tests/runtime-runes/samples/binding-update-in-each/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/binding-update-in-each/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-3/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-3/main.svelte diff --git a/.changeset/rare-cups-fold.md b/.changeset/rare-cups-fold.md new file mode 100644 index 0000000000..8cd5995651 --- /dev/null +++ b/.changeset/rare-cups-fold.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: Introduced Promise.resolve to ensure that the 'set' operation completes before the 'get' operation Minimizing update delays. diff --git a/.changeset/tasty-chicken-care.md b/.changeset/tasty-chicken-care.md new file mode 100644 index 0000000000..ea579efe4c --- /dev/null +++ b/.changeset/tasty-chicken-care.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: wait until changes propagate before updating input selection state diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index 67e6ff1dd2..815acde7c5 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -6,7 +6,7 @@ import * as e from '../../../errors.js'; import { is } from '../../../proxy.js'; import { queue_micro_task } from '../../task.js'; import { hydrating } from '../../hydration.js'; -import { untrack } from '../../../runtime.js'; +import { tick, untrack } from '../../../runtime.js'; import { is_runes } from '../../../context.js'; import { current_batch, previous_batch } from '../../../reactivity/batch.js'; @@ -17,11 +17,9 @@ import { current_batch, previous_batch } from '../../../reactivity/batch.js'; * @returns {void} */ export function bind_value(input, get, set = get) { - var runes = is_runes(); - var batches = new WeakSet(); - listen_to_event_and_reset_event(input, 'input', (is_reset) => { + listen_to_event_and_reset_event(input, 'input', async (is_reset) => { if (DEV && input.type === 'checkbox') { // TODO should this happen in prod too? e.bind_invalid_checkbox_value(); @@ -36,9 +34,13 @@ export function bind_value(input, get, set = get) { batches.add(current_batch); } - // In runes mode, respect any validation in accessors (doesn't apply in legacy mode, - // because we use mutable state which ensures the render effect always runs) - if (runes && value !== (value = get())) { + // Because `{#each ...}` blocks work by updating sources inside the flush, + // we need to wait a tick before checking to see if we should forcibly + // update the input and reset the selection state + await tick(); + + // Respect any validation in accessors + if (value !== (value = get())) { var start = input.selectionStart; var end = input.selectionEnd; diff --git a/packages/svelte/tests/runtime-runes/samples/binding-update-in-each/_config.js b/packages/svelte/tests/runtime-runes/samples/binding-update-in-each/_config.js new file mode 100644 index 0000000000..b6371ce11c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-in-each/_config.js @@ -0,0 +1,28 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client', 'hydrate'], + + html: `

a`, + + async test({ assert, target }) { + const [input] = target.querySelectorAll('input'); + + input.focus(); + input.value = 'ab'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + flushSync(); + + assert.htmlEqual(target.innerHTML, `

ab`); + assert.equal(input.value, 'ab'); + + input.focus(); + input.value = 'abc'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + flushSync(); + + assert.htmlEqual(target.innerHTML, `

abc`); + assert.equal(input.value, 'abc'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/binding-update-in-each/main.svelte b/packages/svelte/tests/runtime-runes/samples/binding-update-in-each/main.svelte new file mode 100644 index 0000000000..7925195ee1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-in-each/main.svelte @@ -0,0 +1,8 @@ + + +{#each array as obj} + obj.value, (value) => array = [{ value }]} /> +

{obj.value}

+{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-3/_config.js b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-3/_config.js new file mode 100644 index 0000000000..0909dee7a8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-3/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client', 'hydrate'], + + async test({ assert, target }) { + const [input] = target.querySelectorAll('input'); + + input.focus(); + input.value = 'Ab'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + + await tick(); + await tick(); + + assert.equal(input.value, 'AB'); + assert.htmlEqual(target.innerHTML, `

AB

`); + + input.focus(); + input.value = 'ABc'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + + await tick(); + await tick(); + + assert.equal(input.value, 'ABC'); + assert.htmlEqual(target.innerHTML, `

ABC

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-3/main.svelte new file mode 100644 index 0000000000..b61bfe4e67 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-3/main.svelte @@ -0,0 +1,6 @@ + + + text, (v) => text = v.toUpperCase()} /> +

{text}

From 0348b4a7050b360fef41a5dc93b80c906e7842bd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 25 Aug 2025 17:50:51 -0400 Subject: [PATCH 10/25] delete unused changeset --- .changeset/rare-cups-fold.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/rare-cups-fold.md diff --git a/.changeset/rare-cups-fold.md b/.changeset/rare-cups-fold.md deleted file mode 100644 index 8cd5995651..0000000000 --- a/.changeset/rare-cups-fold.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: Introduced Promise.resolve to ensure that the 'set' operation completes before the 'get' operation Minimizing update delays. From 967431c1a708f6115e6753c8196cf01edf46a0fa Mon Sep 17 00:00:00 2001 From: Gurpreet Atwal Date: Mon, 25 Aug 2025 15:05:58 -0700 Subject: [PATCH 11/25] chore(elements.d.ts): add "Accept-CH" as valid value for `http-equiv` (#16671) * chore(elements.d.ts): add "Accept-CH" as valid value for `http-equiv` It seems like this is a valid value and it has been working for me, however I did find conflicting information on whether it is supported or not. Supporting Evidence: - https://github.com/WICG/client-hints-infrastructure?tab=readme-ov-file#opt-in-mechanism - https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Client_hints#overview - https://developer.chrome.com/docs/privacy-security/user-agent-client-hints#introducing_the_new_user-agent_client_hints - https://github.com/httpwg/http-extensions/issues/189 Conflicting Evidence: - https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv * changeset --------- Co-authored-by: Rich Harris --- .changeset/thick-books-fail.md | 5 +++++ packages/svelte/elements.d.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/thick-books-fail.md diff --git a/.changeset/thick-books-fail.md b/.changeset/thick-books-fail.md new file mode 100644 index 0000000000..42dba3c86e --- /dev/null +++ b/.changeset/thick-books-fail.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: add "Accept-CH" as valid value for `http-equiv` diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index f63a31a96b..e604505d96 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -1268,6 +1268,7 @@ export interface HTMLMetaAttributes extends HTMLAttributes { charset?: string | undefined | null; content?: string | undefined | null; 'http-equiv'?: + | 'accept-ch' | 'content-security-policy' | 'content-type' | 'default-style' From 677af5723c1a80ccfdbab81cb7ef57edeecf0201 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 26 Aug 2025 00:08:07 +0200 Subject: [PATCH 12/25] fix: ensure async deriveds always get dependencies from thennable (#16672) When an async derived already has a previous promise that is still pending, we were not accessing the `then` property of the new promise. If that property access causes signals to be read, that meant that those dependencies were lost and as such the derived wouldn't rerun anymore when it should. The fix is to make sure to always access the thennable. --- .changeset/orange-chefs-float.md | 5 +++ .../internal/client/reactivity/deriveds.js | 3 ++ .../async-derived-reverse-order/_config.js | 41 +++++++++++++++++++ .../async-derived-reverse-order/main.svelte | 35 ++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 .changeset/orange-chefs-float.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-reverse-order/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-reverse-order/main.svelte diff --git a/.changeset/orange-chefs-float.md b/.changeset/orange-chefs-float.md new file mode 100644 index 0000000000..fc5db3c680 --- /dev/null +++ b/.changeset/orange-chefs-float.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure async deriveds always get dependencies from thennable diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 7f730e365e..31dc267960 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -120,6 +120,9 @@ export function async_derived(fn, location) { try { var p = fn(); + // Make sure to always access the then property to read any signals + // it might access, so that we track them as dependencies. + if (prev) Promise.resolve(p).catch(() => {}); // avoid unhandled rejection } catch (error) { p = Promise.reject(error); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-reverse-order/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-reverse-order/_config.js new file mode 100644 index 0000000000..bd0dd753c2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-reverse-order/_config.js @@ -0,0 +1,41 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + + pop.click(); + await tick(); + + pop.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

1

+ ` + ); + + increment.click(); + await tick(); + + pop.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-reverse-order/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-reverse-order/main.svelte new file mode 100644 index 0000000000..b9f6c26c2a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-reverse-order/main.svelte @@ -0,0 +1,35 @@ + + + + + + + +

{await push()}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
From 5912754fd6ec48ec00af121c66e959a2c9210de9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:09:16 -0400 Subject: [PATCH 13/25] Version Packages (#16667) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/cool-garlics-fail.md | 5 ----- .changeset/fuzzy-shrimps-dream.md | 5 ----- .changeset/silent-pigs-relax.md | 5 ----- .changeset/tasty-chicken-care.md | 5 ----- .changeset/thick-books-fail.md | 5 ----- packages/svelte/CHANGELOG.md | 14 ++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 8 files changed, 16 insertions(+), 27 deletions(-) delete mode 100644 .changeset/cool-garlics-fail.md delete mode 100644 .changeset/fuzzy-shrimps-dream.md delete mode 100644 .changeset/silent-pigs-relax.md delete mode 100644 .changeset/tasty-chicken-care.md delete mode 100644 .changeset/thick-books-fail.md diff --git a/.changeset/cool-garlics-fail.md b/.changeset/cool-garlics-fail.md deleted file mode 100644 index cabff1840d..0000000000 --- a/.changeset/cool-garlics-fail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: place instance-level snippets inside async body diff --git a/.changeset/fuzzy-shrimps-dream.md b/.changeset/fuzzy-shrimps-dream.md deleted file mode 100644 index 0ddab531ac..0000000000 --- a/.changeset/fuzzy-shrimps-dream.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -fix: Add check for builtin custom elements in `set_custom_element_data` diff --git a/.changeset/silent-pigs-relax.md b/.changeset/silent-pigs-relax.md deleted file mode 100644 index 5acf185ffe..0000000000 --- a/.changeset/silent-pigs-relax.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: restore batch along with effect context diff --git a/.changeset/tasty-chicken-care.md b/.changeset/tasty-chicken-care.md deleted file mode 100644 index ea579efe4c..0000000000 --- a/.changeset/tasty-chicken-care.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: wait until changes propagate before updating input selection state diff --git a/.changeset/thick-books-fail.md b/.changeset/thick-books-fail.md deleted file mode 100644 index 42dba3c86e..0000000000 --- a/.changeset/thick-books-fail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -fix: add "Accept-CH" as valid value for `http-equiv` diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index fb6b20c489..9b6c652333 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,19 @@ # svelte +## 5.38.4 + +### Patch Changes + +- fix: place instance-level snippets inside async body ([#16666](https://github.com/sveltejs/svelte/pull/16666)) + +- fix: Add check for builtin custom elements in `set_custom_element_data` ([#16592](https://github.com/sveltejs/svelte/pull/16592)) + +- fix: restore batch along with effect context ([#16668](https://github.com/sveltejs/svelte/pull/16668)) + +- fix: wait until changes propagate before updating input selection state ([#16649](https://github.com/sveltejs/svelte/pull/16649)) + +- fix: add "Accept-CH" as valid value for `http-equiv` ([#16671](https://github.com/sveltejs/svelte/pull/16671)) + ## 5.38.3 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index b7effe35bd..cb606a336f 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.38.3", + "version": "5.38.4", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 815b25bf1f..1e5a968bf5 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.38.3'; +export const VERSION = '5.38.4'; export const PUBLIC_VERSION = '5'; From 5314f48d956732a573fb69f57ed42a7ca273e704 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:06:00 +0200 Subject: [PATCH 14/25] Version Packages (#16673) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/orange-chefs-float.md | 5 ----- packages/svelte/CHANGELOG.md | 6 ++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/orange-chefs-float.md diff --git a/.changeset/orange-chefs-float.md b/.changeset/orange-chefs-float.md deleted file mode 100644 index fc5db3c680..0000000000 --- a/.changeset/orange-chefs-float.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure async deriveds always get dependencies from thennable diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 9b6c652333..2f75c135fe 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte +## 5.38.5 + +### Patch Changes + +- fix: ensure async deriveds always get dependencies from thennable ([#16672](https://github.com/sveltejs/svelte/pull/16672)) + ## 5.38.4 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index cb606a336f..7891d94b88 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.38.4", + "version": "5.38.5", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 1e5a968bf5..77cd8c1bd5 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.38.4'; +export const VERSION = '5.38.5'; export const PUBLIC_VERSION = '5'; From bde51ed7dc3554eee50edc0c29e70a05e9ee842b Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 26 Aug 2025 21:59:46 +0200 Subject: [PATCH 15/25] fix: don't fail on `flushSync` while flushing effects (#16674) * fix: don't fail on `flushSync` while flushing effects WIP fixes #16640 * cleanup, unrelated lint * revert * fix --- .changeset/honest-coins-work.md | 5 +++++ packages/svelte/elements.d.ts | 2 +- .../svelte/src/internal/client/reactivity/batch.js | 7 +++++-- .../flush-sync-inside-attachment/Child.svelte | 7 +++++++ .../samples/flush-sync-inside-attachment/_config.js | 12 ++++++++++++ .../flush-sync-inside-attachment/main.svelte | 13 +++++++++++++ 6 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 .changeset/honest-coins-work.md create mode 100644 packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/main.svelte diff --git a/.changeset/honest-coins-work.md b/.changeset/honest-coins-work.md new file mode 100644 index 0000000000..e6b68a8a67 --- /dev/null +++ b/.changeset/honest-coins-work.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't fail on `flushSync` while flushing effects diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index e604505d96..b0c2fae2de 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -1268,7 +1268,7 @@ export interface HTMLMetaAttributes extends HTMLAttributes { charset?: string | undefined | null; content?: string | undefined | null; 'http-equiv'?: - | 'accept-ch' + | 'accept-ch' | 'content-security-policy' | 'content-type' | 'default-style' diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2c60fc8313..82f1de67a9 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -187,7 +187,7 @@ export class Batch { // if there are multiple batches, we are 'time travelling' — // we need to undo the changes belonging to any batch // other than the current one - if (batches.size > 1) { + if (async_mode_flag && batches.size > 1) { current_values = new Map(); batch_deriveds = new Map(); @@ -484,6 +484,7 @@ export class Batch { */ export function flushSync(fn) { if (async_mode_flag && active_effect !== null) { + // We disallow this because it creates super-hard to reason about stack trace and because it's generally a bad idea e.flush_sync_in_effect(); } @@ -622,7 +623,9 @@ function flush_queued_effects(effects) { } } - if (eager_block_effects.length > 0) { + // If update_effect() has a flushSync() in it, we may have flushed another flush_queued_effects(), + // which already handled this logic and did set eager_block_effects to null. + if (eager_block_effects?.length > 0) { // TODO this feels incorrect! it gets the tests passing old_values.clear(); diff --git a/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/Child.svelte b/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/Child.svelte new file mode 100644 index 0000000000..44447e4f36 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/Child.svelte @@ -0,0 +1,7 @@ + + +{text} diff --git a/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/_config.js b/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/_config.js new file mode 100644 index 0000000000..ec8858b2c6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/_config.js @@ -0,0 +1,12 @@ +import { async_mode } from '../../../helpers'; +import { test } from '../../test'; + +export default test({ + // In legacy mode this succeeds and logs 'hello' + // In async mode this throws an error because flushSync is called inside an effect + async test({ assert, target, logs }) { + assert.htmlEqual(target.innerHTML, `
hello
`); + assert.deepEqual(logs, ['hello']); + }, + runtime_error: async_mode ? 'flush_sync_in_effect' : undefined +}); diff --git a/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/main.svelte b/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/main.svelte new file mode 100644 index 0000000000..bef820376b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/flush-sync-inside-attachment/main.svelte @@ -0,0 +1,13 @@ + + + + +
{ + mount(Child, { target, props: { text: 'hello' } }); + flushSync(); +}}>
From 942eaf027bc08625ed501473861a8dc618b1929e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 13:43:16 -0400 Subject: [PATCH 16/25] Version Packages (#16677) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/honest-coins-work.md | 5 ----- packages/svelte/CHANGELOG.md | 6 ++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/honest-coins-work.md diff --git a/.changeset/honest-coins-work.md b/.changeset/honest-coins-work.md deleted file mode 100644 index e6b68a8a67..0000000000 --- a/.changeset/honest-coins-work.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: don't fail on `flushSync` while flushing effects diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 2f75c135fe..de94eb1897 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte +## 5.38.6 + +### Patch Changes + +- fix: don't fail on `flushSync` while flushing effects ([#16674](https://github.com/sveltejs/svelte/pull/16674)) + ## 5.38.5 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 7891d94b88..fe42603184 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.38.5", + "version": "5.38.6", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 77cd8c1bd5..67c586790f 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.38.5'; +export const VERSION = '5.38.6'; export const PUBLIC_VERSION = '5'; From 9d1aa699b6a4ea3772a1b2bc06e6dc1170575316 Mon Sep 17 00:00:00 2001 From: Jinay Patel <78944617+Github11200@users.noreply.github.com> Date: Sat, 30 Aug 2025 05:38:50 -0700 Subject: [PATCH 17/25] Docs: Updated the custom elements documentation to include $host (#16686) * Updated the custom elements documentation to include * moved the rune reference further up * Small update to the sentence * updated the formatting * Update documentation/docs/07-misc/04-custom-elements.md --------- Co-authored-by: Rich Harris --- documentation/docs/07-misc/04-custom-elements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/07-misc/04-custom-elements.md b/documentation/docs/07-misc/04-custom-elements.md index 7e6a17b947..4e5afff7d2 100644 --- a/documentation/docs/07-misc/04-custom-elements.md +++ b/documentation/docs/07-misc/04-custom-elements.md @@ -4,7 +4,7 @@ title: Custom elements -Svelte components can also be compiled to custom elements (aka web components) using the `customElement: true` compiler option. You should specify a tag name for the component using the `` [element](svelte-options). +Svelte components can also be compiled to custom elements (aka web components) using the `customElement: true` compiler option. You should specify a tag name for the component using the `` [element](svelte-options). Within the custom element you can access the host element via the [`$host`](https://svelte.dev/docs/svelte/$host) rune. ```svelte From 8f11796838f38ecaf493946c44bae0e2b08487d0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 30 Aug 2025 09:11:25 -0400 Subject: [PATCH 18/25] remove unused import --- packages/svelte/src/internal/client/dom/blocks/boundary.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 1866931ef2..60fe2b7d3c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,10 +1,5 @@ /** @import { Effect, Source, TemplateNode, } from '#client' */ -import { - BOUNDARY_EFFECT, - EFFECT_PRESERVED, - EFFECT_RAN, - EFFECT_TRANSPARENT -} from '#client/constants'; +import { BOUNDARY_EFFECT, EFFECT_PRESERVED, EFFECT_TRANSPARENT } from '#client/constants'; import { component_context, set_component_context } from '../../context.js'; import { handle_error, invoke_error_boundary } from '../../error-handling.js'; import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js'; From be645b4d9f84cb7580683c7b2336d1023906c4da Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:52:08 -0700 Subject: [PATCH 19/25] fix: place store setup inside async body (#16687) * fix: place store setup inside async body * fix? * fix * tweak * more --- .changeset/wise-schools-report.md | 5 ++++ .../3-transform/client/transform-client.js | 27 +++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 .changeset/wise-schools-report.md diff --git a/.changeset/wise-schools-report.md b/.changeset/wise-schools-report.md new file mode 100644 index 0000000000..47ec887256 --- /dev/null +++ b/.changeset/wise-schools-report.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: place store setup inside async body 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 bdd7eb3f17..706d2b4e10 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 @@ -360,15 +360,27 @@ export function client_component(analysis, options) { let component_block = b.block([ store_init, - ...store_setup, ...legacy_reactive_declarations, ...group_binding_declarations ]); + const should_inject_context = + dev || + analysis.needs_context || + analysis.reactive_statements.size > 0 || + component_returned_object.length > 0; + if (analysis.instance.has_await) { + if (should_inject_context && component_returned_object.length > 0) { + component_block.body.push(b.var('$$exports')); + } const body = b.block([ + ...store_setup, ...state.instance_level_snippets, .../** @type {ESTree.Statement[]} */ (instance.body), + ...(should_inject_context && component_returned_object.length > 0 + ? [b.stmt(b.assignment('=', b.id('$$exports'), b.object(component_returned_object)))] + : []), b.if(b.call('$.aborted'), b.return()), .../** @type {ESTree.Statement[]} */ (template.body) ]); @@ -379,6 +391,10 @@ export function client_component(analysis, options) { ...state.instance_level_snippets, .../** @type {ESTree.Statement[]} */ (instance.body) ); + if (should_inject_context && component_returned_object.length > 0) { + component_block.body.push(b.var('$$exports', b.object(component_returned_object))); + } + component_block.body.unshift(...store_setup); if (!analysis.runes && analysis.needs_context) { component_block.body.push(b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined))); @@ -393,12 +409,6 @@ export function client_component(analysis, options) { ); } - const should_inject_context = - dev || - analysis.needs_context || - analysis.reactive_statements.size > 0 || - component_returned_object.length > 0; - let should_inject_props = should_inject_context || analysis.needs_props || @@ -445,7 +455,7 @@ export function client_component(analysis, options) { let to_push; if (component_returned_object.length > 0) { - let pop_call = b.call('$.pop', b.object(component_returned_object)); + let pop_call = b.call('$.pop', b.id('$$exports')); to_push = needs_store_cleanup ? b.var('$$pop', pop_call) : b.return(pop_call); } else { to_push = b.stmt(b.call('$.pop')); @@ -456,6 +466,7 @@ export function client_component(analysis, options) { if (needs_store_cleanup) { component_block.body.push(b.stmt(b.call('$$cleanup'))); + if (component_returned_object.length > 0) { component_block.body.push(b.return(b.id('$$pop'))); } From 2344b4052eb80ff745b849da759b0d28d2dcaff6 Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Sun, 31 Aug 2025 02:30:46 -0700 Subject: [PATCH 20/25] fix: replace `undefined` with `void(0)` in CallExpressions (#16693) * fix: replace `undefined` with `void(0)` in CallExpressions * tweak comment --- .changeset/ninety-olives-report.md | 5 +++++ packages/svelte/src/compiler/utils/builders.js | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/ninety-olives-report.md diff --git a/.changeset/ninety-olives-report.md b/.changeset/ninety-olives-report.md new file mode 100644 index 0000000000..3e66a41d02 --- /dev/null +++ b/.changeset/ninety-olives-report.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: replace `undefined` with `void(0)` in CallExpressions diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 56a5f31ffe..03a946ff9c 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -100,7 +100,7 @@ export function call(callee, ...args) { if (typeof callee === 'string') callee = id(callee); args = args.slice(); - // replacing missing arguments with `undefined`, unless they're at the end in which case remove them + // replacing missing arguments with `void(0)`, unless they're at the end in which case remove them let i = args.length; let popping = true; while (i--) { @@ -108,7 +108,7 @@ export function call(callee, ...args) { if (popping) { args.pop(); } else { - args[i] = id('undefined'); + args[i] = void0; } } else { popping = false; From f51c04afcecd8496bc9ecd5d4d5bb4715a7fd0c0 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 4 Sep 2025 04:06:17 +0200 Subject: [PATCH 21/25] fix: ensure batch exists when resetting a failed boundary (#16698) fixes #16681 --- .changeset/ninety-ravens-join.md | 5 +++++ .../internal/client/dom/blocks/boundary.js | 3 +++ .../samples/async-boundary-reset/Test.svelte | 15 +++++++++++++++ .../samples/async-boundary-reset/_config.js | 19 +++++++++++++++++++ .../samples/async-boundary-reset/main.svelte | 12 ++++++++++++ 5 files changed, 54 insertions(+) create mode 100644 .changeset/ninety-ravens-join.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-reset/Test.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-reset/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-reset/main.svelte diff --git a/.changeset/ninety-ravens-join.md b/.changeset/ninety-ravens-join.md new file mode 100644 index 0000000000..f7bc6f8def --- /dev/null +++ b/.changeset/ninety-ravens-join.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure batch exists when resetting a failed boundary diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 60fe2b7d3c..12ca547608 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -297,6 +297,9 @@ export class Boundary { e.svelte_boundary_reset_onerror(); } + // If the failure happened while flushing effects, current_batch can be null + Batch.ensure(); + this.#pending_count = 0; if (this.#failed_effect !== null) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/Test.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/Test.svelte new file mode 100644 index 0000000000..2232a094cb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/Test.svelte @@ -0,0 +1,15 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/_config.js b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/_config.js new file mode 100644 index 0000000000..19b273175b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + let [btn] = target.querySelectorAll('button'); + btn.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ''); + + [btn] = target.querySelectorAll('button'); + btn.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ''); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/main.svelte new file mode 100644 index 0000000000..42242f26d6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/main.svelte @@ -0,0 +1,12 @@ + + + + + + {#snippet pending()}pending{/snippet} + {#snippet failed(_, reset)} + + {/snippet} + From 08b3b66865db472e5e6d2262386c1dc030c10a78 Mon Sep 17 00:00:00 2001 From: Kyle Gach Date: Thu, 4 Sep 2025 13:36:34 -0600 Subject: [PATCH 22/25] docs: add testing with storybook (#16701) * docs: add testing with storybook * docs: remove video * docs: address feedback - consistent tabs vs. spaces in snippet - add more Storybook details * Revise Storybook testing documentation condense, remove referer query parameters * Update documentation/docs/07-misc/02-testing.md Co-authored-by: Jeppe Reinhold * Tweaks - Prose updates - More helpful link * make it its own section --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Jeppe Reinhold --- documentation/docs/07-misc/02-testing.md | 46 ++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/documentation/docs/07-misc/02-testing.md b/documentation/docs/07-misc/02-testing.md index bcec4db0a3..23e2e023d3 100644 --- a/documentation/docs/07-misc/02-testing.md +++ b/documentation/docs/07-misc/02-testing.md @@ -160,9 +160,9 @@ export function logger(getValue) { ### Component testing -It is possible to test your components in isolation using Vitest. +It is possible to test your components in isolation, which allows you to render them in a browser (real or simulated), simulate behavior, and make assertions, without spinning up your whole app. -> [!NOTE] Before writing component tests, think about whether you actually need to test the component, or if it's more about the logic _inside_ the component. If so, consider extracting out that logic to test it in isolation, without the overhead of a component +> [!NOTE] Before writing component tests, think about whether you actually need to test the component, or if it's more about the logic _inside_ the component. If so, consider extracting out that logic to test it in isolation, without the overhead of a component. To get started, install jsdom (a library that shims DOM APIs): @@ -246,6 +246,48 @@ test('Component', async () => { When writing component tests that involve two-way bindings, context or snippet props, it's best to create a wrapper component for your specific test and interact with that. `@testing-library/svelte` contains some [examples](https://testing-library.com/docs/svelte-testing-library/example). +### Component testing with Storybook + +[Storybook](https://storybook.js.org) is a tool for developing and documenting UI components, and it can also be used to test your components. They're run with Vitest's browser mode, which renders your components in a real browser for the most realistic testing environment. + +To get started, first install Storybook ([using Svelte's CLI](/docs/cli/storybook)) in your project via `npx sv add storybook` and choose the recommended configuration that includes testing features. If you're already using Storybook, and for more information on Storybook's testing capabilities, follow the [Storybook testing docs](https://storybook.js.org/docs/writing-tests?renderer=svelte) to get started. + +You can create stories for component variations and test interactions with the [play function](https://storybook.js.org/docs/writing-tests/interaction-testing?renderer=svelte#writing-interaction-tests), which allows you to simulate behavior and make assertions using the Testing Library and Vitest APIs. Here's an example of two stories that can be tested, one that renders an empty LoginForm component and one that simulates a user filling out the form: + +```svelte +/// file: LoginForm.stories.svelte + + + + + { + // Simulate a user filling out the form + await userEvent.type(canvas.getByTestId('email'), 'email@provider.com'); + await userEvent.type(canvas.getByTestId('password'), 'a-random-password'); + await userEvent.click(canvas.getByRole('button')); + + // Run assertions + await expect(args.onSubmit).toHaveBeenCalledTimes(1); + await expect(canvas.getByText('You’re in!')).toBeInTheDocument(); + }} +/> +``` + ## E2E tests using Playwright E2E (short for 'end to end') tests allow you to test your full application through the eyes of the user. This section uses [Playwright](https://playwright.dev/) as an example, but you can also use other solutions like [Cypress](https://www.cypress.io/) or [NightwatchJS](https://nightwatchjs.org/). From d92fa432d1032f4e1746af654eb5a7b7f536fbe9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 09:08:32 +0200 Subject: [PATCH 23/25] Version Packages (#16690) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/ninety-olives-report.md | 5 ----- .changeset/ninety-ravens-join.md | 5 ----- .changeset/wise-schools-report.md | 5 ----- packages/svelte/CHANGELOG.md | 10 ++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 6 files changed, 12 insertions(+), 17 deletions(-) delete mode 100644 .changeset/ninety-olives-report.md delete mode 100644 .changeset/ninety-ravens-join.md delete mode 100644 .changeset/wise-schools-report.md diff --git a/.changeset/ninety-olives-report.md b/.changeset/ninety-olives-report.md deleted file mode 100644 index 3e66a41d02..0000000000 --- a/.changeset/ninety-olives-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: replace `undefined` with `void(0)` in CallExpressions diff --git a/.changeset/ninety-ravens-join.md b/.changeset/ninety-ravens-join.md deleted file mode 100644 index f7bc6f8def..0000000000 --- a/.changeset/ninety-ravens-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure batch exists when resetting a failed boundary diff --git a/.changeset/wise-schools-report.md b/.changeset/wise-schools-report.md deleted file mode 100644 index 47ec887256..0000000000 --- a/.changeset/wise-schools-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: place store setup inside async body diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index de94eb1897..535214781c 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,15 @@ # svelte +## 5.38.7 + +### Patch Changes + +- fix: replace `undefined` with `void(0)` in CallExpressions ([#16693](https://github.com/sveltejs/svelte/pull/16693)) + +- fix: ensure batch exists when resetting a failed boundary ([#16698](https://github.com/sveltejs/svelte/pull/16698)) + +- fix: place store setup inside async body ([#16687](https://github.com/sveltejs/svelte/pull/16687)) + ## 5.38.6 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index fe42603184..c6bc40ae2c 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.38.6", + "version": "5.38.7", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 67c586790f..d499c06797 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.38.6'; +export const VERSION = '5.38.7'; export const PUBLIC_VERSION = '5'; From 02b737224e925e22989438e98c5ea63fd07626b4 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Mon, 8 Sep 2025 17:12:34 -0600 Subject: [PATCH 24/25] fix: `$effect.pending` sends updates to incorrect boundary --- .../src/internal/client/dom/blocks/async.js | 4 +- .../internal/client/dom/blocks/boundary.js | 93 ++++++++++++++---- .../src/internal/client/reactivity/async.js | 4 +- .../src/internal/client/reactivity/batch.js | 5 +- .../async-effect-pending-nested/_config.js | 95 +++++++++++++++++++ .../async-effect-pending-nested/main.svelte | 34 +++++++ 6 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 82f107ab29..5ec50a5988 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,7 +1,7 @@ /** @import { TemplateNode, Value } from '#client' */ import { flatten } from '../../reactivity/async.js'; import { get } from '../../runtime.js'; -import { get_pending_boundary } from './boundary.js'; +import { get_boundary } from './boundary.js'; /** * @param {TemplateNode} node @@ -9,7 +9,7 @@ import { get_pending_boundary } from './boundary.js'; * @param {(anchor: TemplateNode, ...deriveds: Value[]) => void} fn */ export function async(node, expressions, fn) { - var boundary = get_pending_boundary(); + var boundary = get_boundary(); boundary.update_pending_count(1); diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 12ca547608..b36ac0deed 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -49,11 +49,34 @@ export function boundary(node, props, children) { } export class Boundary { - pending = false; - /** @type {Boundary | null} */ parent; + /** + * Whether this boundary is inside a boundary (including this one) that's showing a pending snippet. + * @type {boolean} + */ + get pending() { + if (this.has_pending_snippet()) { + return this.#pending; + } + + // intentionally not throwing here, as the answer to "am I in a pending snippet" is false when + // there's no pending snippet at all + return this.parent?.pending ?? false; + } + + set pending(value) { + if (this.has_pending_snippet()) { + this.#pending = value; + } else if (this.parent) { + this.parent.pending = value; + } else if (value) { + e.await_outside_boundary(); + } + // if we're trying to set it to `false` and yeeting that into the void, it's fine + } + /** @type {TemplateNode} */ #anchor; @@ -81,7 +104,28 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; + /** + * Whether this boundary is inside a boundary (including this one) that's showing a pending snippet. + * Derived from {@link props.pending} and {@link cascading_pending_count}. + */ + #pending = false; + + /** + * The number of pending async deriveds/expressions within this boundary, not counting any parent or child boundaries. + * This controls `$effect.pending` for this boundary. + * + * Don't ever set this directly; use {@link update_pending_count} instead. + */ #pending_count = 0; + + /** + * Like {@link #pending_count}, but treats boundaries with no `pending` snippet as porous. + * This controls the pending snippet for this boundary. + * + * Don't ever set this directly; use {@link update_pending_count} instead. + */ + #cascading_pending_count = 0; + #is_creating_fallback = false; /** @@ -149,7 +193,7 @@ export class Boundary { return branch(() => this.#children(this.#anchor)); }); - if (this.#pending_count > 0) { + if (this.#cascading_pending_count > 0) { this.#show_pending_snippet(); } else { pause_effect(/** @type {Effect} */ (this.#pending_effect), () => { @@ -166,7 +210,7 @@ export class Boundary { this.error(error); } - if (this.#pending_count > 0) { + if (this.#cascading_pending_count > 0) { this.#show_pending_snippet(); } else { this.pending = false; @@ -220,11 +264,11 @@ export class Boundary { } } - /** @param {1 | -1} d */ - #update_pending_count(d) { - this.#pending_count += d; + /** @param {number} d */ + #update_cascading_pending_count(d) { + this.#cascading_pending_count = Math.max(this.#cascading_pending_count + d, 0); - if (this.#pending_count === 0) { + if (this.#cascading_pending_count === 0) { this.pending = false; if (this.#pending_effect) { @@ -240,12 +284,21 @@ export class Boundary { } } - /** @param {1 | -1} d */ - update_pending_count(d) { + /** + * @param {number} d + * @param {boolean} safe + */ + update_pending_count(d, safe = false, first = true) { + if (first) { + this.#pending_count = Math.max(this.#pending_count + d, 0); + } + if (this.has_pending_snippet()) { - this.#update_pending_count(d); + this.#update_cascading_pending_count(d); } else if (this.parent) { - this.parent.#update_pending_count(d); + this.parent.update_pending_count(d, safe, false); + } else if (this.parent === null && !safe) { + e.await_outside_boundary(); } effect_pending_updates.add(this.#effect_pending_update); @@ -300,7 +353,9 @@ export class Boundary { // If the failure happened while flushing effects, current_batch can be null Batch.ensure(); - this.#pending_count = 0; + // this ensures we modify the cascading_pending_count of the correct parent + // by the number we're decreasing this boundary by + this.update_pending_count(-this.#pending_count, true); if (this.#failed_effect !== null) { pause_effect(this.#failed_effect, () => { @@ -308,14 +363,16 @@ export class Boundary { }); } - this.pending = true; + // we intentionally do not try to find the nearest pending boundary. If this boundary has one, we'll render it on reset + // but it would be really weird to show the parent's boundary on a child reset. + this.pending = this.has_pending_snippet(); this.#main_effect = this.#run(() => { this.#is_creating_fallback = false; return branch(() => this.#children(this.#anchor)); }); - if (this.#pending_count > 0) { + if (this.#cascading_pending_count > 0) { this.#show_pending_snippet(); } else { this.pending = false; @@ -384,13 +441,9 @@ function move_effect(effect, fragment) { } } -export function get_pending_boundary() { +export function get_boundary() { var boundary = /** @type {Effect} */ (active_effect).b; - while (boundary !== null && !boundary.has_pending_snippet()) { - boundary = boundary.parent; - } - if (boundary === null) { e.await_outside_boundary(); } diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 65d004137f..f91f2e094a 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -3,7 +3,6 @@ import { DESTROYED } from '#client/constants'; import { DEV } from 'esm-env'; import { component_context, is_runes, set_component_context } from '../context.js'; -import { get_pending_boundary } from '../dom/blocks/boundary.js'; import { invoke_error_boundary } from '../error-handling.js'; import { active_effect, @@ -39,7 +38,6 @@ export function flatten(sync, async, fn) { var parent = /** @type {Effect} */ (active_effect); var restore = capture(); - var boundary = get_pending_boundary(); Promise.all(async.map((expression) => async_derived(expression))) .then((result) => { @@ -60,7 +58,7 @@ export function flatten(sync, async, fn) { unset_context(); }) .catch((error) => { - boundary.error(error); + invoke_error_boundary(error, parent); }); } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 82f1de67a9..fd5c34ab26 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -10,12 +10,11 @@ import { INERT, RENDER_EFFECT, ROOT_EFFECT, - USER_EFFECT, MAYBE_DIRTY } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; -import { get_pending_boundary } from '../dom/blocks/boundary.js'; +import { get_boundary } from '../dom/blocks/boundary.js'; import { active_effect, is_dirty, @@ -668,7 +667,7 @@ export function schedule_effect(signal) { } export function suspend() { - var boundary = get_pending_boundary(); + var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); var pending = boundary.pending; diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js new file mode 100644 index 0000000000..9fe354bac0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js @@ -0,0 +1,95 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, shift] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + +

loading...

+ ` + ); + + shift.click(); + shift.click(); + shift.click(); + + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

0

+

0

+

0

+

inner pending: 0

+

outer pending: 0

+ ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

0

+

0

+

0

+

inner pending: 3

+

outer pending: 0

+ ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

0

+

0

+

0

+

inner pending: 2

+

outer pending: 0

+ ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

0

+

0

+

0

+

inner pending: 1

+

outer pending: 0

+ ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

1

+

1

+

1

+

inner pending: 0

+

outer pending: 0

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte new file mode 100644 index 0000000000..eeafbdc3c4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte @@ -0,0 +1,34 @@ + + + + + + + +

{await push(value)}

+

{await push(value)}

+

{await push(value)}

+

inner pending: {$effect.pending()}

+
+

outer pending: {$effect.pending()}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
+ + From bd6a414377613d1dcaba716b39c653d7cc97cf54 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Mon, 8 Sep 2025 17:19:39 -0600 Subject: [PATCH 25/25] changeset --- .changeset/dirty-cycles-smash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dirty-cycles-smash.md diff --git a/.changeset/dirty-cycles-smash.md b/.changeset/dirty-cycles-smash.md new file mode 100644 index 0000000000..1b031cf0af --- /dev/null +++ b/.changeset/dirty-cycles-smash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: send `$effect.pending` count to the correct boundary