Skip to content

Commit 28765f8

Browse files
authored
fix: don't rerun async effects unnecessarily (#16944)
Since #16866, when an async effect runs multiple times, we rebase older batches and rerun those effects. This can have unintended consequences: In a case where an async effect only depends on a single source, and that single source was updated in a later batch, we know that we don't need to / should not rerun the older batch. This PR makes it so: We collect all the sources of older batches that are not part of the current batch that just committed, and then only mark those async effects as dirty which depend on one of those other sources. Fixes the bug I noticed while working on #16935
1 parent d50701d commit 28765f8

File tree

4 files changed

+65
-17
lines changed

4 files changed

+65
-17
lines changed

.changeset/wild-mirrors-take.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: don't rerun async effects unnecessarily

packages/svelte/src/internal/client/reactivity/batch.js

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { Derived, Effect, Source, Value } from '#client' */
1+
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
22
import {
33
BLOCK_EFFECT,
44
BRANCH_EFFECT,
@@ -342,31 +342,46 @@ export class Batch {
342342
continue;
343343
}
344344

345+
/** @type {Source[]} */
346+
const sources = [];
347+
345348
for (const [source, value] of this.current) {
346349
if (batch.current.has(source)) {
347-
if (is_earlier) {
350+
if (is_earlier && value !== batch.current.get(source)) {
348351
// bring the value up to date
349352
batch.current.set(source, value);
350353
} else {
351-
// later batch has more recent value,
354+
// same value or later batch has more recent value,
352355
// no need to re-run these effects
353356
continue;
354357
}
355358
}
356359

357-
mark_effects(source);
360+
sources.push(source);
358361
}
359362

360-
if (queued_root_effects.length > 0) {
361-
current_batch = batch;
362-
batch.apply();
363+
if (sources.length === 0) {
364+
continue;
365+
}
363366

364-
for (const root of queued_root_effects) {
365-
batch.#traverse_effect_tree(root);
367+
// Re-run async/block effects that depend on distinct values changed in both batches
368+
const others = [...batch.current.keys()].filter((s) => !this.current.has(s));
369+
if (others.length > 0) {
370+
for (const source of sources) {
371+
mark_effects(source, others);
366372
}
367373

368-
queued_root_effects = [];
369-
batch.deactivate();
374+
if (queued_root_effects.length > 0) {
375+
current_batch = batch;
376+
batch.apply();
377+
378+
for (const root of queued_root_effects) {
379+
batch.#traverse_effect_tree(root);
380+
}
381+
382+
queued_root_effects = [];
383+
batch.deactivate();
384+
}
370385
}
371386
}
372387

@@ -621,24 +636,46 @@ function flush_queued_effects(effects) {
621636

622637
/**
623638
* This is similar to `mark_reactions`, but it only marks async/block effects
624-
* so that these can re-run after another batch has been committed
639+
* depending on `value` and at least one of the other `sources`, so that
640+
* these effects can re-run after another batch has been committed
625641
* @param {Value} value
642+
* @param {Source[]} sources
626643
*/
627-
function mark_effects(value) {
644+
function mark_effects(value, sources) {
628645
if (value.reactions !== null) {
629646
for (const reaction of value.reactions) {
630647
const flags = reaction.f;
631648

632649
if ((flags & DERIVED) !== 0) {
633-
mark_effects(/** @type {Derived} */ (reaction));
634-
} else if ((flags & (ASYNC | BLOCK_EFFECT)) !== 0) {
650+
mark_effects(/** @type {Derived} */ (reaction), sources);
651+
} else if ((flags & (ASYNC | BLOCK_EFFECT)) !== 0 && depends_on(reaction, sources)) {
635652
set_signal_status(reaction, DIRTY);
636653
schedule_effect(/** @type {Effect} */ (reaction));
637654
}
638655
}
639656
}
640657
}
641658

659+
/**
660+
* @param {Reaction} reaction
661+
* @param {Source[]} sources
662+
*/
663+
function depends_on(reaction, sources) {
664+
if (reaction.deps !== null) {
665+
for (const dep of reaction.deps) {
666+
if (sources.includes(dep)) {
667+
return true;
668+
}
669+
670+
if ((dep.f & DERIVED) !== 0 && depends_on(/** @type {Derived} */ (dep), sources)) {
671+
return true;
672+
}
673+
}
674+
}
675+
676+
return false;
677+
}
678+
642679
/**
643680
* @param {Effect} signal
644681
* @returns {void}

packages/svelte/src/internal/client/reactivity/deriveds.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,13 @@ export function async_derived(fn, location) {
171171

172172
internal_set(signal, value);
173173

174+
// All prior async derived runs are now stale
175+
for (const [b, d] of deferreds) {
176+
deferreds.delete(b);
177+
if (b === batch) break;
178+
d.reject(STALE_REACTION);
179+
}
180+
174181
if (DEV && location !== undefined) {
175182
recent_async_deriveds.add(signal);
176183

packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ export default test({
2020
input.value = '12';
2121
input.dispatchEvent(new Event('input', { bubbles: true }));
2222
await macrotask(6);
23-
// TODO this is wrong (separate bug), this should be 3 | 12
24-
assert.htmlEqual(target.innerHTML, '<input> 5 | 12');
23+
assert.htmlEqual(target.innerHTML, '<input> 3 | 12');
2524
}
2625
});

0 commit comments

Comments
 (0)