From be8ffeb2a4b0c36657fb5c1bdcf36971da814227 Mon Sep 17 00:00:00 2001 From: Kiwi Date: Tue, 19 May 2026 10:58:35 -0400 Subject: [PATCH 1/2] docs(contributing): fix grammar in Prioritization section (#18241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small typo in `CONTRIBUTING.md` — "the most important and active ones repositories" reads like a leftover from an edit. Removed the dangling "ones". ```diff -PRs to the most important and active ones repositories get reviewed more quickly +PRs to the most important and active repositories get reviewed more quickly ``` --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e940252892..586c6fe6ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ The maintainers meet on the final Saturday of each month. While these meetings a ### Prioritization -We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active ones repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. +We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. ## Bugs From 000c594e05d27e3e56c08cf30274689615d99092 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 19 May 2026 22:16:03 +0200 Subject: [PATCH 2/2] fix: `{#await await ...}` and async dependencies fixes (#18243) This started out as an investigation to get rid of the runtime code for `{#await ...}` that deactivated a batch prior to reading the promise function. That can result in a new batch being created if the promise invocation happens to write a source. Through that I discovered two other bugs: 1. The way we handle `{#await await ...}` was flawed around SSR/hydration: On the server it would await the expression (which it should not; `{#await await ...}` is kind of a weird special case here) and during hydration it did not produce matching nodes, leading to hydration fails. 2. When reading a dependency after an await expression which we add to the current reaction, we did not deduplicate those reads. That can lead to duplicate dependencies, which in turn can lead to bugs when `remove_reaction` later runs. Conside this: You have `deps = [count, unrelated, count]`. Now you do `remove_reactions(deps, 1)`, i.e. "remove all reactions after the first one". That means the "disconnect these from each other" logic runs for `count`, too, because it's also in the third position, but that is wrong because it is also in the first position, i.e. the connection should be kept. --------- Co-authored-by: Rich Harris --- .changeset/clean-ties-start.md | 5 +++ .changeset/fair-otters-notice.md | 5 +++ .changeset/loud-phones-return.md | 5 +++ .../3-transform/client/visitors/AwaitBlock.js | 4 +- .../client/visitors/VariableDeclaration.js | 18 +++++--- .../3-transform/server/visitors/AwaitBlock.js | 9 +++- .../src/internal/client/dom/blocks/await.js | 28 ++++++------ .../svelte/src/internal/client/runtime.js | 12 ++++-- .../samples/async-await-block-2/_config.js | 24 +++++++++++ .../samples/async-await-block-2/main.svelte | 22 ++++++++++ .../async-duplicate-dependencies/_config.js | 43 +++++++++++++++++++ .../async-duplicate-dependencies/main.svelte | 20 +++++++++ 12 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 .changeset/clean-ties-start.md create mode 100644 .changeset/fair-otters-notice.md create mode 100644 .changeset/loud-phones-return.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-await-block-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-await-block-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/main.svelte diff --git a/.changeset/clean-ties-start.md b/.changeset/clean-ties-start.md new file mode 100644 index 0000000000..825ed675a5 --- /dev/null +++ b/.changeset/clean-ties-start.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't unset batch when calling `{#await ...}` promise diff --git a/.changeset/fair-otters-notice.md b/.changeset/fair-otters-notice.md new file mode 100644 index 0000000000..eb5a181847 --- /dev/null +++ b/.changeset/fair-otters-notice.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: promise-ify `{#await await ...}` expressions on the server and correctly hydrate them on the client diff --git a/.changeset/loud-phones-return.md b/.changeset/loud-phones-return.md new file mode 100644 index 0000000000..02f2b3a2e7 --- /dev/null +++ b/.changeset/loud-phones-return.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: deduplicate dependencies that are added outside the init/update cycle diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js index 832c56818b..b8def178f2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js @@ -71,14 +71,14 @@ export function AwaitBlock(node, context) { 'await' ); - if (node.metadata.expression.has_blockers()) { + if (node.metadata.expression.has_blockers() || node.metadata.expression.has_await) { context.state.init.push( b.stmt( b.call( '$.async', context.state.node, node.metadata.expression.blockers(), - b.array([]), + b.array([]), // {#await await ...} is special insofar that the await should not be waited on b.arrow([context.state.node], b.block([stmt])) ) ) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index 16aa164d84..dffa79cd7a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -386,13 +386,17 @@ export function VariableDeclaration(node, context) { * @param {Expression} value */ function create_state_declarators(declarator, context, value) { + /** + * @param {Expression} value + * @param {string} name + */ + const mutable_source = (value, name) => { + const call = b.call('$.mutable_source', value, context.state.analysis.immutable && b.true); + return dev ? b.call('$.tag', call, b.literal(name)) : call; + }; + if (declarator.id.type === 'Identifier') { - return [ - b.declarator( - declarator.id, - b.call('$.mutable_source', value, context.state.analysis.immutable ? b.true : undefined) - ) - ]; + return [b.declarator(declarator.id, mutable_source(value, declarator.id.name))]; } const tmp = b.id(context.state.scope.generate('tmp')); @@ -414,7 +418,7 @@ function create_state_declarators(declarator, context, value) { return b.declarator( path.node, binding?.kind === 'state' - ? b.call('$.mutable_source', value, context.state.analysis.immutable ? b.true : undefined) + ? mutable_source(value, /** @type {Identifier} */ (path.node).name) : value ); }) diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js index 84c2a81612..ba05ca1357 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js @@ -9,12 +9,19 @@ import { block_close, create_child_block } from './shared/utils.js'; * @param {ComponentContext} context */ export function AwaitBlock(node, context) { + let expression = /** @type {Expression} */ (context.visit(node.expression)); + if (node.metadata.expression.has_await) { + // If this is an await expression, turn it into a IIFE so that the result is a promise. + // {#await await ...} is special insofar that the await should not be waited on. + expression = b.call(b.arrow([], expression, true)); + } + /** @type {Statement} */ let statement = b.stmt( b.call( '$.await', b.id('$$renderer'), - /** @type {Expression} */ (context.visit(node.expression)), + expression, b.thunk( node.pending ? /** @type {BlockStatement} */ (context.visit(node.pending)) : b.block([]) ), diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index d6430547b5..194a7ccc0e 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -7,7 +7,8 @@ import { hydrating, skip_nodes, set_hydrate_node, - set_hydrating + set_hydrating, + hydrate_node } from '../hydration.js'; import { queue_micro_task } from '../task.js'; import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; @@ -15,6 +16,7 @@ import { is_runes } from '../../context.js'; import { Batch, current_batch, flushSync, is_flushing_sync } from '../../reactivity/batch.js'; import { BranchManager } from './branches.js'; import { capture, unset_context } from '../../reactivity/async.js'; +import { DEV } from 'esm-env'; const PENDING = 0; const THEN = 1; @@ -42,16 +44,16 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { var value = runes ? source(v) : mutable_source(v, false, false); var error = runes ? source(v) : mutable_source(v, false, false); + if (DEV) { + value.label = '{#await ...} value'; + error.label = '{#await ...} error'; + } + var branches = new BranchManager(node); block(() => { var batch = /** @type {Batch} */ (current_batch); - - // we null out `current_batch` because otherwise `save(...)` will incorrectly restore it — - // the batch will already have been committed by the time it resolves - batch.deactivate(); var input = get_input(); - batch.activate(); var destroyed = false; @@ -79,15 +81,17 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { // We don't want to restore the previous batch here; {#await} blocks don't follow the async logic // we have elsewhere, instead pending/resolve/fail states are each their own batch so to speak. restore(false); + // ...but it might still be set here. That means a `save(...)` has restored it — but that batch will + // likely already have been committed by the time it resolves, and this resolve should be processed + // in a separate batch. We're not using batch.deactivate()/activate() above because get_input() + // could write to sources, which would then incorrectly create a new batch or could mess with + // async_derived expecting a current_batch to exist. + if (current_batch === batch) { + batch.deactivate(); + } // Make sure we have a batch, since the branch manager expects one to exist Batch.ensure(); - if (hydrating) { - // `restore()` could set `hydrating` to `true`, which we very much - // don't want — we want to restore everything _except_ this - set_hydrating(false); - } - try { fn(); } finally { diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 033afe98dc..87abbd71b6 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -560,9 +560,15 @@ export function get(signal) { } } } else { - // we're adding a dependency outside the init/update cycle - // (i.e. after an `await`) - (active_reaction.deps ??= []).push(signal); + // We're adding a dependency outside the init/update cycle (i.e. after an `await`). + // We have to deduplicate deps/reactions in this case or remove_reactions could + // disconnect deps/reactions that are actually still in use (if skip_deps says + // "disconnect all after this index" and some of the signals are also present in + // list prior to the cutoff index, i.e. that should be kept). + active_reaction.deps ??= []; + if (!includes.call(active_reaction.deps, signal)) { + active_reaction.deps.push(signal); + } var reactions = signal.reactions; diff --git a/packages/svelte/tests/runtime-runes/samples/async-await-block-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-await-block-2/_config.js new file mode 100644 index 0000000000..0a927e64f6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-await-block-2/_config.js @@ -0,0 +1,24 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [incremet, resolve] = target.querySelectorAll('button'); + + incremet.click(); + await tick(); + incremet.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 4 4 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-await-block-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-await-block-2/main.svelte new file mode 100644 index 0000000000..b619c6a949 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-await-block-2/main.svelte @@ -0,0 +1,22 @@ + + + + +{#await request(count) then result}{result}{/await} +{#await await push(count) + count then result}{result}{/await} +{#await await 1 then result}{result}{/await} diff --git a/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/_config.js b/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/_config.js new file mode 100644 index 0000000000..b7451282ac --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/_config.js @@ -0,0 +1,43 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 4 + ` + ); + + increment.click(); + await tick(); + increment.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 8 + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/main.svelte new file mode 100644 index 0000000000..58a5848e3b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/main.svelte @@ -0,0 +1,20 @@ + + + + +{await request(count)}