From fcaa8ce7236069755912df5f7d29c7e5e5c57974 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 7 May 2026 20:34:26 +0200 Subject: [PATCH 1/5] fix: reapply new batch after `#commit` (#18186) Fixes a regression of #18170 (not released yet therefore no changeset). `current_batch` is nulled out if the `#commit` rebases other branches, and that can lead to nullpointers down the line. No test right now but it's part of getting the failing SvelteKit test passing. --- packages/svelte/src/internal/client/reactivity/batch.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a106806721..8f14cb4437 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -363,6 +363,8 @@ export class Batch { // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed if (async_mode_flag && !batches.has(this)) { this.#commit(); + // Rebases can activate other batches or null it out, therefore restore the new one here + current_batch = next_batch; } // Edge case: During traversal new branches might create effects that run immediately and set state, From 9950b22869b084052cee9e708a79d7cc3bd12be8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 12:08:04 -0400 Subject: [PATCH 2/5] fix: process batches created in effect and immediately flushed (#18194) no changeset because the bug this fixes hasn't been released --- .../src/internal/client/reactivity/batch.js | 21 ++++++---------- .../async-flushsync-in-effect/_config.js | 25 +++++++++++++++++++ .../async-flushsync-in-effect/main.svelte | 25 +++++++++++++++++++ 3 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 8f14cb4437..418e2a8f1a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -372,12 +372,11 @@ export class 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); + batches.add(batch); batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); } if (next_batch !== null) { - batches.add(next_batch); - if (DEV) { for (const source of this.current.keys()) { /** @type {Set} */ (source_stacks).add(source); @@ -728,20 +727,14 @@ export class Batch { static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); + batches.add(batch); - if (!is_processing) { - batches.add(current_batch); - - if (!is_flushing_sync) { - queue_micro_task(() => { - if (batch.#started) { - // a flushSync happened in the meantime - return; - } - + if (!is_processing && !is_flushing_sync) { + queue_micro_task(() => { + if (!batch.#started) { batch.flush(); - }); - } + } + }); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js new file mode 100644 index 0000000000..59a81afd35 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js @@ -0,0 +1,25 @@ +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, ' 0'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 1'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 2'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte new file mode 100644 index 0000000000..12c4dd578e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte @@ -0,0 +1,25 @@ + + + + +{await push(count)} From e00944ffd1940110e4c1aeb8912fa26858e610bc Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 8 May 2026 18:08:26 +0200 Subject: [PATCH 3/5] fix: correctly compile component member expressions for SSR (#18192) Fixes #18191 --- .changeset/quiet-teams-pick.md | 5 +++++ .../phases/3-transform/server/visitors/Component.js | 6 +++++- .../samples/dynamic-component-member/Icon.svelte | 1 + .../samples/dynamic-component-member/_config.js | 8 ++++++++ .../samples/dynamic-component-member/main.svelte | 8 ++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-teams-pick.md create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte diff --git a/.changeset/quiet-teams-pick.md b/.changeset/quiet-teams-pick.md new file mode 100644 index 0000000000..ed046168be --- /dev/null +++ b/.changeset/quiet-teams-pick.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly compile component member expressions for SSR diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js index 8e7d7bcdbf..ed202edd3b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js @@ -9,5 +9,9 @@ import { build_inline_component } from './shared/component.js'; * @param {ComponentContext} context */ export function Component(node, context) { - build_inline_component(node, /** @type {Expression} */ (context.visit(b.id(node.name))), context); + build_inline_component( + node, + /** @type {Expression} */ (context.visit(b.member_id(node.name))), + context + ); } diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte new file mode 100644 index 0000000000..77cdcabaf7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte @@ -0,0 +1 @@ +x diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js new file mode 100644 index 0000000000..22412de14d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + mode: ['client', 'server'], + + html: `x`, + ssrHtml: `x` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte new file mode 100644 index 0000000000..b0f95b2a31 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte @@ -0,0 +1,8 @@ + + + From 9ffc13d7c3a9360591e1ad45209b10024e3a8333 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 12:55:29 -0400 Subject: [PATCH 4/5] fix: remove `source.updated` stack traces after `flush` (#18196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Noticed that we're not actually doing anything with `source_stacks` — we shadow the module-level declaration in `flush`, which means we just keep appending to it and then clearing a different (and empty) set. As a result, any source that ever gets an `updated` property never gets rid of it. This probably causes a memory leak? Anyway, this fixes it. ### 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. - [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/red-crabs-ring.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 .changeset/red-crabs-ring.md diff --git a/.changeset/red-crabs-ring.md b/.changeset/red-crabs-ring.md new file mode 100644 index 0000000000..82b53c5464 --- /dev/null +++ b/.changeset/red-crabs-ring.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reset `source.updated` stack traces after `flush` diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 418e2a8f1a..dfa53788f3 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -85,7 +85,9 @@ export let collected_effects = null; export let legacy_updates = null; var flush_count = 0; -var source_stacks = DEV ? new Set() : null; + +/** @type {Set} */ +var source_stacks = new Set(); let uid = 1; @@ -273,6 +275,14 @@ export class Batch { infinite_loop_guard(); } + if (DEV) { + // track all the values that were updated during this flush, + // so that they can be reset afterwards + for (const value of this.current.keys()) { + source_stacks.add(value); + } + } + // we only reschedule previously-deferred effects if we expect // to be able to run them after processing the batch if (!this.#is_deferred()) { @@ -377,12 +387,6 @@ export class Batch { } if (next_batch !== null) { - if (DEV) { - for (const source of this.current.keys()) { - /** @type {Set} */ (source_stacks).add(source); - } - } - next_batch.#process(); } } @@ -481,9 +485,11 @@ export class Batch { } flush() { - var source_stacks = DEV ? new Set() : null; - try { + if (DEV) { + source_stacks.clear(); + } + is_processing = true; current_batch = this; @@ -501,7 +507,7 @@ export class Batch { old_values.clear(); if (DEV) { - for (const source of /** @type {Set} */ (source_stacks)) { + for (const source of source_stacks) { source.updated = null; } } From af5b9724ab31e8570d9c69ac24c009bb0913960a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 13:41:08 -0400 Subject: [PATCH 5/5] chore: flatten `#process()` (#18190) Realised that we can make the logic in `#process()` a little easier to follow by early-returning if a batch is deferred (or blocked) after processing, since nothing that happens after the `if` block applies in that case --- .../src/internal/client/reactivity/batch.js | 36 +++++++------ .../_config.js | 54 +++++++++++++++++++ .../main.svelte | 29 ++++++++++ 3 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index dfa53788f3..d1f3ce08af 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -344,26 +344,28 @@ export class Batch { for (const [e, t] of this.#skipped_branches) { reset_branch(e, t); } - } else { - if (this.#pending === 0) { - batches.delete(this); + + if (updates.length > 0) { + /** @type {Batch} */ (/** @type {unknown} */ (current_batch)).#process(); } - // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. - this.#dirty_effects.clear(); - this.#maybe_dirty_effects.clear(); + return; + } - // append/remove branches - for (const fn of this.#commit_callbacks) fn(this); - this.#commit_callbacks.clear(); + // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. + this.#dirty_effects.clear(); + this.#maybe_dirty_effects.clear(); - previous_batch = this; - flush_queued_effects(render_effects); - flush_queued_effects(effects); - previous_batch = null; + // append/remove branches + for (const fn of this.#commit_callbacks) fn(this); + this.#commit_callbacks.clear(); - this.#deferred?.resolve(); - } + previous_batch = this; + flush_queued_effects(render_effects); + flush_queued_effects(effects); + previous_batch = null; + + this.#deferred?.resolve(); var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); @@ -371,7 +373,7 @@ export class Batch { // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed - if (async_mode_flag && !batches.has(this)) { + if (async_mode_flag && this.#pending === 0) { this.#commit(); // Rebases can activate other batches or null it out, therefore restore the new one here current_batch = next_batch; @@ -530,6 +532,8 @@ export class Batch { } #commit() { + batches.delete(this); + // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more diff --git a/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js new file mode 100644 index 0000000000..d7293f9b70 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js @@ -0,0 +1,54 @@ +import { tick } from 'svelte'; +import { ok, test } from '../../test'; + +// Test that the store is unsubscribed from, even if it's not referenced once the store itself is set to null +export default test({ + skip_no_async: true, + + async test({ target, assert }) { + assert.htmlEqual( + target.innerHTML, + `

0

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

1

hello 1 ` + ); + + const input = target.querySelector('input'); + ok(input); + + input.stepUp(); + input.dispatchEvent(new Event('input', { bubbles: true })); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

hello 2 ` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

` + ); + + input.stepUp(); + input.dispatchEvent(new Event('input', { bubbles: true })); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

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

3

hello 3 ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte new file mode 100644 index 0000000000..4abfd3c2f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte @@ -0,0 +1,29 @@ + + + +

{count}

+ +{#if watcherA} + + {#if true} + {await 'hello'} + {/if} + + {$watcherA} + +{:else} + +{/if}