From e3f06f9fc7ea0703bc89f94fd82ae4b7855e7a0a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 4 Mar 2026 17:15:09 -0500 Subject: [PATCH 01/17] fix: skip derived re-evaluation inside inert effect blocks (#17852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this is #17850 with changes (for whatever reason I wasn't able to push direct to the fork) — same test but simplified, and a simpler fix that doesn't undo the recent (necessary!) changes to the scheduling logic --------- Co-authored-by: Mattias Granlund --- .changeset/chatty-papers-sing.md | 5 +++ .../internal/client/reactivity/deriveds.js | 17 ++++++++-- .../if-block-const-inert-derived/_config.js | 18 +++++++++++ .../if-block-const-inert-derived/main.svelte | 32 +++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 .changeset/chatty-papers-sing.md create mode 100644 packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte diff --git a/.changeset/chatty-papers-sing.md b/.changeset/chatty-papers-sing.md new file mode 100644 index 0000000000..4d85b91bfc --- /dev/null +++ b/.changeset/chatty-papers-sing.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: skip derived re-evaluation inside inert effect blocks diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 7df7651294..2c9b9da33e 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -10,7 +10,8 @@ import { ASYNC, WAS_MARKED, DESTROYED, - CLEAN + CLEAN, + INERT } from '#client/constants'; import { active_reaction, @@ -305,10 +306,22 @@ function get_derived_parent_effect(derived) { * @returns {T} */ export function execute_derived(derived) { + var parent_effect = get_derived_parent_effect(derived); + + // don't update `{@const ...}` in an outroing block + if ( + !async_mode_flag && + !is_destroying_effect && + parent_effect !== null && + (parent_effect.f & INERT) !== 0 + ) { + return derived.v; + } + var value; var prev_active_effect = active_effect; - set_active_effect(get_derived_parent_effect(derived)); + set_active_effect(parent_effect); if (DEV) { let prev_eager_effects = eager_effects; diff --git a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js new file mode 100644 index 0000000000..b803182079 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js @@ -0,0 +1,18 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '

hello

', + + async test({ assert, target, raf, logs }) { + const [button] = target.querySelectorAll('button'); + + flushSync(() => button.click()); + assert.deepEqual(logs, ['hello']); + + // Let the transition finish and clean up + raf.tick(100); + + assert.htmlEqual(target.innerHTML, ''); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte new file mode 100644 index 0000000000..58b34d52b9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte @@ -0,0 +1,32 @@ + + + + +{#if value} + {@const result = compute(value)} + {#if result.ready} +
+

{result.data}

+
+ {/if} +{/if} From 61a443f1fafb8c7f3da7f4839dc5c787485c91ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:00:45 -0500 Subject: [PATCH 02/17] chore(deps): bump immutable from 4.3.7 to 4.3.8 (#17860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [immutable](https://github.com/immutable-js/immutable-js) from 4.3.7 to 4.3.8.
Release notes

Sourced from immutable's releases.

v4.3.8

Fix Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution') in immutable

Changelog

Sourced from immutable's changelog.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. Dates are formatted as YYYY-MM-DD.

Unreleased

5.1.5

  • Fix Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution') in immutable

5.1.4

Documentation

Internal

5.1.3

TypeScript

Documentation

There has been a huge amount of changes in the documentation, mainly migrate from an autogenerated documentation from .d.ts file, to a proper documentation in markdown. The playground has been included on nearly all method examples. We added a page about browser extensions too: https://immutable-js.com/browser-extension/

Internal

... (truncated)

Commits
Maintainer changes

This version was pushed to npm by [GitHub Actions](https://www.npmjs.com/~GitHub Actions), a new releaser for immutable since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=immutable&package-manager=npm_and_yarn&previous-version=4.3.7&new-version=4.3.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/sveltejs/svelte/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5d857ee81..8693c466c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1293,8 +1293,8 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -1604,8 +1604,8 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immutable@4.3.7: - resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} + immutable@4.3.8: + resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -3589,7 +3589,7 @@ snapshots: emoji-regex@9.2.2: {} - enhanced-resolve@5.19.0: + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 @@ -3685,7 +3685,7 @@ snapshots: eslint-plugin-n@17.24.0(eslint@10.0.0)(typescript@5.5.4): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0) - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 eslint: 10.0.0 eslint-plugin-es-x: 7.8.0(eslint@10.0.0) get-tsconfig: 4.13.6 @@ -3974,7 +3974,7 @@ snapshots: ignore@7.0.5: {} - immutable@4.3.7: + immutable@4.3.8: optional: true imurmurhash@0.1.4: {} @@ -4450,7 +4450,7 @@ snapshots: sass@1.70.0: dependencies: chokidar: 3.6.0 - immutable: 4.3.7 + immutable: 4.3.8 source-map-js: 1.2.1 optional: true From 9066b75c01f38c9a0966c9ca4835b030e74bea1d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Mar 2026 07:07:33 -0500 Subject: [PATCH 03/17] chore: tidy up (#17863) small tweaks, will self-merge --- packages/svelte/src/internal/client/dom/blocks/boundary.js | 7 +------ packages/svelte/src/internal/client/reactivity/async.js | 1 - packages/svelte/src/internal/client/reactivity/batch.js | 1 - 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 429a2eb293..052736c35e 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -225,7 +225,6 @@ export class Boundary { fragment.append(anchor); this.#main_effect = this.#run(() => { - Batch.ensure(); return branch(() => this.#children(anchor)); }); @@ -320,6 +319,7 @@ export class Boundary { set_component_context(this.#effect.ctx); try { + Batch.ensure(); return fn(); } catch (e) { handle_error(e); @@ -445,9 +445,6 @@ export class Boundary { } this.#run(() => { - // If the failure happened while flushing effects, current_batch can be null - Batch.ensure(); - this.#render(); }); }; @@ -464,8 +461,6 @@ export class Boundary { if (failed) { this.#failed_effect = this.#run(() => { - Batch.ensure(); - try { return branch(() => { // errors in `failed` snippets cause the boundary to error again diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index f2643e0c34..edd2b37371 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -43,7 +43,6 @@ export function flatten(blockers, sync, async, fn) { return; } - var batch = current_batch; var parent = /** @type {Effect} */ (active_effect); var restore = capture(); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 73e4a30fa4..a1cd08bd6a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -14,7 +14,6 @@ import { MAYBE_DIRTY, DERIVED, EAGER_EFFECT, - HEAD_EFFECT, ERROR_VALUE, MANAGED_EFFECT, REACTION_RAN From aed36051fdddf2be34242c372b1a3dfb3a9e653b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Mar 2026 10:30:26 -0500 Subject: [PATCH 04/17] chore: robustify `flatten` (#17864) Extracted from #17805. Currently we restore context in`flatten` unnecessarily in the case where we have async expressions but no blockers (the context is already correct), and we don't unset context after blockers resolve in the case where we have them. The first bit is suboptimal, but the second bit feels bug-shaped, even though I'm not currently aware of any actual bugs that have resulted from this. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [ ] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- packages/svelte/src/internal/client/reactivity/async.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index edd2b37371..093d45ec0a 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -76,14 +76,17 @@ export function flatten(blockers, sync, async, fn) { // 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)); } if (blocker_promise) { - blocker_promise.then(run); + blocker_promise.then(() => { + restore(); + run(); + unset_context(); + }); } else { run(); } From 7dc864d94160164ae9f835b51aed24f3e4c6f539 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Mar 2026 21:34:38 -0500 Subject: [PATCH 05/17] Revert "fix: skip derived re-evaluation inside inert effect blocks" (#17869) Reverts sveltejs/svelte#17852, because it isn't a real fix https://github.com/sveltejs/svelte/pull/17868 --- .changeset/chatty-papers-sing.md | 5 --- .../internal/client/reactivity/deriveds.js | 17 ++-------- .../if-block-const-inert-derived/_config.js | 18 ----------- .../if-block-const-inert-derived/main.svelte | 32 ------------------- 4 files changed, 2 insertions(+), 70 deletions(-) delete mode 100644 .changeset/chatty-papers-sing.md delete mode 100644 packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js delete mode 100644 packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte diff --git a/.changeset/chatty-papers-sing.md b/.changeset/chatty-papers-sing.md deleted file mode 100644 index 4d85b91bfc..0000000000 --- a/.changeset/chatty-papers-sing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: skip derived re-evaluation inside inert effect blocks diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 2c9b9da33e..7df7651294 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -10,8 +10,7 @@ import { ASYNC, WAS_MARKED, DESTROYED, - CLEAN, - INERT + CLEAN } from '#client/constants'; import { active_reaction, @@ -306,22 +305,10 @@ function get_derived_parent_effect(derived) { * @returns {T} */ export function execute_derived(derived) { - var parent_effect = get_derived_parent_effect(derived); - - // don't update `{@const ...}` in an outroing block - if ( - !async_mode_flag && - !is_destroying_effect && - parent_effect !== null && - (parent_effect.f & INERT) !== 0 - ) { - return derived.v; - } - var value; var prev_active_effect = active_effect; - set_active_effect(parent_effect); + set_active_effect(get_derived_parent_effect(derived)); if (DEV) { let prev_eager_effects = eager_effects; diff --git a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js deleted file mode 100644 index b803182079..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js +++ /dev/null @@ -1,18 +0,0 @@ -import { flushSync, tick } from 'svelte'; -import { test } from '../../test'; - -export default test({ - html: '

hello

', - - async test({ assert, target, raf, logs }) { - const [button] = target.querySelectorAll('button'); - - flushSync(() => button.click()); - assert.deepEqual(logs, ['hello']); - - // Let the transition finish and clean up - raf.tick(100); - - assert.htmlEqual(target.innerHTML, ''); - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte deleted file mode 100644 index 58b34d52b9..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - -{#if value} - {@const result = compute(value)} - {#if result.ready} -
-

{result.data}

-
- {/if} -{/if} From 2a1f5ada13e167ed82e44274ea45722bc640900b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Mar 2026 21:36:03 -0500 Subject: [PATCH 06/17] perf: avoid re-traversing the effect tree after `$:` assignments (#17848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If an assignment happens in a `$:` statement, any affected effects are rescheduled while the traversal is ongoing. But this is wasteful — it results in the `flush_effects` loop running another time, even though the affected effects are guaranteed to be visited _later_ in the traversal (unless the thing being updated is a store). This PR fixes it: inside a `legacy_pre_effect`, we temporarily pretend that the branch _containing_ the component with the `$:` statement is the `active_effect`, such that Svelte understands that any marked effects are about to be visited and thus don't need to be scheduled. We deal with the store case by temporarily pretending that there _is_ no `active_effect`. I will be delighted when we can rip all this legacy stuff out of the codebase. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/ninety-kings-attend.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 14 +++++++-- .../src/internal/client/reactivity/effects.js | 17 +++++++++-- .../src/internal/client/reactivity/store.js | 29 ++++++++++++++++--- .../samples/store-reschedule/Child.svelte | 9 ++++++ .../samples/store-reschedule/_config.js | 22 ++++++++++++++ .../samples/store-reschedule/main.svelte | 6 ++++ .../samples/store-reschedule/stores.js | 3 ++ 8 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 .changeset/ninety-kings-attend.md create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-reschedule/Child.svelte create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-reschedule/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-reschedule/main.svelte create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-reschedule/stores.js diff --git a/.changeset/ninety-kings-attend.md b/.changeset/ninety-kings-attend.md new file mode 100644 index 0000000000..40913dab67 --- /dev/null +++ b/.changeset/ninety-kings-attend.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: avoid re-traversing the effect tree after `$:` assignments diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a1cd08bd6a..638aba2fcd 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -22,6 +22,7 @@ import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property, includes } from '../../shared/utils.js'; import { active_effect, + active_reaction, get, increment_write_version, is_dirty, @@ -36,6 +37,7 @@ import { eager_effect, unlink_effect } from './effects.js'; import { defer_effect } from './utils.js'; import { UNINITIALIZED } from '../../../constants.js'; import { set_signal_status } from './status.js'; +import { legacy_is_updating_store } from './store.js'; /** @type {Set} */ const batches = new Set(); @@ -856,10 +858,18 @@ export function schedule_effect(signal) { // updated an internal source, or because a branch is being unskipped, // bail out or we'll cause a second flush if (collected_effects !== null && effect === active_effect) { + if (async_mode_flag) return; + // in sync mode, render effects run during traversal. in an extreme edge case + // — namely that we're setting a value inside a derived read during traversal — // they can be made dirty after they have already been visited, in which - // case we shouldn't bail out - if (async_mode_flag || (signal.f & RENDER_EFFECT) === 0) { + // case we shouldn't bail out. we also shouldn't bail out if we're + // updating a store inside a `$:`, since this might invalidate + // effects that were already visited + if ( + (active_reaction === null || (active_reaction.f & DERIVED) === 0) && + !legacy_is_updating_store + ) { return; } } diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index b3d37659ea..3118851277 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -10,7 +10,8 @@ import { set_active_reaction, set_is_destroying_effect, untrack, - untracking + untracking, + set_active_effect } from '../runtime.js'; import { DIRTY, @@ -316,7 +317,19 @@ export function legacy_pre_effect(deps, fn) { if (token.ran) return; token.ran = true; - untrack(fn); + + var effect = /** @type {Effect} */ (active_effect); + + // here, we lie: by setting `active_effect` to be the parent branch, any writes + // that happen inside `fn` will _not_ cause an unnecessary reschedule, because + // the affected effects will be children of `active_effect`. this is safe + // because these effects are known to run in the correct order + try { + set_active_effect(effect.parent); + untrack(fn); + } finally { + set_active_effect(effect); + } }); } diff --git a/packages/svelte/src/internal/client/reactivity/store.js b/packages/svelte/src/internal/client/reactivity/store.js index ce082866ce..7124e23db8 100644 --- a/packages/svelte/src/internal/client/reactivity/store.js +++ b/packages/svelte/src/internal/client/reactivity/store.js @@ -8,6 +8,12 @@ import { teardown } from './effects.js'; import { mutable_source, set } from './sources.js'; import { DEV } from 'esm-env'; +/** + * We set this to `true` when updating a store so that we correctly + * schedule effects if the update takes place inside a `$:` effect + */ +export let legacy_is_updating_store = false; + /** * Whether or not the prop currently being read is a store binding, as in * ``. If it is, we treat the prop as mutable even in @@ -102,7 +108,7 @@ export function store_unsub(store, store_name, stores) { * @returns {V} */ export function store_set(store, value) { - store.set(value); + update_with_flag(store, value); return value; } @@ -141,6 +147,21 @@ export function setup_stores() { return [stores, cleanup]; } +/** + * @param {Store} store + * @param {V} value + * @template V + */ +function update_with_flag(store, value) { + legacy_is_updating_store = true; + + try { + store.set(value); + } finally { + legacy_is_updating_store = false; + } +} + /** * Updates a store with a new value. * @param {Store} store the store to update @@ -149,7 +170,7 @@ export function setup_stores() { * @template V */ export function store_mutate(store, expression, new_value) { - store.set(new_value); + update_with_flag(store, new_value); return expression; } @@ -160,7 +181,7 @@ export function store_mutate(store, expression, new_value) { * @returns {number} */ export function update_store(store, store_value, d = 1) { - store.set(store_value + d); + update_with_flag(store, store_value + d); return store_value; } @@ -172,7 +193,7 @@ export function update_store(store, store_value, d = 1) { */ export function update_pre_store(store, store_value, d = 1) { const value = store_value + d; - store.set(value); + update_with_flag(store, value); return value; } diff --git a/packages/svelte/tests/runtime-legacy/samples/store-reschedule/Child.svelte b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/Child.svelte new file mode 100644 index 0000000000..d955a82a88 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/Child.svelte @@ -0,0 +1,9 @@ + + + diff --git a/packages/svelte/tests/runtime-legacy/samples/store-reschedule/_config.js b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/_config.js new file mode 100644 index 0000000000..1c9ea0d5ea --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/_config.js @@ -0,0 +1,22 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ``, + + test({ assert, target }) { + const [button1, button2] = target.querySelectorAll('button'); + + flushSync(() => button1.click()); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => button1.click()); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => button2.click()); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => button2.click()); + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/store-reschedule/main.svelte b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/main.svelte new file mode 100644 index 0000000000..55c1438411 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/main.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/packages/svelte/tests/runtime-legacy/samples/store-reschedule/stores.js b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/stores.js new file mode 100644 index 0000000000..d432d339ec --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/stores.js @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; + +export const count = writable(0); From 3df2645451e6e9441e02d423d2fe77b8e4a38f59 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 6 Mar 2026 11:27:47 -0500 Subject: [PATCH 07/17] chore: deactivate batch after async derived resolves (#17865) Extracted from #17805. Similar to #17864, I'm not aware of any bugs resulting from this, but the fact that we're setting `current_batch` before calling `internal_set` and then not _unsetting_ `current_batch` feels like something that could potentially bite us. --- packages/svelte/src/internal/client/reactivity/deriveds.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 7df7651294..c1ee4f3f52 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -196,6 +196,8 @@ export function async_derived(fn, label, location) { if (decrement_pending) { decrement_pending(); } + + batch.deactivate(); }; d.promise.then(handler, (e) => handler(null, e || 'unknown')); From 0206a2019ec55ab62e8dbfd4449e371e9d76eb5c Mon Sep 17 00:00:00 2001 From: dev-miro26 <121471669+dev-miro26@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:11:16 -0600 Subject: [PATCH 08/17] fix: clean up externally-added DOM nodes in {@html} on re-render (#17853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes `{@html}` content duplication when used inside a contenteditable element. When `{@html content}` is inside a contenteditable element and the user types, the browser inserts DOM nodes directly into the {@html} managed region. On re-render (e.g. triggered by a blur handler setting `content = e.currentTarget.innerText`, the `{@html} `block only removed nodes it previously created via` effect.nodes`, leaving browser-inserted nodes in place. This caused content to appear twice — once as leftover text nodes and once as the new `{@html}` output. The fix tracks the boundary node (`previousSibling `of the anchor at init) and removes all nodes between the boundary and the anchor on re-render, ensuring externally-added nodes are also cleaned up. Closes: #16993 --------- Co-authored-by: 7nik Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .changeset/html-contenteditable-fix.md | 5 +++ .../3-transform/client/visitors/HtmlTag.js | 13 +++++-- .../client/visitors/shared/fragment.js | 2 ++ .../svelte/src/compiler/types/template.d.ts | 2 ++ .../src/internal/client/dom/blocks/html.js | 34 ++++++++++++++++++- .../html-tag-contenteditable/_config.js | 25 ++++++++++++++ .../html-tag-contenteditable/main.svelte | 9 +++++ 7 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 .changeset/html-contenteditable-fix.md create mode 100644 packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte diff --git a/.changeset/html-contenteditable-fix.md b/.changeset/html-contenteditable-fix.md new file mode 100644 index 0000000000..5cae7f6234 --- /dev/null +++ b/.changeset/html-contenteditable-fix.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: `{@html}` no longer duplicates content inside `contenteditable` elements diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js index 2706cf7f0a..6c8b7c0354 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js @@ -9,7 +9,11 @@ import { build_expression } from './shared/utils.js'; * @param {ComponentContext} context */ export function HtmlTag(node, context) { - context.state.template.push_comment(); + const is_controlled = node.metadata.is_controlled; + + if (!is_controlled) { + context.state.template.push_comment(); + } const has_await = node.metadata.expression.has_await; const has_blockers = node.metadata.expression.has_blockers(); @@ -17,14 +21,17 @@ export function HtmlTag(node, context) { const expression = build_expression(context, node.expression, node.metadata.expression); const html = has_await ? b.call('$.get', b.id('$$html')) : expression; - const is_svg = context.state.metadata.namespace === 'svg'; - const is_mathml = context.state.metadata.namespace === 'mathml'; + // When is_controlled, the parent node already provides the correct namespace, + // so is_svg/is_mathml are only needed for the non-controlled path's wrapper element + const is_svg = !is_controlled && context.state.metadata.namespace === 'svg'; + const is_mathml = !is_controlled && context.state.metadata.namespace === 'mathml'; const statement = b.stmt( b.call( '$.html', context.state.node, b.thunk(html), + is_controlled && b.true, is_svg && b.true, is_mathml && b.true, is_ignored(node, 'hydration_html_changed') && b.true diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js index 59b93f24ef..bd3e708662 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js @@ -109,6 +109,8 @@ export function process_children(nodes, initial, is_element, context) { !node.metadata.expression.is_async() ) { node.metadata.is_controlled = true; + } else if (node.type === 'HtmlTag' && nodes.length === 1 && is_element) { + node.metadata.is_controlled = true; } else { const id = flush_node( false, diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index d44a31349a..3c1e3e772c 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -133,6 +133,8 @@ export namespace AST { /** @internal */ metadata: { expression: ExpressionMetadata; + /** If `true`, the `{@html}` block is the only child of its parent element and can use `parent.innerHTML` directly */ + is_controlled?: boolean; }; } diff --git a/packages/svelte/src/internal/client/dom/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js index af66a04534..ffe947eb16 100644 --- a/packages/svelte/src/internal/client/dom/blocks/html.js +++ b/packages/svelte/src/internal/client/dom/blocks/html.js @@ -42,17 +42,33 @@ function check_hash(element, server_hash, value) { /** * @param {Element | Text | Comment} node * @param {() => string | TrustedHTML} get_value + * @param {boolean} [is_controlled] * @param {boolean} [svg] * @param {boolean} [mathml] * @param {boolean} [skip_warning] * @returns {void} */ -export function html(node, get_value, svg = false, mathml = false, skip_warning = false) { +export function html( + node, + get_value, + is_controlled = false, + svg = false, + mathml = false, + skip_warning = false +) { var anchor = node; /** @type {string | TrustedHTML} */ var value = ''; + if (is_controlled) { + var parent_node = /** @type {Element} */ (node); + + if (hydrating) { + anchor = set_hydrate_node(get_first_child(parent_node)); + } + } + template_effect(() => { var effect = /** @type {Effect} */ (active_effect); @@ -61,6 +77,22 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning return; } + if (is_controlled && !hydrating) { + // When @html is the only child, use innerHTML directly. + // This also handles contenteditable, where the user may delete the anchor comment. + effect.nodes = null; + parent_node.innerHTML = /** @type {string} */ (value); + + if (value !== '') { + assign_nodes( + /** @type {TemplateNode} */ (get_first_child(parent_node)), + /** @type {TemplateNode} */ (parent_node.lastChild) + ); + } + + return; + } + if (effect.nodes !== null) { remove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end)); effect.nodes = null; diff --git a/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js new file mode 100644 index 0000000000..9e188b1119 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from '../../../../src/index-client'; +import { test } from '../../test'; + +export default test({ + html: `

`, + + test({ assert, target }) { + const div = /** @type {HTMLDivElement} */ (target.querySelector('#editable')); + const output = /** @type {HTMLParagraphElement} */ (target.querySelector('#output')); + + // Simulate user typing by directly modifying the DOM + div.textContent = 'hello'; + + // Simulate blur which triggers `content = e.currentTarget.innerText` + const event = new Event('blur'); + div.dispatchEvent(event); + flushSync(); + + // The output should show "hello" (innerText was set correctly) + assert.equal(output.textContent, 'hello'); + + // The contenteditable div should contain "hello" once, not duplicated + assert.htmlEqual(div.innerHTML, 'hello'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte new file mode 100644 index 0000000000..3f887f2a9a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte @@ -0,0 +1,9 @@ + + +
{ content = e.currentTarget.textContent; }} contenteditable="true"> + {@html content} +
+ +

{content}

From 2deebdea8ffbdb74790ce7021e3b6992b39b77bb Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:59:58 +0100 Subject: [PATCH 09/17] fix: handle asnyc updates within pending boundary (#17873) When an async value is updated inside the boundary while the pending snippet is shown, we previously didn't notice that update and instead showed an outdated value once it resolved. This fixes that by rejecting all deferreds inside an async_derived while the pending snippet is shown. --------- Co-authored-by: Rich Harris --- .changeset/nasty-friends-crash.md | 5 ++ .../internal/client/reactivity/deriveds.js | 14 ++++- .../_config.js | 51 +++++++++++++++++++ .../main.svelte | 19 +++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 .changeset/nasty-friends-crash.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/main.svelte diff --git a/.changeset/nasty-friends-crash.md b/.changeset/nasty-friends-crash.md new file mode 100644 index 0000000000..5895f3752a --- /dev/null +++ b/.changeset/nasty-friends-crash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: handle asnyc updates within pending boundary diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index c1ee4f3f52..d8989ef03d 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -146,8 +146,18 @@ export function async_derived(fn, label, location) { if (should_suspend) { var decrement_pending = increment_pending(); - deferreds.get(batch)?.reject(STALE_REACTION); - deferreds.delete(batch); // delete to ensure correct order in Map iteration below + if (/** @type {Boundary} */ (parent.b).is_rendered()) { + deferreds.get(batch)?.reject(STALE_REACTION); + deferreds.delete(batch); // delete to ensure correct order in Map iteration below + } else { + // While the boundary is still showing pending, a new run supersedes all older in-flight runs + // for this async expression. Cancel eagerly so resolution cannot commit stale values. + for (const d of deferreds.values()) { + d.reject(STALE_REACTION); + } + deferreds.clear(); + } + deferreds.set(batch, d); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/_config.js new file mode 100644 index 0000000000..e444aa8f9b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/_config.js @@ -0,0 +1,51 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [shift, increment] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + + loading + ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + loading + ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + loading + ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 1 + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/main.svelte new file mode 100644 index 0000000000..c5a32dc4b9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/main.svelte @@ -0,0 +1,19 @@ + + + + + + + {await push(count)} + {#snippet pending()}loading{/snippet} + From 6fb7b4d265c1ffc2ff48fdf89be2244f22c6bb05 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 8 Mar 2026 07:39:07 -0400 Subject: [PATCH 10/17] chore: refactor scheduling (#17805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This simplifies the scheduling logic and will likely improve performance in some cases. Previously, there was a global `queued_root_effects` array, and we would cycle through the batch flushing logic as long as it was non-empty. This was a very loosey-goosey approach that was appropriate in the pre-async world, but has gradually become a source of confusion. Now, effects are scheduled within the context of a specific batch. The lifecycle is more rigorous and debuggable. This opens the door to explorations of alternative approaches, such as only scheduling effects when we call `batch.flush()`, which _may_ be better than the eager status quo. The layout of the `Batch` class is extremely chaotic — public/private/static fields/methods are all jumbled up together — and I would like to get a grip of it. In the interests of minimising diff noise that ought to be a follow-up rather than part of this PR. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/slick-bars-train.md | 5 + .../src/internal/client/dom/blocks/await.js | 7 + .../internal/client/dom/blocks/boundary.js | 31 +- .../client/dom/elements/bindings/input.js | 6 +- .../client/dom/elements/bindings/select.js | 6 +- .../src/internal/client/reactivity/async.js | 30 +- .../src/internal/client/reactivity/batch.js | 419 +++++++++--------- .../internal/client/reactivity/deriveds.js | 36 +- .../src/internal/client/reactivity/effects.js | 4 +- .../src/internal/client/reactivity/props.js | 4 +- .../src/internal/client/reactivity/sources.js | 30 +- .../async-attribute-without-state/_config.js | 9 +- 12 files changed, 319 insertions(+), 268 deletions(-) create mode 100644 .changeset/slick-bars-train.md diff --git a/.changeset/slick-bars-train.md b/.changeset/slick-bars-train.md new file mode 100644 index 0000000000..795f8d806f --- /dev/null +++ b/.changeset/slick-bars-train.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: simplify scheduling logic diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index 09a3ec5ca4..d6430547b5 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -45,7 +45,14 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { 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; /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 052736c35e..b38a3131ca 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -35,7 +35,7 @@ import { queue_micro_task } from '../task.js'; import * as e from '../../errors.js'; import * as w from '../../warnings.js'; import { DEV } from 'esm-env'; -import { Batch, schedule_effect } from '../../reactivity/batch.js'; +import { Batch, current_batch, schedule_effect } from '../../reactivity/batch.js'; import { internal_set, source } from '../../reactivity/sources.js'; import { tag } from '../../dev/tracing.js'; import { createSubscriber } from '../../../../reactivity/create-subscriber.js'; @@ -218,6 +218,8 @@ export class Boundary { this.is_pending = true; this.#pending_effect = branch(() => pending(this.#anchor)); + var batch = /** @type {Batch} */ (current_batch); + queue_micro_task(() => { var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); var anchor = create_text(); @@ -236,12 +238,14 @@ export class Boundary { this.#pending_effect = null; }); - this.#resolve(); + this.#resolve(batch); } }); } #render() { + var batch = /** @type {Batch} */ (current_batch); + try { this.is_pending = this.has_pending_snippet(); this.#pending_count = 0; @@ -258,14 +262,17 @@ export class Boundary { const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); this.#pending_effect = branch(() => pending(this.#anchor)); } else { - this.#resolve(); + this.#resolve(batch); } } catch (error) { this.error(error); } } - #resolve() { + /** + * @param {Batch} batch + */ + #resolve(batch) { this.is_pending = false; // any effects that were previously deferred should be rescheduled — @@ -273,12 +280,12 @@ export class Boundary { // same update that brought us here) the effects will be flushed for (const e of this.#dirty_effects) { set_signal_status(e, DIRTY); - schedule_effect(e); + batch.schedule(e); } for (const e of this.#maybe_dirty_effects) { set_signal_status(e, MAYBE_DIRTY); - schedule_effect(e); + batch.schedule(e); } this.#dirty_effects.clear(); @@ -335,11 +342,12 @@ export class Boundary { * Updates the pending count associated with the currently visible pending snippet, * if any, such that we can replace the snippet with content once work is done * @param {1 | -1} d + * @param {Batch} batch */ - #update_pending_count(d) { + #update_pending_count(d, batch) { if (!this.has_pending_snippet()) { if (this.parent) { - this.parent.#update_pending_count(d); + this.parent.#update_pending_count(d, batch); } // if there's no parent, we're in a scope with no pending snippet @@ -349,7 +357,7 @@ export class Boundary { this.#pending_count += d; if (this.#pending_count === 0) { - this.#resolve(); + this.#resolve(batch); if (this.#pending_effect) { pause_effect(this.#pending_effect, () => { @@ -369,9 +377,10 @@ export class Boundary { * and controls when the current `pending` snippet (if any) is removed. * Do not call from inside the class * @param {1 | -1} d + * @param {Batch} batch */ - update_pending_count(d) { - this.#update_pending_count(d); + update_pending_count(d, batch) { + this.#update_pending_count(d, batch); this.#local_pending_count += d; 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 23ad6f5cdc..55e61c3774 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -9,6 +9,7 @@ import { hydrating } from '../../hydration.js'; import { tick, untrack } from '../../../runtime.js'; import { is_runes } from '../../../context.js'; import { current_batch, previous_batch } from '../../../reactivity/batch.js'; +import { async_mode_flag } from '../../../../flags/index.js'; /** * @param {HTMLInputElement} input @@ -87,8 +88,9 @@ export function bind_value(input, get, set = get) { var value = get(); if (input === document.activeElement) { - // we need both, because in non-async mode, render effects run before previous_batch is set - var batch = /** @type {Batch} */ (previous_batch ?? current_batch); + // In sync mode render effects are executed during tree traversal -> needs current_batch + // In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch + var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch); // Never rewrite the contents of a focused input. We can get here if, for example, // an update is deferred because of async work depending on the input: diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js index 46e8f524f8..21be75ba61 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js @@ -4,6 +4,7 @@ import { is } from '../../../proxy.js'; import { is_array } from '../../../../shared/utils.js'; import * as w from '../../../warnings.js'; import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js'; +import { async_mode_flag } from '../../../../flags/index.js'; /** * Selects the correct option(s) (depending on whether this is a multiple select) @@ -115,8 +116,9 @@ export function bind_select_value(select, get, set = get) { var value = get(); if (select === document.activeElement) { - // we need both, because in non-async mode, render effects run before previous_batch is set - var batch = /** @type {Batch} */ (previous_batch ?? current_batch); + // In sync mode render effects are executed during tree traversal -> needs current_batch + // In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch + var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch); // Don't update the