Skip to content

Commit 7872d3b

Browse files
committed
fix: don't rerun async effects unnecessarily
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 23e2bb3 commit 7872d3b

File tree

4 files changed

+58
-18
lines changed

4 files changed

+58
-18
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: 45 additions & 16 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,
@@ -335,31 +335,43 @@ export class Batch {
335335
continue;
336336
}
337337

338+
/** @type {Source[]} */
339+
const sources = [];
340+
338341
for (const [source, value] of this.current) {
339342
if (batch.current.has(source)) {
340-
if (is_earlier) {
343+
if (is_earlier && value !== batch.current.get(source)) {
341344
// bring the value up to date
342345
batch.current.set(source, value);
343346
} else {
344-
// later batch has more recent value,
347+
// same value or later batch has more recent value,
345348
// no need to re-run these effects
346349
continue;
347350
}
348351
}
349352

350-
mark_effects(source);
353+
sources.push(source);
351354
}
352355

353-
if (queued_root_effects.length > 0) {
354-
current_batch = batch;
355-
const revert = Batch.apply(batch);
356-
357-
for (const root of queued_root_effects) {
358-
batch.#traverse_effect_tree(root);
356+
// Only reschedule an async effect if it depends on something else than
357+
// the sources that were updated in this batch and that something else changed.
358+
const others = [...batch.current.keys()].filter((s) => !this.current.has(s));
359+
if (others.length > 0) {
360+
for (const source of sources) {
361+
mark_effects(source, others);
359362
}
360363

361-
queued_root_effects = [];
362-
revert();
364+
if (queued_root_effects.length > 0) {
365+
current_batch = batch;
366+
const revert = Batch.apply(batch);
367+
368+
for (const root of queued_root_effects) {
369+
batch.#traverse_effect_tree(root);
370+
}
371+
372+
queued_root_effects = [];
373+
revert();
374+
}
363375
}
364376
}
365377

@@ -640,17 +652,34 @@ function flush_queued_effects(effects) {
640652

641653
/**
642654
* This is similar to `mark_reactions`, but it only marks async/block effects
643-
* so that these can re-run after another batch has been committed
655+
* depending on one of the sources, so that these effects can re-run after
656+
* another batch has been committed
644657
* @param {Value} value
658+
* @param {Source[]} sources
645659
*/
646-
function mark_effects(value) {
660+
function mark_effects(value, sources) {
647661
if (value.reactions !== null) {
662+
/** @param {Reaction} reaction */
663+
const depends_on_sources = (reaction) => {
664+
if (reaction.deps !== null) {
665+
for (const dep of reaction.deps) {
666+
if (sources.includes(dep)) {
667+
return true;
668+
}
669+
if ((dep.f & DERIVED) !== 0 && depends_on_sources(/** @type {Derived} */ (dep))) {
670+
return true;
671+
}
672+
}
673+
}
674+
return false;
675+
};
676+
648677
for (const reaction of value.reactions) {
649678
const flags = reaction.f;
650679

651680
if ((flags & DERIVED) !== 0) {
652-
mark_effects(/** @type {Derived} */ (reaction));
653-
} else if ((flags & (ASYNC | BLOCK_EFFECT)) !== 0) {
681+
mark_effects(/** @type {Derived} */ (reaction), sources);
682+
} else if ((flags & (ASYNC | BLOCK_EFFECT)) !== 0 && depends_on_sources(reaction)) {
654683
set_signal_status(reaction, DIRTY);
655684
schedule_effect(/** @type {Effect} */ (reaction));
656685
}

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)