fix: reject pending async deriveds on discard (#18308)

The rejects all async deriveds of a batch as `OBSOLETE`, so they don't
hang around and bail early without triggering the batch.

If we don't do this, an async derived can trigger the already done
batch, which schedules an effect that is never flushed. Because it is
never flushed the branches it touched on its way up are never cleared,
and so anything else in that subtree is now unreactive.
rolldown
Simon H 3 months ago committed by GitHub
parent 3ef761b87b
commit a81f96549d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: reject pending async deriveds on discard

@ -41,6 +41,7 @@ import { set_signal_status } from './status.js';
import { legacy_is_updating_store } from './store.js'; import { legacy_is_updating_store } from './store.js';
import { invariant } from '../../shared/dev.js'; import { invariant } from '../../shared/dev.js';
import { log_effect_tree } from '../dev/debug.js'; import { log_effect_tree } from '../dev/debug.js';
import { OBSOLETE } from './deriveds.js';
/** @type {Batch | null} */ /** @type {Batch | null} */
let first_batch = null; let first_batch = null;
@ -511,6 +512,10 @@ export class Batch {
if (d) deferred.promise.then(d.resolve).catch(d.reject); if (d) deferred.promise.then(d.resolve).catch(d.reject);
} }
// Clear them or else those that are still pending might get rejected on discard (after merged-into batch is done).
// This can happen when batch Y merged into X and Y has a pending boundary and therefore still-pending async deriveds inside.
batch.async_deriveds.clear();
// Mark is not guaranteed not touch these, so we transfer them // Mark is not guaranteed not touch these, so we transfer them
this.transfer_effects(batch.#dirty_effects, batch.#maybe_dirty_effects); this.transfer_effects(batch.#dirty_effects, batch.#maybe_dirty_effects);
@ -629,6 +634,10 @@ export class Batch {
for (const fn of this.#discard_callbacks) fn(this); for (const fn of this.#discard_callbacks) fn(this);
this.#discard_callbacks.clear(); this.#discard_callbacks.clear();
for (const deferred of this.async_deriveds.values()) {
deferred.reject(OBSOLETE);
}
this.#unlink(); this.#unlink();
this.#deferred?.resolve(); this.#deferred?.resolve();
} }
@ -677,12 +686,15 @@ export class Batch {
} }
} }
if (!batch.#started) continue; var current = [...batch.current.keys()].filter(
(source) => !(/** @type {[any, boolean]} */ (batch.current.get(source))[1])
);
// If not started yet or no sources to update (which is e.g. possible for the very first batch) then bail
if (!batch.#started || current.length === 0) continue;
// Re-run async/block effects that depend on distinct values changed in both batches (ignoring deriveds) // Re-run async/block effects that depend on distinct values changed in both batches (ignoring deriveds)
var others = [...batch.current.keys()].filter( var others = current.filter((source) => !this.current.has(source));
(s) => !(/** @type {[any, boolean]} */ (batch.current.get(s))[1]) && !this.current.has(s)
);
if (others.length === 0) { if (others.length === 0) {
if (is_earlier) { if (is_earlier) {

@ -0,0 +1,39 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
await tick();
const [fork, real, resolve] = target.querySelectorAll('button');
fork.click();
await tick();
resolve.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
0
<button>fork</button>
<button>real</button>
<button>resolve</button>
`
);
assert.deepEqual(logs, [0]);
real.click();
await tick();
resolve.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
1
<button>fork</button>
<button>real</button>
<button>resolve</button>
`
);
assert.deepEqual(logs, [0, 1]);
}
});

@ -0,0 +1,23 @@
<script>
import { fork } from "svelte";
let count = $state(0);
let queued = [];
function push(v) {
if (!v) return v;
return new Promise((resolve) => {
queued.push(() => resolve(v));
});
}
$effect(() => {
console.log(count);
})
</script>
{await push(count)}
<button onclick={() => fork(() => count++).discard()}>fork</button>
<button onclick={() => count++}>real</button>
<button onclick={() => queued.shift()?.()}>resolve</button>
Loading…
Cancel
Save