From b08aeee3cfaf923e670d4d23d0b62bba3948db8d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 22 Sep 2026 10:51:23 +0200 Subject: [PATCH] fix more edge cases --- .../internal/client/dom/blocks/branches.js | 11 +++++ .../src/internal/client/reactivity/batch.js | 16 +++++-- .../_config.js | 38 +++++++++++++++ .../main.svelte | 48 +++++++++++++++++++ .../_config.js | 42 ++++++++++++++++ .../main.svelte | 43 +++++++++++++++++ .../async-merge-branch-reselection/_config.js | 31 ++++++++++++ .../main.svelte | 24 ++++++++++ .../Child.svelte | 10 ++++ .../_config.js | 47 ++++++++++++++++++ .../main.svelte | 31 ++++++++++++ 11 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/main.svelte diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index ed55b39974..ffc42fe5ad 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -190,6 +190,17 @@ export class BranchManager { var defer = should_defer_append(); var first = false; + // Re-evaluating in the surviving batch supersedes selections made before a merge, + // even though those batches originally had newer IDs and still have commit callbacks. + for (const previous of this.#batches.keys()) { + for (let merged = previous.merged_into; merged !== null; merged = merged.merged_into) { + if (merged === batch) { + this.#batches.delete(previous); + break; + } + } + } + if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) { first = true; diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 80176f3184..dfb8117b65 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -720,6 +720,11 @@ export class Batch { } } + this.#pending += batch.#pending; + for (const [effect, count] of batch.#blocking_pending) { + this.#blocking_pending.set(effect, (this.#blocking_pending.get(effect) ?? 0) + count); + } + for (const c of batch.#commit_callbacks) { this.oncommit(() => c(batch)); } @@ -848,7 +853,7 @@ export class Batch { batch.current.clear(); batch.discard(); } else { - if (current) batch.current.set(source, current); + if (current && current[0] !== value) batch.current.set(source, current); if ( !is_derived && (!current || current[0] !== value) && @@ -862,7 +867,7 @@ export class Batch { batch.current.delete(source); const b = batch; queue_micro_task(() => { - if (b.mark(source, DIRTY)) { + if (b.linked && b.mark(source, DIRTY)) { b.flush(); } }); @@ -979,8 +984,13 @@ export class Batch { * @param {Set} dirty_effects * @param {Set} maybe_dirty_effects * @param {Set} dirty_deriveds + * @returns {void} */ transfer_effects(dirty_effects, maybe_dirty_effects, dirty_deriveds) { + if (this.merged_into) { + return this.merged_into.transfer_effects(dirty_effects, maybe_dirty_effects, dirty_deriveds); + } + for (const e of dirty_effects) { this.#dirty_effects.add(e); } @@ -1594,7 +1604,7 @@ export function fork(fn) { } else if (!is_derived) { const b = next_batch; queue_micro_task(() => { - if (b.mark(source, DIRTY)) { + if (b.linked && b.mark(source, DIRTY)) { b.flush(); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/_config.js new file mode 100644 index 0000000000..45f2a13f23 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + const [speculate, update, commit, resolve, reset] = target.querySelectorAll('button'); + + for (const action of [update, commit]) { + speculate.click(); + await tick(); + logs.length = 0; + + // Discard before the queued fork revalidation runs. It must not restart + // the async effect and abort the real world's request, whether the write + // came from a normal update or another fork's commit. + action.click(); + await tick(); + resolve.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, `${buttons}

0/1

`); + assert.deepEqual(logs, ['0/1']); + + reset.click(); + await tick(); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/main.svelte new file mode 100644 index 0000000000..197764c910 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/main.svelte @@ -0,0 +1,48 @@ + + + + + + + + +

{await load(x, y)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/_config.js new file mode 100644 index 0000000000..b2f791a40a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/_config.js @@ -0,0 +1,42 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + const [speculate, catch_up, update, commit, discard] = target.querySelectorAll('button'); + + speculate.click(); + await tick(); + catch_up.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + logs.length = 0; + + // All speculative writes are obsolete. Only the real world should react. + update.click(); + await tick(); + try { + assert.htmlEqual(target.innerHTML, `${buttons}

3

`); + assert.deepEqual(logs, ['1/1/1']); + + // Committing an automatically discarded fork should be a no-op and not throw, + // as the user cannot really know that something got automatically discarded. + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

3

`); + assert.deepEqual(logs, ['1/1/1', 'committed']); + } finally { + discard.click(); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/main.svelte new file mode 100644 index 0000000000..eeacc2c8d1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/main.svelte @@ -0,0 +1,43 @@ + + + + + + + + +

{await load(x, y, z)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js new file mode 100644 index 0000000000..c21996a615 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js @@ -0,0 +1,31 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [start_a, start_b, resolve] = target.querySelectorAll('button'); + + start_a.click(); + await tick(); + start_b.click(); + await tick(); + + // B selected the empty branch before merging into A. A's async result + // now selects the content, which B's old commit callback must not remove. + // The two blocks register A's and B's callbacks in opposite orders. + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `${buttons}

1/1

readyalso ready` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte new file mode 100644 index 0000000000..605d5d85ea --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte @@ -0,0 +1,24 @@ + + + + + + +

{x}/{result}

+{#if show && x > 0 && result > 0} + ready +{/if} +{#if x > 0 && show && result > 0} + also ready +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/Child.svelte new file mode 100644 index 0000000000..15ca4b6127 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/Child.svelte @@ -0,0 +1,10 @@ + + +{result} diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/_config.js new file mode 100644 index 0000000000..9ed25aba6a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/_config.js @@ -0,0 +1,47 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + const [start_a, start_b, resolve_a, resolve_b, reset] = target.querySelectorAll('button'); + + for (const boundary_first of [false, true]) { + logs.length = 0; + start_a.click(); + await tick(); + start_b.click(); + await tick(); + + // B's pending boundary must transfer effects to A regardless of whether + // it resolves before or after A renders. + if (boundary_first) { + resolve_b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + assert.deepEqual(logs, []); + resolve_a.click(); + } else { + resolve_a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

pending`); + resolve_b.click(); + } + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

ready`); + assert.deepEqual(logs, ['ready']); + + reset.click(); + await tick(); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/main.svelte new file mode 100644 index 0000000000..b38500ee63 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + +

{await delay_a(x)}

+{#if show && x > 0} + + {#snippet pending()}pending{/snippet} + + +{/if}