From 7719d0312ca27b9347a04290e94a6a9097fefe94 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 22 Jan 2026 14:34:56 -0800 Subject: [PATCH] fix: perf regression with async mode (#17461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: use Set for new_deps to avoid O(n) includes check * fix: only call revive() when batch is no longer deferred * fix: avoid unnecessary async tracking when blockers are already settled * add changeset * only record promises as settled after cleanup is complete * don't flush when already flushing * skip settled blockers more aggressively * batch decrement and pending count updates to reduce flush pressure * extract new_deps changes to a separate PR * remove changeset that applies to the other branch * mark promises as settled as they settle — at the end is too late to do any good * remove unnecessary microtask * extract batch changes to separate PR * this too * avoid assigning to parameter * wrong promise added to settled_promises due to reassignment * WIP * WIP * fix * unused * oops * bad import --------- Co-authored-by: Rich Harris --- .changeset/ten-horses-cross.md | 5 + .../3-transform/client/visitors/ConstTag.js | 4 +- .../src/internal/client/dom/blocks/async.js | 9 +- .../client/dom/elements/attributes.js | 4 +- .../src/internal/client/reactivity/async.js | 105 +++++++++++------- .../src/internal/client/reactivity/effects.js | 6 +- .../src/internal/client/reactivity/types.d.ts | 5 + .../svelte/src/internal/client/validate.js | 3 +- 8 files changed, 91 insertions(+), 50 deletions(-) create mode 100644 .changeset/ten-horses-cross.md diff --git a/.changeset/ten-horses-cross.md b/.changeset/ten-horses-cross.md new file mode 100644 index 0000000000..5502c5887b --- /dev/null +++ b/.changeset/ten-horses-cross.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid async overhead for already settled promises diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js index ffeffe6ea4..d2bd3c10dc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js @@ -115,9 +115,9 @@ function add_const_declaration(state, id, expression, metadata, bindings) { const body = after.length === 0 ? assignment : b.block([b.stmt(assignment), ...after]); if (blockers.length === 1) { - run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0]))); + run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise'))); } else if (blockers.length > 0) { - run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers)))); + run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers)))); } run.thunks.push(b.thunk(body, has_await)); diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 3bc1eecba7..c2ae07fd17 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,4 +1,4 @@ -/** @import { TemplateNode, Value } from '#client' */ +/** @import { Blocker, TemplateNode, Value } from '#client' */ import { flatten } from '../../reactivity/async.js'; import { Batch, current_batch } from '../../reactivity/batch.js'; import { get } from '../../runtime.js'; @@ -14,11 +14,16 @@ import { get_boundary } from './boundary.js'; /** * @param {TemplateNode} node - * @param {Array>} blockers + * @param {Blocker[]} blockers * @param {Array<() => Promise>} expressions * @param {(anchor: TemplateNode, ...deriveds: Value[]) => void} fn */ export function async(node, blockers = [], expressions = [], fn) { + if (expressions.length === 0 && blockers.every((b) => b.settled)) { + fn(node); + return; + } + var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); var blocking = boundary.is_rendered(); diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index c1ab97dc61..6f1fa7391e 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -1,4 +1,4 @@ -/** @import { Effect } from '#client' */ +/** @import { Blocker, Effect } from '#client' */ import { DEV } from 'esm-env'; import { hydrating, set_hydrating } from '../hydration.js'; import { get_descriptors, get_prototype_of } from '../../../shared/utils.js'; @@ -483,7 +483,7 @@ function set_attributes( * @param {(...expressions: any) => Record} fn * @param {Array<() => any>} sync * @param {Array<() => Promise>} async - * @param {Array>} blockers + * @param {Blocker[]} blockers * @param {string} [css_hash] * @param {boolean} [should_remove_defaults] * @param {boolean} [skip_warning] diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 334bf8f145..b6eba3bf8a 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -1,4 +1,4 @@ -/** @import { Effect, TemplateNode, Value } from '#client' */ +/** @import { Blocker, Effect, Value } from '#client' */ import { DESTROYED, STALE_REACTION } from '#client/constants'; import { DEV } from 'esm-env'; import { @@ -27,7 +27,7 @@ import { import { aborted } from './effects.js'; /** - * @param {Array>} blockers + * @param {Blocker[]} blockers * @param {Array<() => any>} sync * @param {Array<() => Promise>} async * @param {(values: Value[]) => any} fn @@ -35,7 +35,10 @@ import { aborted } from './effects.js'; export function flatten(blockers, sync, async, fn) { const d = is_runes() ? derived : derived_safe_equal; - if (async.length === 0 && blockers.length === 0) { + // Filter out already-settled blockers - no need to wait for them + var pending = blockers.filter((b) => !b.settled); + + if (async.length === 0 && pending.length === 0) { fn(sync.map(d)); return; } @@ -44,47 +47,52 @@ export function flatten(blockers, sync, async, fn) { var parent = /** @type {Effect} */ (active_effect); var restore = capture(); + var blocker_promise = + pending.length === 1 + ? pending[0].promise + : pending.length > 1 + ? Promise.all(pending.map((b) => b.promise)) + : null; + + /** @param {Value[]} values */ + function finish(values) { + restore(); - function run() { - Promise.all(async.map((expression) => async_derived(expression))) - .then((result) => { - restore(); + try { + fn(values); + } catch (error) { + if ((parent.f & DESTROYED) === 0) { + invoke_error_boundary(error, parent); + } + } - try { - fn([...sync.map(d), ...result]); - } catch (error) { - // ignore errors in blocks that have already been destroyed - if ((parent.f & DESTROYED) === 0) { - invoke_error_boundary(error, parent); - } - } + batch?.deactivate(); + unset_context(); + } - batch?.deactivate(); - unset_context(); - }) - .catch((error) => { - invoke_error_boundary(error, parent); - }); + // Fast path: blockers but no async expressions + if (async.length === 0) { + /** @type {Promise} */ (blocker_promise).then(() => finish(sync.map(d))); + return; } - if (blockers.length > 0) { - Promise.all(blockers).then(() => { - restore(); + // Full path: has async expressions + function run() { + restore(); + Promise.all(async.map((expression) => async_derived(expression))) + .then((result) => finish([...sync.map(d), ...result])) + .catch((error) => invoke_error_boundary(error, parent)); + } - try { - return run(); - } finally { - batch?.deactivate(); - unset_context(); - } - }); + if (blocker_promise) { + blocker_promise.then(run); } else { run(); } } /** - * @param {Array>} blockers + * @param {Blocker[]} blockers * @param {(values: Value[]) => any} fn */ export function run_after_blockers(blockers, fn) { @@ -239,7 +247,13 @@ export function run(thunks) { var promise = Promise.resolve(thunks[0]()).catch(handle_error); - var promises = [promise]; + /** @type {Blocker} */ + var blocker = { promise, settled: false }; + var blockers = [blocker]; + + promise.finally(() => { + blocker.settled = true; + }); for (const fn of thunks.slice(1)) { promise = promise @@ -255,13 +269,17 @@ export function run(thunks) { restore(); return fn(); }) - .catch(handle_error) - .finally(() => { - unset_context(); - current_batch?.deactivate(); - }); + .catch(handle_error); + + const blocker = { promise, settled: false }; + blockers.push(blocker); - promises.push(promise); + promise.finally(() => { + blocker.settled = true; + + unset_context(); + current_batch?.deactivate(); + }); } promise @@ -273,5 +291,12 @@ export function run(thunks) { batch.decrement(blocking); }); - return promises; + return blockers; +} + +/** + * @param {Blocker[]} blockers + */ +export function wait(blockers) { + return Promise.all(blockers.map((b) => b.promise)); } diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 4cba446afc..5b8dee07b9 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -1,4 +1,4 @@ -/** @import { ComponentContext, ComponentContextLegacy, Derived, Effect, TemplateNode, TransitionManager } from '#client' */ +/** @import { Blocker, ComponentContext, ComponentContextLegacy, Derived, Effect, TemplateNode, TransitionManager } from '#client' */ import { is_dirty, active_effect, @@ -361,7 +361,7 @@ export function render_effect(fn, flags = 0) { * @param {(...expressions: any) => void | (() => void)} fn * @param {Array<() => any>} sync * @param {Array<() => Promise>} async - * @param {Array>} blockers + * @param {Blocker[]} blockers */ export function template_effect(fn, sync = [], async = [], blockers = []) { flatten(blockers, sync, async, (values) => { @@ -374,7 +374,7 @@ export function template_effect(fn, sync = [], async = [], blockers = []) { * @param {(...expressions: any) => void | (() => void)} fn * @param {Array<() => any>} sync * @param {Array<() => Promise>} async - * @param {Array>} blockers + * @param {Blocker[]} blockers */ export function deferred_template_effect(fn, sync = [], async = [], blockers = []) { var batch = /** @type {Batch} */ (current_batch); diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 908576922f..8477917991 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -103,3 +103,8 @@ export interface Effect extends Reaction { export type Source = Value; export type MaybeSource = T | Source; + +export interface Blocker { + promise: Promise; + settled: boolean; +} diff --git a/packages/svelte/src/internal/client/validate.js b/packages/svelte/src/internal/client/validate.js index 0daecdb480..48a44db304 100644 --- a/packages/svelte/src/internal/client/validate.js +++ b/packages/svelte/src/internal/client/validate.js @@ -1,3 +1,4 @@ +/** @import { Blocker } from '#client' */ import { dev_current_component_function } from './context.js'; import { is_array } from '../shared/utils.js'; import * as e from './errors.js'; @@ -41,7 +42,7 @@ export function validate_each_keys(collection, key_fn) { /** * @param {string} binding - * @param {Array>} blockers + * @param {Blocker[]} blockers * @param {() => Record} get_object * @param {() => string} get_property * @param {number} line