From be36c934c4ed5d6e43818a7b1ff6cdb7ecb65412 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:43:06 +0100 Subject: [PATCH 01/14] fix: better `bind:this` cleanup timing (#17885) This removes the `queue_micro_task`-workaround we employed in `bind:this` in favor of a search for the nearest component effect / effect that is still getting destroyed, whichever comes first. We used `queue_micro_task` mainly due to timing issues with components wanting to access the bound property on teardown still, and when nulling it out on cleanup of the bind-this-effect itself, that was too early. The microtask is too late though in some cases, when accessing properties of objects that are no longer there. The targeted upwards-walk solves this while keeping the binding around as long as needed. For that I had to add a new `DESTROYING` flag. We _could_ have done it without one and by deleting code in `props.js` where we don't do `get(d)` when the prop derived is destroyed, but I wanted to keep that because you could still run into an access error if you e.g. access the property in a timeout. Alternative to #17862 --- .changeset/vast-moles-burn.md | 5 ++++ .../svelte/src/internal/client/constants.js | 2 ++ .../svelte/src/internal/client/context.js | 3 +- .../client/dom/elements/bindings/this.js | 29 +++++++++++++++---- .../src/internal/client/reactivity/effects.js | 8 +++-- .../src/internal/client/reactivity/props.js | 4 +-- .../svelte/src/internal/client/types.d.ts | 6 ++++ .../bind-this-destroy-timing/Inner.svelte | 9 ++++++ .../bind-this-destroy-timing/_config.js | 18 ++++++++++++ .../bind-this-destroy-timing/main.svelte | 19 ++++++++++++ .../bind-this-destroy-timing2/_config.js | 12 ++++++++ .../bind-this-destroy-timing2/main.svelte | 10 +++++++ 12 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 .changeset/vast-moles-burn.md create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/Inner.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/main.svelte diff --git a/.changeset/vast-moles-burn.md b/.changeset/vast-moles-burn.md new file mode 100644 index 0000000000..8d5ca73189 --- /dev/null +++ b/.changeset/vast-moles-burn.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: better `bind:this` cleanup timing diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 3525539f1d..df96f4899b 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -29,6 +29,8 @@ export const INERT = 1 << 13; export const DESTROYED = 1 << 14; /** Set once a reaction has run for the first time */ export const REACTION_RAN = 1 << 15; +/** Effect is in the process of getting destroyed. Can be observed in child teardown functions */ +export const DESTROYING = 1 << 25; // Flags exclusive to effects /** diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 36c735272c..0baef5c63e 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -5,7 +5,7 @@ import { active_effect, active_reaction } from './runtime.js'; import { create_user_effect } from './reactivity/effects.js'; import { async_mode_flag, legacy_mode_flag } from '../flags/index.js'; import { FILENAME } from '../../constants.js'; -import { BRANCH_EFFECT, REACTION_RAN } from './constants.js'; +import { BRANCH_EFFECT } from './constants.js'; /** @type {ComponentContext | null} */ export let component_context = null; @@ -182,6 +182,7 @@ export function push(props, runes = false, fn) { e: null, s: props, x: null, + r: /** @type {Effect} */ (active_effect), l: legacy_mode_flag && !runes ? { s: null, u: null, $: [] } : null }; diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/this.js b/packages/svelte/src/internal/client/dom/elements/bindings/this.js index f2e715113f..c39ca34062 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/this.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/this.js @@ -1,7 +1,8 @@ -import { STATE_SYMBOL } from '#client/constants'; +/** @import { ComponentContext, Effect } from '#client' */ +import { DESTROYING, STATE_SYMBOL } from '#client/constants'; +import { component_context } from '../../../context.js'; import { effect, render_effect } from '../../../reactivity/effects.js'; -import { untrack } from '../../../runtime.js'; -import { queue_micro_task } from '../../task.js'; +import { active_effect, untrack } from '../../../runtime.js'; /** * @param {any} bound_value @@ -23,6 +24,9 @@ function is_bound_this(bound_value, element_or_component) { * @returns {void} */ export function bind_this(element_or_component = {}, update, get_value, get_parts) { + var component_effect = /** @type {ComponentContext} */ (component_context).r; + var parent = /** @type {Effect} */ (active_effect); + effect(() => { /** @type {unknown[]} */ var old_parts; @@ -48,12 +52,25 @@ export function bind_this(element_or_component = {}, update, get_value, get_part }); return () => { - // We cannot use effects in the teardown phase, we we use a microtask instead. - queue_micro_task(() => { + // When the bind:this effect is destroyed, we go up the effect parent chain until we find the last parent effect that is destroyed, + // or the effect containing the component bind:this is in (whichever comes first). That way we can time the nulling of the binding + // as close to user/developer expectation as possible. + // TODO Svelte 6: Decide if we want to keep this logic or just always null the binding in the component effect's teardown + // (which would be simpler, but less intuitive in some cases, and breaks the `ondestroy-before-cleanup` test) + let p = parent; + while (p !== component_effect && p.parent !== null && p.parent.f & DESTROYING) { + p = p.parent; + } + const teardown = () => { if (parts && is_bound_this(get_value(...parts), element_or_component)) { update(null, ...parts); } - }); + }; + const original_teardown = p.teardown; + p.teardown = () => { + teardown(); + original_teardown?.(); + }; }; }); diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 49fe68d90b..aeffeedddd 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -34,7 +34,8 @@ import { USER_EFFECT, ASYNC, CONNECTED, - MANAGED_EFFECT + MANAGED_EFFECT, + DESTROYING } from '#client/constants'; import * as e from '../errors.js'; import { DEV } from 'esm-env'; @@ -520,9 +521,9 @@ export function destroy_effect(effect, remove_dom = true) { removed = true; } + set_signal_status(effect, DESTROYING); destroy_effect_children(effect, remove_dom && !removed); remove_reactions(effect, 0); - set_signal_status(effect, DESTROYED); var transitions = effect.nodes && effect.nodes.t; @@ -534,6 +535,9 @@ export function destroy_effect(effect, remove_dom = true) { execute_effect_teardown(effect); + effect.f ^= DESTROYING; + effect.f |= DESTROYED; + var parent = effect.parent; // If the parent doesn't have any children, then skip this work altogether diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index f63d4daedd..e208d3b6f6 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -419,9 +419,7 @@ export function prop(props, key, flags, fallback) { // special case — avoid recalculating the derived if we're in a // teardown function and the prop was overridden locally, or the - // component was already destroyed (this latter part is necessary - // because `bind:this` can read props after the component has - // been destroyed. TODO simplify `bind:this` + // component was already destroyed (people could access props in a timeout) if ((is_destroying_effect && overridden) || (parent_effect.f & DESTROYED) !== 0) { return d.v; } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index f4fc81170d..2320e7b510 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -38,6 +38,12 @@ export type ComponentContext = { * @deprecated remove in 6.0 */ x: Record | null; + /** + * The parent effect of this component + * TODO 6.0 this is used to control `bind:this` timing that might change, + * in which case we can remove this property + */ + r: Effect; /** * legacy stuff * @deprecated remove in 6.0 diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/Inner.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/Inner.svelte new file mode 100644 index 0000000000..e9e6e0d18d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/Inner.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/_config.js new file mode 100644 index 0000000000..e0bf4e3af0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/_config.js @@ -0,0 +1,18 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [btn] = target.querySelectorAll('button'); + + btn.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + +

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/main.svelte new file mode 100644 index 0000000000..11e75b8b6d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/main.svelte @@ -0,0 +1,19 @@ + + +{#if value} + {@const result = value} + +{/if} + + +

{externalView}

diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/_config.js new file mode 100644 index 0000000000..b48efa0fec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/_config.js @@ -0,0 +1,12 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [btn] = target.querySelectorAll('button'); + + btn.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/main.svelte new file mode 100644 index 0000000000..8b60d693eb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/main.svelte @@ -0,0 +1,10 @@ + + +{#if value} + {value} +{/if} + + From d54361b97f344300e599eea4ad538cd2ab78594d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:25:06 -0400 Subject: [PATCH 02/14] Version Packages (#17886) 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.9 ### Patch Changes - fix: better `bind:this` cleanup timing ([#17885](https://github.com/sveltejs/svelte/pull/17885)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/vast-moles-burn.md | 5 ----- packages/svelte/CHANGELOG.md | 6 ++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/vast-moles-burn.md diff --git a/.changeset/vast-moles-burn.md b/.changeset/vast-moles-burn.md deleted file mode 100644 index 8d5ca73189..0000000000 --- a/.changeset/vast-moles-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: better `bind:this` cleanup timing diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 3c796623a3..f09032294a 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte +## 5.53.9 + +### Patch Changes + +- fix: better `bind:this` cleanup timing ([#17885](https://github.com/sveltejs/svelte/pull/17885)) + ## 5.53.8 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index a26deef7e5..17fc4335d5 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.8", + "version": "5.53.9", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index a6f9f0f907..e481799ad5 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.8'; +export const VERSION = '5.53.9'; export const PUBLIC_VERSION = '5'; From 51d305d18a455c7ffce8e50ffebf092f408fd809 Mon Sep 17 00:00:00 2001 From: Rohan Santhosh Date: Tue, 10 Mar 2026 22:08:26 +0800 Subject: [PATCH 03/14] docs: fix usable spelling in migration guide (#17889) ### Summary Fix spelling in the v5 migration guide ("useable" -> "usable"). ### 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`). (Not applicable; docs-only change.) ### Tests and linting - [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint`. (Not run; docs-only change.) Co-authored-by: rohan436 --- documentation/docs/07-misc/07-v5-migration-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/07-misc/07-v5-migration-guide.md b/documentation/docs/07-misc/07-v5-migration-guide.md index 580dbec6d4..9b1f2dec63 100644 --- a/documentation/docs/07-misc/07-v5-migration-guide.md +++ b/documentation/docs/07-misc/07-v5-migration-guide.md @@ -324,7 +324,7 @@ When spreading props, local event handlers must go _after_ the spread, or they r > > It was always possible to use component callback props, but because you had to listen to DOM events using `on:`, it made sense to use `createEventDispatcher` for component events due to syntactical consistency. Now that we have event attributes (`onclick`), it's the other way around: Callback props are now the more sensible thing to do. > -> The removal of event modifiers is arguably one of the changes that seems like a step back for those who've liked the shorthand syntax of event modifiers. Given that they are not used that frequently, we traded a smaller surface area for more explicitness. Modifiers also were inconsistent, because most of them were only useable on DOM elements. +> The removal of event modifiers is arguably one of the changes that seems like a step back for those who've liked the shorthand syntax of event modifiers. Given that they are not used that frequently, we traded a smaller surface area for more explicitness. Modifiers also were inconsistent, because most of them were only usable on DOM elements. > > Multiple listeners for the same event are also no longer possible, but it was something of an anti-pattern anyway, since it impedes readability: if there are many attributes, it becomes harder to spot that there are two handlers unless they are right next to each other. It also implies that the two handlers are independent, when in fact something like `event.stopImmediatePropagation()` inside `one` would prevent `two` from being called. > From 342d8568f118e91375a452991335c9a89af8afee Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:52:23 +0100 Subject: [PATCH 04/14] fix: re-process batch if new root effects were scheduled (#17895) In some cases a new branch might create effects which via reading/writing reschedule an effect, causing `this.#roots` to become populated again. In this case we need to re-process the batch. Most of the time this will just result in a cleanup of the dirtied branches since other work is already handled via running the effects etc. - it's still crucial, else the reactive graph becomes frozen since no new root effects are scheduled. Fixes #17891 --------- Co-authored-by: Rich Harris --- .changeset/green-regions-write.md | 5 ++ .../src/internal/client/reactivity/batch.js | 8 ++++ .../new-branch-reschedule-2/_config.js | 46 +++++++++++++++++++ .../new-branch-reschedule-2/main.svelte | 37 +++++++++++++++ .../samples/new-branch-reschedule/_config.js | 44 ++++++++++++++++++ .../samples/new-branch-reschedule/main.svelte | 31 +++++++++++++ 6 files changed, 171 insertions(+) create mode 100644 .changeset/green-regions-write.md create mode 100644 packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/main.svelte diff --git a/.changeset/green-regions-write.md b/.changeset/green-regions-write.md new file mode 100644 index 0000000000..678e668b50 --- /dev/null +++ b/.changeset/green-regions-write.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: re-process batch if new root effects were scheduled diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index fc4c70c6b8..70bfbc0b47 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -271,6 +271,14 @@ export class Batch { var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); + // Edge case: During traversal new branches might create effects that run immediately and set state, + // causing an effect and therefore a root to be scheduled again. We need to traverse the current batch + // once more in that case - most of the time this will just clean up dirty branches. + if (this.#roots.length > 0) { + const batch = (next_batch ??= this); + batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); + } + if (next_batch !== null) { batches.add(next_batch); diff --git a/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/_config.js b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/_config.js new file mode 100644 index 0000000000..0d319b7274 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/_config.js @@ -0,0 +1,46 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [open, close, increment] = target.querySelectorAll('button'); + + open.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
open (width: 42)
+ ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
open (width: 42)
+ ` + ); + + close.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
closed
+ ` + ); + + assert.deepEqual(logs, ['effect ran']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/main.svelte new file mode 100644 index 0000000000..38a88a0bca --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/main.svelte @@ -0,0 +1,37 @@ + + + + + + + + +
+ {#if store.active} + open (width: {store.panelWidth}) + {:else} + closed + {/if} +
+ \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/_config.js b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/_config.js new file mode 100644 index 0000000000..5ebc278f0b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/_config.js @@ -0,0 +1,44 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [open, close, increment] = target.querySelectorAll('button'); + + open.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
open (width: 42)
+ ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
open (width: 42)
+ ` + ); + + close.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
closed
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/main.svelte b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/main.svelte new file mode 100644 index 0000000000..55b5baf62c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + +
+ {#if store.active} + open (width: {store.panelWidth}) + {:else} + closed + {/if} +
\ No newline at end of file From 72cd247c33ed5a2c9f0f952979336a3996617e1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:59:13 +0100 Subject: [PATCH 05/14] Version Packages (#17896) 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.10 ### Patch Changes - fix: re-process batch if new root effects were scheduled ([#17895](https://github.com/sveltejs/svelte/pull/17895)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/green-regions-write.md | 5 ----- packages/svelte/CHANGELOG.md | 6 ++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/green-regions-write.md diff --git a/.changeset/green-regions-write.md b/.changeset/green-regions-write.md deleted file mode 100644 index 678e668b50..0000000000 --- a/.changeset/green-regions-write.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: re-process batch if new root effects were scheduled diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index f09032294a..35a0b2df3f 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte +## 5.53.10 + +### Patch Changes + +- fix: re-process batch if new root effects were scheduled ([#17895](https://github.com/sveltejs/svelte/pull/17895)) + ## 5.53.9 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 17fc4335d5..bd55811602 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.9", + "version": "5.53.10", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index e481799ad5..13f68c6467 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.9'; +export const VERSION = '5.53.10'; export const PUBLIC_VERSION = '5'; From 0e8f49b25fbb11bf0bf9378b9423feeb0176f316 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 10 Mar 2026 19:23:32 -0400 Subject: [PATCH 06/14] chore: rebase batches after process, not during (#17900) This is part of me trying to figure out #17162. It feels less confusing to rebase other branches after the current batch has been processed, rather than sort of doing it in the middle (which is an artifact of historical constraints that no longer apply). No test because it doesn't change any user-observable behaviour (but I added a changeset just in case) ### 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/upset-parts-throw.md | 5 + .../src/internal/client/reactivity/batch.js | 112 ++++++++---------- 2 files changed, 57 insertions(+), 60 deletions(-) create mode 100644 .changeset/upset-parts-throw.md diff --git a/.changeset/upset-parts-throw.md b/.changeset/upset-parts-throw.md new file mode 100644 index 0000000000..5835fd48e0 --- /dev/null +++ b/.changeset/upset-parts-throw.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: rebase batches after process, not during diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 70bfbc0b47..cb115994f3 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -249,6 +249,10 @@ export class Batch { reset_branch(e, t); } } else { + if (this.#pending === 0) { + batches.delete(this); + } + // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. this.#dirty_effects.clear(); this.#maybe_dirty_effects.clear(); @@ -262,10 +266,6 @@ export class Batch { flush_queued_effects(effects); previous_batch = null; - if (this.#pending === 0) { - this.#commit(); - } - this.#deferred?.resolve(); } @@ -290,6 +290,10 @@ export class Batch { next_batch.#process(); } + + if (!batches.has(this)) { + this.#commit(); + } } /** @@ -433,74 +437,59 @@ export class Batch { // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more // recent value for a given source - if (batches.size > 1) { - this.previous.clear(); - - var previous_batch = current_batch; - var previous_batch_values = batch_values; - var is_earlier = true; - - for (const batch of batches) { - if (batch === this) { - is_earlier = false; - continue; + for (const batch of batches) { + var is_earlier = batch.id < this.id; + + /** @type {Source[]} */ + var sources = []; + + for (const [source, value] of this.current) { + if (batch.current.has(source)) { + if (is_earlier && value !== batch.current.get(source)) { + // bring the value up to date + batch.current.set(source, value); + } else { + // same value or later batch has more recent value, + // no need to re-run these effects + continue; + } } - /** @type {Source[]} */ - const sources = []; - - for (const [source, value] of this.current) { - if (batch.current.has(source)) { - if (is_earlier && value !== batch.current.get(source)) { - // bring the value up to date - batch.current.set(source, value); - } else { - // same value or later batch has more recent value, - // no need to re-run these effects - continue; - } - } + sources.push(source); + } - sources.push(source); - } + if (sources.length === 0) { + continue; + } - 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) { + batch.activate(); - // Re-run async/block effects that depend on distinct values changed in both batches - const others = [...batch.current.keys()].filter((s) => !this.current.has(s)); - if (others.length > 0) { - batch.activate(); - - /** @type {Set} */ - const marked = new Set(); - /** @type {Map} */ - const checked = new Map(); - for (const source of sources) { - mark_effects(source, others, marked, checked); - } + /** @type {Set} */ + var marked = new Set(); - if (batch.#roots.length > 0) { - batch.apply(); + /** @type {Map} */ + var checked = new Map(); - for (const root of batch.#roots) { - batch.#traverse(root, [], []); - } + for (var source of sources) { + mark_effects(source, others, marked, checked); + } - // TODO do we need to do anything with the dummy effect arrays? + if (batch.#roots.length > 0) { + batch.apply(); + + for (var root of batch.#roots) { + batch.#traverse(root, [], []); } - batch.deactivate(); + // TODO do we need to do anything with the dummy effect arrays? } - } - current_batch = previous_batch; - batch_values = previous_batch_values; + batch.deactivate(); + } } - - this.#skipped_branches.clear(); - batches.delete(this); } /** @@ -567,7 +556,10 @@ export class Batch { } apply() { - if (!async_mode_flag || (!this.is_fork && batches.size === 1)) return; + if (!async_mode_flag || (!this.is_fork && batches.size === 1)) { + batch_values = null; + return; + } // if there are multiple batches, we are 'time travelling' — // we need to override values with the ones in this batch... From 58f161dee2e59c79412e3906b17c1f05a119f193 Mon Sep 17 00:00:00 2001 From: Conduitry Date: Wed, 11 Mar 2026 13:48:42 -0400 Subject: [PATCH 07/14] fix: properly lazily evaluate RHS when checking for assignment_value_stale (#17906) Fixes #17904 by wrapping the RHS in a `() =>`. ### 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/quiet-jars-search.md | 5 ++++ .../client/visitors/AssignmentExpression.js | 4 ++-- .../svelte/src/internal/client/dev/assign.js | 24 +++++++++---------- .../_config.js | 19 +++++++++++++++ .../main.svelte | 18 ++++++++++++++ 5 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 .changeset/quiet-jars-search.md create mode 100644 packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs/main.svelte diff --git a/.changeset/quiet-jars-search.md b/.changeset/quiet-jars-search.md new file mode 100644 index 0000000000..4d229da74a --- /dev/null +++ b/.changeset/quiet-jars-search.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: properly lazily evaluate RHS when checking for `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 1379669e77..5282f1ed64 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 @@ -179,7 +179,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_nullish(object, 'items', () => [])` let should_transform = dev && path.at(-1) !== 'ExpressionStatement' && @@ -236,7 +236,7 @@ function build_assignment(operator, left, right, context) { ? left.property : b.literal(/** @type {Identifier} */ (left.property).name) ), - right, + b.arrow([], right), b.literal(locate_node(left)) ) ) diff --git a/packages/svelte/src/internal/client/dev/assign.js b/packages/svelte/src/internal/client/dev/assign.js index 3b48e736b5..1cda7044b5 100644 --- a/packages/svelte/src/internal/client/dev/assign.js +++ b/packages/svelte/src/internal/client/dev/assign.js @@ -21,12 +21,12 @@ function compare(a, b, property, location) { /** * @param {any} object * @param {string} property - * @param {any} value + * @param {() => any} rhs_getter * @param {string} location */ -export function assign(object, property, value, location) { +export function assign(object, property, rhs_getter, location) { return compare( - (object[property] = value), + (object[property] = rhs_getter()), untrack(() => object[property]), property, location @@ -36,12 +36,12 @@ export function assign(object, property, value, location) { /** * @param {any} object * @param {string} property - * @param {any} value + * @param {() => any} rhs_getter * @param {string} location */ -export function assign_and(object, property, value, location) { +export function assign_and(object, property, rhs_getter, location) { return compare( - (object[property] &&= value), + (object[property] &&= rhs_getter()), untrack(() => object[property]), property, location @@ -51,12 +51,12 @@ export function assign_and(object, property, value, location) { /** * @param {any} object * @param {string} property - * @param {any} value + * @param {() => any} rhs_getter * @param {string} location */ -export function assign_or(object, property, value, location) { +export function assign_or(object, property, rhs_getter, location) { return compare( - (object[property] ||= value), + (object[property] ||= rhs_getter()), untrack(() => object[property]), property, location @@ -66,12 +66,12 @@ export function assign_or(object, property, value, location) { /** * @param {any} object * @param {string} property - * @param {any} value + * @param {() => any} rhs_getter * @param {string} location */ -export function assign_nullish(object, property, value, location) { +export function assign_nullish(object, property, rhs_getter, location) { return compare( - (object[property] ??= value), + (object[property] ??= rhs_getter()), untrack(() => object[property]), property, location diff --git a/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs/_config.js b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs/_config.js new file mode 100644 index 0000000000..729eb1813a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs/_config.js @@ -0,0 +1,19 @@ +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

`); + 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/main.svelte b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs/main.svelte new file mode 100644 index 0000000000..9937dc9713 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs/main.svelte @@ -0,0 +1,18 @@ + + + +

count1: {count1}, count2: {count2}

From e4e089310d0fdf2b7dece779b58048e71c6ac02c Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Thu, 12 Mar 2026 11:17:56 +0100 Subject: [PATCH 08/14] 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 09/14] 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 10/14] 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 11/14] 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 12/14] 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 13/14] 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 14/14] 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

+ ` + ); } });