From 128be7625ebaef39bc95afd48249abcceced1de5 Mon Sep 17 00:00:00 2001 From: Nic <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:03:42 -0400 Subject: [PATCH 1/2] fix: don't resurrect outroing elements when an ancestor block is paused and resumed --- .changeset/quiet-falcons-search.md | 5 ++++ .../svelte/src/internal/client/constants.js | 7 +++++ .../src/internal/client/reactivity/effects.js | 9 +++++- .../_config.js | 29 +++++++++++++++++++ .../main.svelte | 19 ++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-falcons-search.md create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume/main.svelte diff --git a/.changeset/quiet-falcons-search.md b/.changeset/quiet-falcons-search.md new file mode 100644 index 0000000000..8c5c4dedca --- /dev/null +++ b/.changeset/quiet-falcons-search.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't resurrect outroing elements when an ancestor block is paused and resumed diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 043b50b4b2..974e9d00ef 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -21,6 +21,13 @@ export const BOUNDARY_EFFECT = 1 << 7; * no dependents, we can disconnect it from the graph, allowing it to either be * GC'd or reconnected later if an effect comes to depend on it again */ +/** + * Set on the effect that `pause_effect` was called on, i.e. the root of a paused subtree, + * as opposed to its descendants which are merely `INERT`. This allows `resume_effect` on + * an ancestor to skip subtrees that were paused for their own reasons (such as a block + * whose condition is still false) rather than resurrecting them + */ +export const PAUSED = 1 << 8; export const CONNECTED = 1 << 9; export const CLEAN = 1 << 10; export const DIRTY = 1 << 11; diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index c5d195dfae..f6f4e385f0 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -34,7 +34,8 @@ import { ASYNC, CONNECTED, MANAGED_EFFECT, - DESTROYING + DESTROYING, + PAUSED } from '#client/constants'; import * as e from '../errors.js'; import { DEV } from 'esm-env'; @@ -610,6 +611,7 @@ export function pause_effect(effect, callback, destroy = true) { /** @type {TransitionManager[]} */ var transitions = []; + effect.f |= PAUSED; pause_children(effect, transitions, true); var fn = () => { @@ -677,6 +679,7 @@ function pause_children(effect, transitions, local) { * @param {Effect} effect */ export function resume_effect(effect) { + effect.f &= ~PAUSED; resume_children(effect, true); } @@ -685,6 +688,10 @@ export function resume_effect(effect) { * @param {boolean} local */ function resume_children(effect, local) { + // this subtree was paused for its own reasons (e.g. a block whose condition + // is still false) — its controller will resume or destroy it + if ((effect.f & PAUSED) !== 0) return; + if ((effect.f & INERT) === 0) return; effect.f ^= INERT; diff --git a/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume/_config.js b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume/_config.js new file mode 100644 index 0000000000..4009eea204 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume/_config.js @@ -0,0 +1,29 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +// #15604 — an element mid-outro must not be resurrected when an ancestor +// block is paused and resumed while the element's own condition is still false. +// The outer `transition:fade` matters: it keeps the outer branch alive (pending +// its own outro) so that toggling back takes the resume path +export default test({ + test({ assert, target, raf }) { + const [hide, fetch] = target.querySelectorAll('button'); + + // let the mount intro (dom variant) finish + raf.tick(200); + assert.ok(target.querySelector('.red')); + + // start the outro of the inner block + flushSync(() => hide.click()); + + // pause the outer block while the outro is in flight... + flushSync(() => fetch.click()); + raf.tick(250); + + // ...then resume it before either outro completes + flushSync(() => fetch.click()); + raf.tick(2000); + + assert.equal(target.querySelector('.red'), null); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume/main.svelte b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume/main.svelte new file mode 100644 index 0000000000..0060401b8e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume/main.svelte @@ -0,0 +1,19 @@ + + + + + +{#if fetching} +

loading

+{:else} +
+ {#if shown} +
red
+ {/if} +
+{/if} From f77cebf37ff7481cc192fd29c90ab64425d4b1c3 Mon Sep 17 00:00:00 2001 From: Nic <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:53:19 -0400 Subject: [PATCH 2/2] test: cover the each.js outro path and the flip-back-while-paused case --- .../_config.js | 23 ++++++++++++++++++ .../main.svelte | 19 +++++++++++++++ .../_config.js | 24 +++++++++++++++++++ .../main.svelte | 19 +++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-each/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-each/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-flipback/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-flipback/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-each/_config.js b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-each/_config.js new file mode 100644 index 0000000000..7ccfd72f00 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-each/_config.js @@ -0,0 +1,23 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +// #15604 — a removed each item mid-outro must not be resurrected by an ancestor pause/resume +export default test({ + test({ assert, target, raf }) { + const [remove, fetch] = target.querySelectorAll('button'); + + raf.tick(200); + assert.equal(target.querySelectorAll('.item').length, 3); + + flushSync(() => remove.click()); + + flushSync(() => fetch.click()); + raf.tick(30); + + flushSync(() => fetch.click()); + raf.tick(2000); + + const items = [...target.querySelectorAll('.item')].map((el) => el.textContent); + assert.deepEqual(items, ['a', 'c']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-each/main.svelte b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-each/main.svelte new file mode 100644 index 0000000000..36e685e3c8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-each/main.svelte @@ -0,0 +1,19 @@ + + + + + +{#if fetching} +

loading

+{:else} +
+ {#each items as item (item)} +
{item}
+ {/each} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-flipback/_config.js b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-flipback/_config.js new file mode 100644 index 0000000000..a8e5e99bbf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-flipback/_config.js @@ -0,0 +1,24 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +// #15604 companion — flipping the condition back while the ancestor is paused must still revive the element +export default test({ + test({ assert, target, raf }) { + const [toggle, fetch] = target.querySelectorAll('button'); + + raf.tick(200); + assert.ok(target.querySelector('.red')); + + flushSync(() => toggle.click()); + + flushSync(() => fetch.click()); + raf.tick(30); + + flushSync(() => toggle.click()); + + flushSync(() => fetch.click()); + raf.tick(2000); + + assert.ok(target.querySelector('.red')); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-flipback/main.svelte b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-flipback/main.svelte new file mode 100644 index 0000000000..71a3ea2090 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-global-nested-resume-flipback/main.svelte @@ -0,0 +1,19 @@ + + + + + +{#if fetching} +

loading

+{:else} +
+ {#if shown} +
red
+ {/if} +
+{/if}