From e4e089310d0fdf2b7dece779b58048e71c6ac02c Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Thu, 12 Mar 2026 11:17:56 +0100 Subject: [PATCH 01/17] fix: remove `untrack` circular dependency (#17910) Closes #17899 by importing `untrack` from the actual file instead of the `index-client.js`. Verified by packing the library and launching a build with it. --- .changeset/late-weeks-unite.md | 5 +++++ packages/svelte/src/store/utils.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/late-weeks-unite.md diff --git a/.changeset/late-weeks-unite.md b/.changeset/late-weeks-unite.md new file mode 100644 index 0000000000..4e6e446a44 --- /dev/null +++ b/.changeset/late-weeks-unite.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: remove `untrack` circular dependency diff --git a/packages/svelte/src/store/utils.js b/packages/svelte/src/store/utils.js index db2a62c68c..2d36d64d2d 100644 --- a/packages/svelte/src/store/utils.js +++ b/packages/svelte/src/store/utils.js @@ -1,5 +1,5 @@ /** @import { Readable } from './public' */ -import { untrack } from '../index-client.js'; +import { untrack } from '../internal/client/runtime.js'; import { noop } from '../internal/shared/utils.js'; /** From 667896a753031b5d58157feb4b4eb7df49222ac0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 12 Mar 2026 07:22:42 -0400 Subject: [PATCH 02/17] fix: recover from errors that leave a corrupted effect tree (#17888) https://github.com/sveltejs/svelte/pull/17680#issuecomment-3888440736. Errors that occur during traversal (not inside a template effect etc) can leave dirty effects inside the effect tree, but with clean parents. This means that a) subsequent changes to their dependencies won't schedule them to re-run b) subsequent batch flushes won't 'reach' them unless a sibling effect happens to be made dirty The easiest way to fix this is to just repair the tree if traversal fails. If you had a truly ginormous tree this could conceivably take a noticeable amount of time, but that's probably better than the app just being broken. Note that this doesn't apply to errors that occur inside an error boundary, because in that case the offending subtree gets destroyed. This is just for errors that bubble all the way to the root. Closes #17680, closes #17679. --- .changeset/public-plants-win.md | 5 +++ .../src/internal/client/reactivity/batch.js | 21 +++++++++++- .../samples/error-recovery/_config.js | 32 +++++++++++++++++++ .../samples/error-recovery/main.svelte | 13 ++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 .changeset/public-plants-win.md create mode 100644 packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte diff --git a/.changeset/public-plants-win.md b/.changeset/public-plants-win.md new file mode 100644 index 0000000000..af65137426 --- /dev/null +++ b/.changeset/public-plants-win.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: recover from errors that leave a corrupted effect tree diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index cb115994f3..a09654bfc0 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -225,7 +225,12 @@ export class Batch { var updates = (legacy_updates = []); for (const root of roots) { - this.#traverse(root, effects, render_effects); + try { + this.#traverse(root, effects, render_effects); + } catch (e) { + reset_all(root); + throw e; + } } // any writes should take effect in a subsequent batch @@ -959,6 +964,20 @@ function reset_branch(effect, tracked) { } } +/** + * Mark an entire effect tree clean following an error + * @param {Effect} effect + */ +function reset_all(effect) { + set_signal_status(effect, CLEAN); + + var e = effect.first; + while (e !== null) { + reset_all(e); + e = e.next; + } +} + /** * Creates a 'fork', in which state changes are evaluated but not applied to the DOM. * This is useful for speculatively loading data (for example) when you suspect that diff --git a/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js b/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js new file mode 100644 index 0000000000..52c1bbd1bf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js @@ -0,0 +1,32 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, compileOptions }) { + const [toggle, increment] = target.querySelectorAll('button'); + + flushSync(() => increment.click()); + assert.htmlEqual( + target.innerHTML, + ` + + +

show: false

+ ` + ); + + assert.throws(() => { + flushSync(() => toggle.click()); + }, /NonExistent is not defined/); + + flushSync(() => increment.click()); + assert.htmlEqual( + target.innerHTML, + ` + + +

show: ${compileOptions.experimental?.async ? 'false' : 'true'}

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte b/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte new file mode 100644 index 0000000000..03bfae2596 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte @@ -0,0 +1,13 @@ + + + + + +

show: {show}

+ +{#if show} + +{/if} From 7c9ff8fc697a0723129d781e5bf116cf38e016f6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 12 Mar 2026 09:28:43 -0400 Subject: [PATCH 03/17] fix: resolve boundary in correct batch when hydrating (#17914) Fixes #17907. When hydrating, we were resolving the boundary in the hydration batch rather than the batch created inside the `queue_micro_task` inside `#hydrate_pending_content`. This meant that effects got scheduled inside a batch that was already resolved. ### 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. - [x] 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/stale-loops-love.md | 5 +++++ .../svelte/src/internal/client/dom/blocks/boundary.js | 8 ++------ .../samples/effect-in-pending-boundary/Child.svelte | 5 +++++ .../samples/effect-in-pending-boundary/_config.js | 7 +++++++ .../samples/effect-in-pending-boundary/main.svelte | 11 +++++++++++ 5 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 .changeset/stale-loops-love.md create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte diff --git a/.changeset/stale-loops-love.md b/.changeset/stale-loops-love.md new file mode 100644 index 0000000000..d36392cca3 --- /dev/null +++ b/.changeset/stale-loops-love.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: resolve boundary in correct batch when hydrating diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index b38a3131ca..8046f1e222 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -218,8 +218,6 @@ 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(); @@ -238,14 +236,12 @@ export class Boundary { this.#pending_effect = null; }); - this.#resolve(batch); + this.#resolve(/** @type {Batch} */ (current_batch)); } }); } #render() { - var batch = /** @type {Batch} */ (current_batch); - try { this.is_pending = this.has_pending_snippet(); this.#pending_count = 0; @@ -262,7 +258,7 @@ export class Boundary { const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); this.#pending_effect = branch(() => pending(this.#anchor)); } else { - this.#resolve(batch); + this.#resolve(/** @type {Batch} */ (current_batch)); } } catch (error) { this.error(error); diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte new file mode 100644 index 0000000000..0f18e43e56 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js new file mode 100644 index 0000000000..21575231ee --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + async test({ assert, logs }) { + assert.deepEqual(logs, ['hello from child']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte new file mode 100644 index 0000000000..c4c0ef23ab --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte @@ -0,0 +1,11 @@ + + + + + + {#snippet pending()} +

Loading...

+ {/snippet} +
From bd433c5ceb74e4b966ff901432078ab366142bb2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:14:28 -0400 Subject: [PATCH 04/17] Version Packages (#17901) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## svelte@5.53.11 ### Patch Changes - fix: remove `untrack` circular dependency ([#17910](https://github.com/sveltejs/svelte/pull/17910)) - fix: recover from errors that leave a corrupted effect tree ([#17888](https://github.com/sveltejs/svelte/pull/17888)) - fix: properly lazily evaluate RHS when checking for `assignment_value_stale` ([#17906](https://github.com/sveltejs/svelte/pull/17906)) - fix: resolve boundary in correct batch when hydrating ([#17914](https://github.com/sveltejs/svelte/pull/17914)) - chore: rebase batches after process, not during ([#17900](https://github.com/sveltejs/svelte/pull/17900)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/late-weeks-unite.md | 5 ----- .changeset/public-plants-win.md | 5 ----- .changeset/quiet-jars-search.md | 5 ----- .changeset/stale-loops-love.md | 5 ----- .changeset/upset-parts-throw.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/late-weeks-unite.md delete mode 100644 .changeset/public-plants-win.md delete mode 100644 .changeset/quiet-jars-search.md delete mode 100644 .changeset/stale-loops-love.md delete mode 100644 .changeset/upset-parts-throw.md diff --git a/.changeset/late-weeks-unite.md b/.changeset/late-weeks-unite.md deleted file mode 100644 index 4e6e446a44..0000000000 --- a/.changeset/late-weeks-unite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: remove `untrack` circular dependency diff --git a/.changeset/public-plants-win.md b/.changeset/public-plants-win.md deleted file mode 100644 index af65137426..0000000000 --- a/.changeset/public-plants-win.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: recover from errors that leave a corrupted effect tree diff --git a/.changeset/quiet-jars-search.md b/.changeset/quiet-jars-search.md deleted file mode 100644 index 4d229da74a..0000000000 --- a/.changeset/quiet-jars-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: properly lazily evaluate RHS when checking for `assignment_value_stale` diff --git a/.changeset/stale-loops-love.md b/.changeset/stale-loops-love.md deleted file mode 100644 index d36392cca3..0000000000 --- a/.changeset/stale-loops-love.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: resolve boundary in correct batch when hydrating diff --git a/.changeset/upset-parts-throw.md b/.changeset/upset-parts-throw.md deleted file mode 100644 index 5835fd48e0..0000000000 --- a/.changeset/upset-parts-throw.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: rebase batches after process, not during diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 35a0b2df3f..2ae21fb28a 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,19 @@ # svelte +## 5.53.11 + +### Patch Changes + +- fix: remove `untrack` circular dependency ([#17910](https://github.com/sveltejs/svelte/pull/17910)) + +- fix: recover from errors that leave a corrupted effect tree ([#17888](https://github.com/sveltejs/svelte/pull/17888)) + +- fix: properly lazily evaluate RHS when checking for `assignment_value_stale` ([#17906](https://github.com/sveltejs/svelte/pull/17906)) + +- fix: resolve boundary in correct batch when hydrating ([#17914](https://github.com/sveltejs/svelte/pull/17914)) + +- chore: rebase batches after process, not during ([#17900](https://github.com/sveltejs/svelte/pull/17900)) + ## 5.53.10 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index bd55811602..d1d60d3d1c 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.53.10", + "version": "5.53.11", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 13f68c6467..64b2ee5c2d 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.53.10'; +export const VERSION = '5.53.11'; export const PUBLIC_VERSION = '5'; From f598b4b3c08fead2683369a29e76483321052563 Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Thu, 12 Mar 2026 09:21:20 -0500 Subject: [PATCH 05/17] fix:typo in best practices documentation (#17915) --- documentation/docs/07-misc/01-best-practices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/07-misc/01-best-practices.md b/documentation/docs/07-misc/01-best-practices.md index 66f7da2613..e2cb72828f 100644 --- a/documentation/docs/07-misc/01-best-practices.md +++ b/documentation/docs/07-misc/01-best-practices.md @@ -143,7 +143,7 @@ The CSS in a component's ` ``` -If this impossible (for example, the child component comes from a library) you can use `:global` to override styles: +If this is impossible (for example, the child component comes from a library) you can use `:global` to override styles: ```svelte
From 63686ae22cb14f9f667d65ed906cbf201675b5dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:00:19 -0400 Subject: [PATCH 06/17] chore(deps): bump devalue from 5.6.3 to 5.6.4 (#17916) Bumps [devalue](https://github.com/sveltejs/devalue) from 5.6.3 to 5.6.4.
Release notes

Sourced from devalue's releases.

v5.6.4

Patch Changes

  • 87c1f3c: fix: reject __proto__ keys in malformed Object wrapper payloads

    This validates the "Object" parse path and throws when the wrapped value has an own __proto__ key.

  • 40f1db1: fix: ensure sparse array indices are integers

  • 87c1f3c: fix: disallow __proto__ keys in null-prototype object parsing

    This disallows __proto__ keys in the "null" parse path so null-prototype object hydration cannot carry that key through parse/unflatten.

Changelog

Sourced from devalue's changelog.

5.6.4

Patch Changes

  • 87c1f3c: fix: reject __proto__ keys in malformed Object wrapper payloads

    This validates the "Object" parse path and throws when the wrapped value has an own __proto__ key.

  • 40f1db1: fix: ensure sparse array indices are integers

  • 87c1f3c: fix: disallow __proto__ keys in null-prototype object parsing

    This disallows __proto__ keys in the "null" parse path so null-prototype object hydration cannot carry that key through parse/unflatten.

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=devalue&package-manager=npm_and_yarn&previous-version=5.6.3&new-version=5.6.4)](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> --- packages/svelte/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index d1d60d3d1c..10c93b500b 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -175,7 +175,7 @@ "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.3", + "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8693c466c1..aa423e4923 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 devalue: - specifier: ^5.6.3 - version: 5.6.3 + specifier: ^5.6.4 + version: 5.6.4 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1267,8 +1267,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - devalue@5.6.3: - resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} @@ -3563,7 +3563,7 @@ snapshots: detect-libc@1.0.3: optional: true - devalue@5.6.3: {} + devalue@5.6.4: {} dir-glob@3.0.1: dependencies: From 44c4f213e96dd762eade9b2f3749ac996afc0077 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:10:07 +0100 Subject: [PATCH 07/17] fix: ensure deriveds values are correct across batches (#17917) capture derived updates aswell so they become part of current/previous so that `batch_values` computation is correct when e.g. using `$state.eager` with a derived. Fixes #17849 --- .changeset/gold-times-see.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 17 ++++------- .../internal/client/reactivity/deriveds.js | 2 ++ .../src/internal/client/reactivity/sources.js | 6 +++- .../samples/async-eager-derived/_config.js | 23 ++++++++++++++ .../samples/async-eager-derived/main.svelte | 22 ++++++++++++++ .../_config.js | 16 ++++++++++ .../main.svelte | 21 +++++++++++++ .../async-unresolved-promise/_config.js | 30 +++++++++++++++++++ 9 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 .changeset/gold-times-see.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-derived/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte diff --git a/.changeset/gold-times-see.md b/.changeset/gold-times-see.md new file mode 100644 index 0000000000..f8d5da5042 --- /dev/null +++ b/.changeset/gold-times-see.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure deriveds values are correct across batches diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a09654bfc0..ebaed93e9c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -366,11 +366,11 @@ export class Batch { * Associate a change to a given source with the current * batch, noting its previous and current values * @param {Source} source - * @param {any} value + * @param {any} old_value */ - capture(source, value) { - if (value !== UNINITIALIZED && !this.previous.has(source)) { - this.previous.set(source, value); + capture(source, old_value) { + if (old_value !== UNINITIALIZED && !this.previous.has(source)) { + this.previous.set(source, old_value); } // Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get` @@ -572,7 +572,7 @@ export class Batch { // ...and undo changes belonging to other batches for (const batch of batches) { - if (batch === this) continue; + if (batch === this || batch.is_fork) continue; for (const [source, previous] of batch.previous) { if (!batch_values.has(source)) { @@ -1020,13 +1020,6 @@ export function fork(fn) { source.v = value; } - // make writable deriveds dirty, so they recalculate correctly - for (source of batch.current.keys()) { - if ((source.f & DERIVED) !== 0) { - set_signal_status(source, DIRTY); - } - } - return { commit: async () => { if (committed) { diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 3478784309..aed55f7fba 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -371,6 +371,7 @@ export function execute_derived(derived) { * @returns {void} */ export function update_derived(derived) { + var old_value = derived.v; var value = execute_derived(derived); if (!derived.equals(value)) { @@ -382,6 +383,7 @@ export function update_derived(derived) { // change, `derived.equals` may incorrectly return `true` if (!current_batch?.is_fork || derived.deps === null) { derived.v = value; + current_batch?.capture(derived, old_value); // deriveds without dependencies should never be recomputed if (derived.deps === null) { diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index f4ae92659c..3ccde0f211 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -231,7 +231,11 @@ export function internal_set(source, value, updated_during_traversal = null) { execute_derived(derived); } - update_derived_status(derived); + // During time traveling we don't want to reset the status so that + // traversal of the graph in the other batches still happens + if (batch_values === null) { + update_derived_status(derived); + } } source.wv = increment_write_version(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js new file mode 100644 index 0000000000..043f1610fb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js @@ -0,0 +1,23 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

true - true

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

false - false

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

{$state.eager(count) !== count} - {$state.eager(derivedCount) !== derivedCount}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js new file mode 100644 index 0000000000..98440b6922 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn] = target.querySelectorAll('button'); + + btn.click(); + await tick(); + assert.deepEqual(logs, [10]); + + btn.click(); + await tick(); + assert.deepEqual(logs, [10, 10]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte new file mode 100644 index 0000000000..16c1668480 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js b/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js index e9ccbba2b6..c6f65c33be 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js @@ -18,6 +18,14 @@ export default test({ increment.click(); await tick(); + assert.htmlEqual( + target.innerHTML, + ` + +

0

+ ` + ); + increment.click(); await tick(); @@ -28,5 +36,27 @@ export default test({

2

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

2

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

4

+ ` + ); } }); From ad94009e34ab3bbc95380a7e6a5542eaa445d008 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 12 Mar 2026 19:55:15 -0400 Subject: [PATCH 08/17] chore: add autofix workflow (#17922) lets maintainers comment `/autofix` to trigger Prettier and regenerate messages/types/etc --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .github/workflows/autofix.yml | 69 +++++++++++++++++++++++++++++++++++ packages/svelte/package.json | 5 ++- 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/autofix.yml diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 0000000000..fba68fdb99 --- /dev/null +++ b/.github/workflows/autofix.yml @@ -0,0 +1,69 @@ +name: Autofix Lint + +on: + issue_comment: + types: [created] + workflow_dispatch: + +permissions: {} + +jobs: + autofix-lint: + permissions: + contents: write # to push the generated types commit + pull-requests: read # to resolve the PR head ref + # prevents this action from running on forks + if: | + github.repository == 'sveltejs/svelte' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.event.issue.pull_request != null && + github.event.comment.body == '/autofix' && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + ) + ) + runs-on: ubuntu-latest + steps: + - name: Get PR ref + if: github.event_name != 'workflow_dispatch' + id: pr + uses: actions/github-script@v8 + with: + script: | + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + if (pull.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'Cannot autofix: this PR is from a forked repository. The autofix workflow can only push to branches within this repository.' + }); + core.setFailed('PR is from a fork'); + } + core.setOutput('ref', pull.head.ref); + - uses: actions/checkout@v6 + if: github.event_name == 'workflow_dispatch' || steps.pr.outcome == 'success' + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || steps.pr.outputs.ref }} + - uses: pnpm/action-setup@v4.3.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run prettier + run: pnpm format + - name: Generate types + run: pnpm -F svelte generate + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git diff --staged --quiet || git commit -m "chore: autofix" + git push origin HEAD diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 10c93b500b..60221f4b0f 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -138,14 +138,15 @@ "templating" ], "scripts": { - "build": "node scripts/process-messages && rollup -c && pnpm generate:types && node scripts/check-treeshakeability.js", + "build": "rollup -c && pnpm generate", "dev": "node scripts/process-messages -w & rollup -cw", "check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc", "check:tsgo": "tsgo --project tsconfig.runtime.json --skipLibCheck && tsgo --skipLibCheck", "check:watch": "tsc --watch", + "generate": "node scripts/process-messages && node ./scripts/generate-types.js", "generate:version": "node ./scripts/generate-version.js", "generate:types": "node ./scripts/generate-types.js && tsc -p tsconfig.generated.json", - "prepublishOnly": "pnpm build", + "prepublishOnly": "pnpm build && node scripts/check-treeshakeability.js", "knip": "pnpm dlx knip" }, "devDependencies": { From ee3807ecbe524d4fd9eff03fb161a55262baf883 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 12 Mar 2026 20:01:38 -0400 Subject: [PATCH 09/17] fix autofix (#17923) we need to build the compiler before Prettier will work --- .github/workflows/autofix.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index fba68fdb99..706a62e3d6 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -56,10 +56,10 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + - name: Build + run: pnpm -F svelte build - name: Run prettier run: pnpm format - - name: Generate types - run: pnpm -F svelte generate - name: Commit changes run: | git config user.name "github-actions[bot]" From 3dbd95075c324304d668d72e0c08ed958173fb8f Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Fri, 13 Mar 2026 07:37:57 +0530 Subject: [PATCH 10/17] fix: keep select.__value current when effect is deferred (#17745) ## Summary Fixes #17148 When a `` with dynamic `{#each}` options inside an async boundary, selects a non-initial option while focused, adds another option, and verifies the select retains the user's choice - Verified existing `async-binding-update-while-focused-3` test still passes - All 7151 tests pass (`pnpm test`) --------- Co-authored-by: Tee Ming Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/fix-select-stale-value.md | 5 ++ .../client/dom/elements/bindings/select.js | 3 ++ .../_config.js | 54 +++++++++++++++++++ .../main.svelte | 31 +++++++++++ .../samples/select-option-added/_config.js | 49 +++++++++++++++++ .../samples/select-option-added/main.svelte | 16 ++++++ 6 files changed, 158 insertions(+) create mode 100644 .changeset/fix-select-stale-value.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte diff --git a/.changeset/fix-select-stale-value.md b/.changeset/fix-select-stale-value.md new file mode 100644 index 0000000000..3442f7e3b8 --- /dev/null +++ b/.changeset/fix-select-stale-value.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: update `select.__value` on `change` 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 21be75ba61..eb26062653 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js @@ -106,6 +106,9 @@ export function bind_select_value(select, get, set = get) { set(value); + // @ts-ignore + select.__value = value; + if (current_batch !== null) { batches.add(current_batch); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js new file mode 100644 index 0000000000..24e2036d39 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js @@ -0,0 +1,54 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [add, shift, reset] = target.querySelectorAll('button'); + + // resolve initial pending state + shift.click(); + await tick(); + + const [p] = target.querySelectorAll('p'); + + const select = /** @type {HTMLSelectElement} */ (target.querySelector('select')); + assert.equal(select.value, 'a'); + + // add option 'c', making items ['a', 'b', 'c'] + add.click(); + await tick(); + + // select 'b' while focused + select.focus(); + select.value = 'b'; + select.dispatchEvent(new InputEvent('change', { bubbles: true })); + await tick(); + + assert.equal(select.value, 'b'); + assert.equal(p.textContent, 'a'); + + // add option 'd', making items ['a', 'b', 'c', 'd'] + // this triggers MutationObserver which uses select.__value + add.click(); + await tick(); + + // select should still show 'b', not snap to a stale value + assert.equal(select.value, 'b'); + assert.equal(p.textContent, 'a'); + + shift.click(); + await tick(); + assert.equal(select.value, 'b'); + assert.equal(p.textContent, 'b'); + + reset.click(); + await tick(); + assert.equal(select.value, 'b'); + assert.equal(p.textContent, 'b'); + + shift.click(); + await tick(); + assert.equal(select.value, 'a'); + assert.equal(p.textContent, 'a'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte new file mode 100644 index 0000000000..968e920f82 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + + +

{await push(selected)}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js b/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js new file mode 100644 index 0000000000..c91fd59f26 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js @@ -0,0 +1,49 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, variant }) { + const [button] = target.querySelectorAll('button'); + const [select] = target.querySelectorAll('select'); + + flushSync(() => { + select.focus(); + select.value = '2'; + select.dispatchEvent(new InputEvent('change', { bubbles: true })); + }); + + assert.equal(select.selectedOptions[0].textContent, '2'); + + assert.htmlEqual( + target.innerHTML, + ` + +

selected: 2

+ + ` + ); + + flushSync(() => button.click()); + await tick(); + + assert.equal(select.selectedOptions[0].textContent, '2'); + + assert.htmlEqual( + target.innerHTML, + ` + +

selected: 2

+ + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte b/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte new file mode 100644 index 0000000000..ebf6a6c082 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte @@ -0,0 +1,16 @@ + + + + +

selected: {selected}

+ + From 043a7a26cdd5e9d053041466200f56b8ab56f1fa Mon Sep 17 00:00:00 2001 From: Conduitry Date: Fri, 13 Mar 2026 08:56:41 -0400 Subject: [PATCH 11/17] chore: add sandbox output files to .prettierignore (#17926) If you had any output files from previous sandbox runs, these would get checked and failed by Prettier. We were already ignore `src`, so now we're ignoring `dist` and `output` as well. ### Before submitting the PR, please make sure you do the following - [ ] 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` --- .prettierignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.prettierignore b/.prettierignore index ee5ef6d8c6..92d9bc797b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,6 +31,8 @@ packages/svelte/tests/parser-modern/samples/*/_actual.json packages/svelte/tests/parser-modern/samples/*/output.json packages/svelte/types packages/svelte/compiler/index.js +playgrounds/sandbox/dist/* +playgrounds/sandbox/output/* playgrounds/sandbox/src/* **/node_modules From a513da0445c6d8848b37d6d482c1fb5337cf03d7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 13 Mar 2026 12:48:12 -0400 Subject: [PATCH 12/17] chore: add invariant utility (#17929) With this, we can add invariants to the codebase so we can identify problems like 'this batch already has roots scheduled', which indicate a bug somewhere, without a) needing tests for scenarios that are inherently hard to anticipate, or b) cluttering people's prod bundles --- .changeset/flat-lemons-joke.md | 5 +++++ .../98-reference/.generated/shared-errors.md | 6 ++++++ .../svelte/messages/shared-errors/errors.md | 4 ++++ packages/svelte/src/internal/shared/dev.js | 14 ++++++++++++++ packages/svelte/src/internal/shared/errors.js | 17 +++++++++++++++++ 5 files changed, 46 insertions(+) create mode 100644 .changeset/flat-lemons-joke.md diff --git a/.changeset/flat-lemons-joke.md b/.changeset/flat-lemons-joke.md new file mode 100644 index 0000000000..24c34d77d8 --- /dev/null +++ b/.changeset/flat-lemons-joke.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: add `invariant` helper for debugging diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index 136b3f4957..739bd58b35 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -42,6 +42,12 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` ``` +### invariant_violation + +``` +An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%" +``` + ### lifecycle_outside_component ``` diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index bf053283e4..3ff474768e 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -34,6 +34,10 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P > A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` +## invariant_violation + +> An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%" + ## lifecycle_outside_component > `%name%(...)` can only be used during component initialisation diff --git a/packages/svelte/src/internal/shared/dev.js b/packages/svelte/src/internal/shared/dev.js index aadb3c7e6d..9c36664dc4 100644 --- a/packages/svelte/src/internal/shared/dev.js +++ b/packages/svelte/src/internal/shared/dev.js @@ -1,4 +1,6 @@ +import { DEV } from 'esm-env'; import { define_property } from './utils.js'; +import * as e from './errors.js'; /** * @param {string} label @@ -63,3 +65,15 @@ export function get_stack() { return new_lines; } + +/** + * @param {boolean} condition + * @param {string} message + */ +export function invariant(condition, message) { + if (!DEV) { + throw new Error('invariant(...) was not guarded by if (DEV)'); + } + + if (!condition) e.invariant_violation(message); +} diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index b13a65b598..a4ad8cecff 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -51,6 +51,23 @@ export function invalid_snippet_arguments() { } } +/** + * An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%" + * @param {string} message + * @returns {never} + */ +export function invariant_violation(message) { + if (DEV) { + const error = new Error(`invariant_violation\nAn invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "${message}"\nhttps://svelte.dev/e/invariant_violation`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/invariant_violation`); + } +} + /** * `%name%(...)` can only be used during component initialisation * @param {string} name From 965f2a0ac863f98fc3f85e1dc6664ab69fdad267 Mon Sep 17 00:00:00 2001 From: Conduitry Date: Fri, 13 Mar 2026 14:38:36 -0400 Subject: [PATCH 13/17] fix: handle async RHS in assignment_value_stale (#17925) Fixes #17924. This also DRYs stuff a bit by making `operator` an argument to the runtime helper function, which means we only need two variants of it: regular and async. It also makes it so that `=` assignments don't use the getter, because they don't need to be done lazily. I've added `skip_no_async` to the new test, but I'm not entirely clear on why it was failing the TestNoAsync run to begin with. ### 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. - [x] 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 Holthausen --- .changeset/purple-boats-hear.md | 5 ++ .../client/visitors/AssignmentExpression.js | 44 ++++++-------- .../svelte/src/internal/client/dev/assign.js | 60 ++++++++----------- packages/svelte/src/internal/client/index.js | 2 +- .../_config.js | 25 ++++++++ .../main.svelte | 18 ++++++ 6 files changed, 92 insertions(+), 62 deletions(-) create mode 100644 .changeset/purple-boats-hear.md create mode 100644 packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte diff --git a/.changeset/purple-boats-hear.md b/.changeset/purple-boats-hear.md new file mode 100644 index 0000000000..785edbb1aa --- /dev/null +++ b/.changeset/purple-boats-hear.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: handle async RHS in `assignment_value_stale` diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index 5282f1ed64..e66e3408e2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -5,7 +5,8 @@ import * as b from '#compiler/builders'; import { build_assignment_value, get_attribute_expression, - is_event_attribute + is_event_attribute, + is_expression_async } from '../../../../utils/ast.js'; import { dev, locate_node } from '../../../../state.js'; import { build_getter, should_proxy } from '../utils.js'; @@ -36,14 +37,6 @@ function is_non_coercive_operator(operator) { return ['=', '||=', '&&=', '??='].includes(operator); } -/** @type {Record} */ -const callees = { - '=': '$.assign', - '&&=': '$.assign_and', - '||=': '$.assign_or', - '??=': '$.assign_nullish' -}; - /** * @param {AssignmentOperator} operator * @param {Pattern} left @@ -179,7 +172,7 @@ function build_assignment(operator, left, right, context) { // in cases like `(object.items ??= []).push(value)`, we may need to warn // if the value gets proxified, since the proxy _isn't_ the thing that // will be pushed to. we do this by transforming it to something like - // `$.assign_nullish(object, 'items', () => [])` + // `$.assign(object, 'items', '??=', () => [])` let should_transform = dev && path.at(-1) !== 'ExpressionStatement' && @@ -225,22 +218,23 @@ function build_assignment(operator, left, right, context) { } if (left.type === 'MemberExpression' && should_transform) { - const callee = callees[operator]; - return /** @type {Expression} */ ( - context.visit( - b.call( - callee, - /** @type {Expression} */ (left.object), - /** @type {Expression} */ ( - left.computed - ? left.property - : b.literal(/** @type {Identifier} */ (left.property).name) - ), - b.arrow([], right), - b.literal(locate_node(left)) - ) - ) + const needs_lazy_getter = operator !== '='; + const needs_async = needs_lazy_getter && is_expression_async(right); + /** @type {Expression} */ + let e = b.call( + needs_async ? '$.assign_async' : '$.assign', + /** @type {Expression} */ (left.object), + /** @type {Expression} */ ( + left.computed ? left.property : b.literal(/** @type {Identifier} */ (left.property).name) + ), + b.literal(operator), + needs_lazy_getter ? b.arrow([], right, needs_async) : right, + b.literal(locate_node(left)) ); + if (needs_async) { + e = b.await(e); + } + return /** @type {Expression} */ (context.visit(e)); } return null; diff --git a/packages/svelte/src/internal/client/dev/assign.js b/packages/svelte/src/internal/client/dev/assign.js index 1cda7044b5..e328543b32 100644 --- a/packages/svelte/src/internal/client/dev/assign.js +++ b/packages/svelte/src/internal/client/dev/assign.js @@ -21,12 +21,21 @@ function compare(a, b, property, location) { /** * @param {any} object * @param {string} property - * @param {() => any} rhs_getter + * @param {string} operator + * @param {any} rhs * @param {string} location */ -export function assign(object, property, rhs_getter, location) { +export function assign(object, property, operator, rhs, location) { return compare( - (object[property] = rhs_getter()), + operator === '=' + ? (object[property] = rhs) + : operator === '&&=' + ? (object[property] &&= rhs()) + : operator === '||=' + ? (object[property] ||= rhs()) + : operator === '??=' + ? (object[property] ??= rhs()) + : null, untrack(() => object[property]), property, location @@ -36,42 +45,21 @@ export function assign(object, property, rhs_getter, location) { /** * @param {any} object * @param {string} property - * @param {() => any} rhs_getter + * @param {string} operator + * @param {any} rhs * @param {string} location */ -export function assign_and(object, property, rhs_getter, location) { +export async function assign_async(object, property, operator, rhs, location) { return compare( - (object[property] &&= rhs_getter()), - untrack(() => object[property]), - property, - location - ); -} - -/** - * @param {any} object - * @param {string} property - * @param {() => any} rhs_getter - * @param {string} location - */ -export function assign_or(object, property, rhs_getter, location) { - return compare( - (object[property] ||= rhs_getter()), - untrack(() => object[property]), - property, - location - ); -} - -/** - * @param {any} object - * @param {string} property - * @param {() => any} rhs_getter - * @param {string} location - */ -export function assign_nullish(object, property, rhs_getter, location) { - return compare( - (object[property] ??= rhs_getter()), + operator === '=' + ? (object[property] = await rhs) + : operator === '&&=' + ? (object[property] &&= await rhs()) + : operator === '||=' + ? (object[property] ||= await rhs()) + : operator === '??=' + ? (object[property] ??= await rhs()) + : null, untrack(() => object[property]), property, location diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index af8df2e32c..988998d067 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -1,7 +1,7 @@ export { createAttachmentKey as attachment } from '../../attachments/index.js'; export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js'; export { push, pop, add_svelte_meta } from './context.js'; -export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js'; +export { assign, assign_async } from './dev/assign.js'; export { cleanup_styles } from './dev/css.js'; export { add_locations } from './dev/elements.js'; export { hmr } from './dev/hmr.js'; diff --git a/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js new file mode 100644 index 0000000000..9ad21cffb0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + async test({ assert, target }) { + const button = /** @type {HTMLElement} */ (target.querySelector('button')); + await tick(); + assert.htmlEqual(target.innerHTML, `

count1: 0, count2: 0

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

count1: 1, count2: 1

`); + + // additional tick necessary in legacy mode because it's using Promise.resolve() which finishes before the await in the component, + // causing the cache to not be set yet, which would result in count2 becoming 2 + await tick(); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

count1: 2, count2: 1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte new file mode 100644 index 0000000000..be4d0ad2f4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte @@ -0,0 +1,18 @@ + + + +

count1: {count1}, count2: {count2}

From 1ebed6832270b7ae6abf5251d2e659a39687dd13 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:25:45 +0100 Subject: [PATCH 14/17] fix: avoid traversing clean roots (#17928) Before #17805, all batches drew from the same `queued_root_effects` and did reset them to the empty array when starting a flush. After the refactoring roots are scheduled per batch. This introduces a possible race condition where the same root is scheduled multiple times. It was possible because of the rebase logic in `#commit` not clearing the array of roots, so if you somehow flush that same batch later, you will end up traversing a clean root. (it is possible a bug like this always existed with rebasing it was just impossible hard to trigger it before because everyone drew from the same root effects array) The fix is a bit more complicated than just checking if new roots where added, we gotta check if we actually created async work before traversing. Fixes #17918 --------- Co-authored-by: Rich Harris --- .changeset/stupid-chefs-rescue.md | 5 ++ .../internal/client/dom/blocks/boundary.js | 18 +----- .../src/internal/client/reactivity/batch.js | 57 +++++++++++++------ .../async-pending-effect/Component.svelte | 11 ++++ .../samples/async-pending-effect/_config.js | 16 ++++++ .../samples/async-pending-effect/main.svelte | 19 +++++++ 6 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 .changeset/stupid-chefs-rescue.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte diff --git a/.changeset/stupid-chefs-rescue.md b/.changeset/stupid-chefs-rescue.md new file mode 100644 index 0000000000..d21389e97a --- /dev/null +++ b/.changeset/stupid-chefs-rescue.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid traversing clean roots diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 8046f1e222..b440bb3ba4 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -271,21 +271,9 @@ export class Boundary { #resolve(batch) { this.is_pending = false; - // any effects that were previously deferred should be rescheduled — - // after the next traversal (which will happen immediately, due to the - // same update that brought us here) the effects will be flushed - for (const e of this.#dirty_effects) { - set_signal_status(e, DIRTY); - batch.schedule(e); - } - - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); - batch.schedule(e); - } - - this.#dirty_effects.clear(); - this.#maybe_dirty_effects.clear(); + // any effects that were previously deferred should be transferred + // to the batch, which will flush in the next microtask + batch.transfer_effects(this.#dirty_effects, this.#maybe_dirty_effects); } /** diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ebaed93e9c..3a4872b0b6 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -38,6 +38,8 @@ 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'; +import { invariant } from '../../shared/dev.js'; +import { log_effect_tree } from '../dev/debug.js'; /** @type {Set} */ const batches = new Set(); @@ -204,9 +206,25 @@ export class Batch { #process() { if (flush_count++ > 1000) { + batches.delete(this); infinite_loop_guard(); } + // we only reschedule previously-deferred effects if we expect + // to be able to run them after processing the batch + if (!this.#is_deferred()) { + for (const e of this.#dirty_effects) { + this.#maybe_dirty_effects.delete(e); + set_signal_status(e, DIRTY); + this.schedule(e); + } + + for (const e of this.#maybe_dirty_effects) { + set_signal_status(e, MAYBE_DIRTY); + this.schedule(e); + } + } + const roots = this.#roots; this.#roots = []; @@ -396,21 +414,6 @@ export class Batch { is_processing = true; current_batch = this; - // we only reschedule previously-deferred effects if we expect - // to be able to run them after processing the batch - if (!this.#is_deferred()) { - for (const e of this.#dirty_effects) { - this.#maybe_dirty_effects.delete(e); - set_signal_status(e, DIRTY); - this.schedule(e); - } - - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); - this.schedule(e); - } - } - this.#process(); } finally { flush_count = 0; @@ -470,6 +473,10 @@ export class Batch { // Re-run async/block effects that depend on distinct values changed in both batches var others = [...batch.current.keys()].filter((s) => !this.current.has(s)); if (others.length > 0) { + if (DEV) { + invariant(batch.#roots.length === 0, 'Batch has scheduled roots'); + } + batch.activate(); /** @type {Set} */ @@ -482,6 +489,7 @@ export class Batch { mark_effects(source, others, marked, checked); } + // Only apply and traverse when we know we triggered async work with marking the effects if (batch.#roots.length > 0) { batch.apply(); @@ -489,7 +497,7 @@ export class Batch { batch.#traverse(root, [], []); } - // TODO do we need to do anything with the dummy effect arrays? + batch.#roots = []; } batch.deactivate(); @@ -523,6 +531,23 @@ export class Batch { }); } + /** + * @param {Set} dirty_effects + * @param {Set} maybe_dirty_effects + */ + transfer_effects(dirty_effects, maybe_dirty_effects) { + for (const e of dirty_effects) { + this.#dirty_effects.add(e); + } + + for (const e of maybe_dirty_effects) { + this.#maybe_dirty_effects.add(e); + } + + dirty_effects.clear(); + maybe_dirty_effects.clear(); + } + /** @param {(batch: Batch) => void} fn */ oncommit(fn) { this.#commit_callbacks.add(fn); diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte new file mode 100644 index 0000000000..b39ca3dfd4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js new file mode 100644 index 0000000000..083608e9dc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + // This test causes two batches to be scheduled such that the same root is traversed multiple times, + // some of the time while it was already marked clean by a previous batch processing. It tests + // that the app stays reactive after, i.e. that the root is not improperly marked as unclean. + await tick(); + const [button] = target.querySelectorAll('button'); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

hello

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte new file mode 100644 index 0000000000..d6c09803f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte @@ -0,0 +1,19 @@ + + + + + + + + {#snippet pending()} + + {/snippet} + + +{#if condition} +

hello

+{/if} From dcf4865b22a2ea5ca52549e240a13e628f6b5836 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:25:53 +0100 Subject: [PATCH 15/17] chore: add "untracked allows writes" test (#17930) While we don't officially document it, `untrack` also allows to opt out of the "unsafe mutation" validation, which is what we test here. For anyone coming across this: USE WITH CAUTION. This can cause graph inconsistencies leading to wrong values on initial render Doing this because we're gonna make use of it ourselves for remote functions, and this ensures we don't accidentally regress. --- .../samples/untrack-allows-writes/_config.js | 19 +++++++++++++++++++ .../samples/untrack-allows-writes/main.svelte | 14 ++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js new file mode 100644 index 0000000000..ead5653dd4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js @@ -0,0 +1,19 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +// While we don't officially document it, `untrack` also allows to opt out of the "unsafe mutation" validation, which is what we test here +export default test({ + html: '', + test({ assert, target }) { + const button = target.querySelector('button'); + + flushSync(() => button?.click()); + + assert.htmlEqual( + target.innerHTML, + ` + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte new file mode 100644 index 0000000000..f61a23da84 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte @@ -0,0 +1,14 @@ + + + From 8b86bdd82db5d91156b0128e33a16027f2667ac1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 04:26:40 -0400 Subject: [PATCH 16/17] Version Packages (#17919) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## svelte@5.53.12 ### Patch Changes - fix: update `select.__value` on `change` ([#17745](https://github.com/sveltejs/svelte/pull/17745)) - chore: add `invariant` helper for debugging ([#17929](https://github.com/sveltejs/svelte/pull/17929)) - fix: ensure deriveds values are correct across batches ([#17917](https://github.com/sveltejs/svelte/pull/17917)) - fix: handle async RHS in `assignment_value_stale` ([#17925](https://github.com/sveltejs/svelte/pull/17925)) - fix: avoid traversing clean roots ([#17928](https://github.com/sveltejs/svelte/pull/17928)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/fix-select-stale-value.md | 5 ----- .changeset/flat-lemons-joke.md | 5 ----- .changeset/gold-times-see.md | 5 ----- .changeset/purple-boats-hear.md | 5 ----- .changeset/stupid-chefs-rescue.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/fix-select-stale-value.md delete mode 100644 .changeset/flat-lemons-joke.md delete mode 100644 .changeset/gold-times-see.md delete mode 100644 .changeset/purple-boats-hear.md delete mode 100644 .changeset/stupid-chefs-rescue.md diff --git a/.changeset/fix-select-stale-value.md b/.changeset/fix-select-stale-value.md deleted file mode 100644 index 3442f7e3b8..0000000000 --- a/.changeset/fix-select-stale-value.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: update `select.__value` on `change` diff --git a/.changeset/flat-lemons-joke.md b/.changeset/flat-lemons-joke.md deleted file mode 100644 index 24c34d77d8..0000000000 --- a/.changeset/flat-lemons-joke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: add `invariant` helper for debugging diff --git a/.changeset/gold-times-see.md b/.changeset/gold-times-see.md deleted file mode 100644 index f8d5da5042..0000000000 --- a/.changeset/gold-times-see.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure deriveds values are correct across batches diff --git a/.changeset/purple-boats-hear.md b/.changeset/purple-boats-hear.md deleted file mode 100644 index 785edbb1aa..0000000000 --- a/.changeset/purple-boats-hear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: handle async RHS in `assignment_value_stale` diff --git a/.changeset/stupid-chefs-rescue.md b/.changeset/stupid-chefs-rescue.md deleted file mode 100644 index d21389e97a..0000000000 --- a/.changeset/stupid-chefs-rescue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: avoid traversing clean roots diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 2ae21fb28a..3ca0db8dec 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,19 @@ # svelte +## 5.53.12 + +### Patch Changes + +- fix: update `select.__value` on `change` ([#17745](https://github.com/sveltejs/svelte/pull/17745)) + +- chore: add `invariant` helper for debugging ([#17929](https://github.com/sveltejs/svelte/pull/17929)) + +- fix: ensure deriveds values are correct across batches ([#17917](https://github.com/sveltejs/svelte/pull/17917)) + +- fix: handle async RHS in `assignment_value_stale` ([#17925](https://github.com/sveltejs/svelte/pull/17925)) + +- fix: avoid traversing clean roots ([#17928](https://github.com/sveltejs/svelte/pull/17928)) + ## 5.53.11 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 60221f4b0f..49c53a26eb 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.53.11", + "version": "5.53.12", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 64b2ee5c2d..5045430cee 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.53.11'; +export const VERSION = '5.53.12'; export const PUBLIC_VERSION = '5'; From 98e8b635fab10be1d82c593097276af373300a55 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 14 Mar 2026 17:43:04 -0400 Subject: [PATCH 17/17] fix: discard batches made obsolete by commit (#17934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batches that are made stale (because of a `STALE_REACTION`) can end up sticking around indefinitely, forcing every subsequent batch into time-traveling mode and causing incorrect `previous` values to be rendered. This partially fixes it, by discarding any older batches that are subsets of a batch currently being committed. It's not a complete fix, though — if an earlier batch is stale but is _not_ a subset of the committed batch, it becomes a zombie, and its changes will never be applied. Haven't quite figured out how to think about that yet. ### 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. - [x] 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/modern-towns-call.md | 5 ++ .../src/internal/client/reactivity/batch.js | 15 ++-- .../async-discard-obsolete-batch/_config.js | 89 +++++++++++++++++++ .../async-discard-obsolete-batch/main.svelte | 36 ++++++++ 4 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 .changeset/modern-towns-call.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte diff --git a/.changeset/modern-towns-call.md b/.changeset/modern-towns-call.md new file mode 100644 index 0000000000..77ca8e9185 --- /dev/null +++ b/.changeset/modern-towns-call.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: discard batches made obsolete by commit diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 3a4872b0b6..b100d559d2 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -438,6 +438,8 @@ export class Batch { discard() { for (const fn of this.#discard_callbacks) fn(this); this.#discard_callbacks.clear(); + + batches.delete(this); } #commit() { @@ -466,13 +468,15 @@ export class Batch { sources.push(source); } - if (sources.length === 0) { - continue; - } - // Re-run async/block effects that depend on distinct values changed in both batches var others = [...batch.current.keys()].filter((s) => !this.current.has(s)); - if (others.length > 0) { + + if (others.length === 0) { + if (is_earlier) { + // this batch is now obsolete and can be discarded + batch.discard(); + } + } else if (sources.length > 0) { if (DEV) { invariant(batch.#roots.length === 0, 'Batch has scheduled roots'); } @@ -1095,7 +1099,6 @@ export function fork(fn) { } if (!committed && batches.has(batch)) { - batches.delete(batch); batch.discard(); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js new file mode 100644 index 0000000000..64e1a4b2b5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js @@ -0,0 +1,89 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, shift, pop] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

1 = 1

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

1 = 1

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

1 = 1

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

3 = 3

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

3 = 3

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

5 = 5

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte new file mode 100644 index 0000000000..faa8d139a6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte @@ -0,0 +1,36 @@ + + + + + + + +

{n} = {await push(n)}