From 036a8b6221896e72604907b0be992b6bfe4062da Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 6 May 2026 17:34:16 +0200 Subject: [PATCH] fix: wait with resolving prior batches until current one commits Tweaks the timing when we resolve earlier/outdated batches. Instead of doing that right away we only do it once the current batch is committed. That way - the old batch will not render right away in case this was the last pending value, while the current batch is still pending, causing tearing (because we partly render values of the current batch in the old batch already) - the old batch still has a chance to resolve itself in the meantime if the current batch is still pending in other areas --- .../src/internal/client/reactivity/batch.js | 9 +++++ .../internal/client/reactivity/deriveds.js | 20 +++++++++-- .../samples/async-stale-derived-6/_config.js | 34 +++++++++++++++++++ .../samples/async-stale-derived-6/main.svelte | 21 ++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 0d97ac1524..8c71800e5e 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -107,6 +107,13 @@ export class Batch { */ previous = new Map(); + /** + * Async effects which this batch doesn't take into account anymore when calculating blockers, + * as it has a value for it already. + * @type {Set} + */ + unblocked = new Set(); + /** * When the batch is committed (and the DOM is updated), we need to remove old branches * and append new ones by calling the functions added inside (if/each/key/etc) blocks @@ -198,6 +205,8 @@ export class Batch { #is_blocked() { for (const batch of this.#blockers) { for (const effect of batch.#blocking_pending.keys()) { + if (this.unblocked.has(effect)) continue; + var skipped = false; var e = effect; diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 7681bebd3b..f9b5f4add1 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -120,7 +120,7 @@ export function async_derived(fn, label, location) { var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined)); var signal = source(/** @type {V} */ (UNINITIALIZED)); - if (DEV) signal.label = label; + if (DEV) signal.label = label ?? fn.toString(); // only suspend in async deriveds created on initialisation var should_suspend = !active_reaction; @@ -213,6 +213,10 @@ export function async_derived(fn, label, location) { decrement_pending?.(); + if (error === OBSOLETE || (effect.f & DESTROYED) !== 0) { + return; + } + batch.activate(); if (error) { @@ -229,8 +233,18 @@ export function async_derived(fn, label, location) { // All prior async derived runs are now stale for (const [b, d] of deferreds) { - if (b.id <= batch.id) deferreds.delete(b); - if (b.id < batch.id) d.resolve(value); + if (b.id === batch.id) deferreds.delete(b); + if (b.id < batch.id) { + // Don't delete + resolve directly, instead only do that once + // the current batch commits. This way we avoid tearing when + // `b` is rendering through the early resolve while `batch` is + // still pending. + batch.unblocked.add(effect); + batch.oncommit(() => { + deferreds.delete(b); + d.resolve(value); + }); + } } if (DEV && location !== undefined) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js new file mode 100644 index 0000000000..5b951d6c49 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [button1, button2, pop, shift] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + button1.click(); + await tick(); + button2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + + shift.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte new file mode 100644 index 0000000000..4515e7a488 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +

{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}