mirror of https://github.com/sveltejs/svelte
fix: don't rebase just-created batches (#18117)
It's possible to rebase just-created batches. Case A: - batch A runs effects - one of these effects writes to a source. This creates a new batch B - an effect _after_ that (still part of "flush effects of batch A") executes a derived. This creates an entry in the `current` Map in batch B - batch A commits after processing batch B (`next_batch` etc logic), batch B is pending. Due to derived being part of batchB.current batch A can wrongfully think these are connected and try to rerun/add effects etc on batch B Case B: - like case A but with an additional await inside a pending snippet Case C: - batch A with source a and b, it flushes effects - one of these effects schedules batch B with b and c scheduling an async effect - batch B is deferred - batch A commits. Due to the a/b/c partial overlap it will needlessly rerun the just scheduled async effect All these cases are wrong. We fix it like this: 1. we call `this.#commit()` _before_ running the new batches, which may stick around due to having pending work, and we don't want to rebase these. This fixes case A and C 2. we capture derived values in `previous_batch` if it exists, because it means we're currently flushing effects, and derived writes belong to that batch and not a new one that might have been scheduled already. This fixes case B Discovered this while working on #18097 --------- Co-authored-by: Rich Harris <rich.harris@vercel.com>pull/18163/head
parent
7719a74eef
commit
9521b9f3dc
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: don't rebase just-created batches
|
||||
@ -0,0 +1,27 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(),
|
||||
// rescheduling an effect on the new batch that shouldn't run.
|
||||
export default test({
|
||||
async test({ assert, target, logs }) {
|
||||
await tick();
|
||||
const [increment, resolve] = target.querySelectorAll('button');
|
||||
|
||||
increment.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, []);
|
||||
|
||||
// This resolve
|
||||
// - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing
|
||||
// - shouldn't result in #commit() rebasing the new batch
|
||||
resolve.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, [2]);
|
||||
|
||||
// As a result, this resolve shouldn't result in another execution of the effect depending on the derived
|
||||
resolve.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, [2]);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let double = $derived(count * 2);
|
||||
let count_mirror = $state(0);
|
||||
|
||||
const queued = [];
|
||||
function delay(v) {
|
||||
if (!v) return v;
|
||||
return new Promise(resolve => {
|
||||
queued.push(() => resolve(v));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={() => count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)}</button>
|
||||
<button onclick={() => queued.shift()?.()}>resolve</button>
|
||||
|
||||
{#if count}
|
||||
<!-- inside if block so effects are newly created and therefore added to batch.#new_effects -->
|
||||
<!-- first $effect creates new batch ... -->
|
||||
{(() => {
|
||||
$effect(() => {
|
||||
count_mirror = count;
|
||||
})
|
||||
})()}
|
||||
<!-- ... which second $effect shouldn't write to because the derived execution belongs to the previous batch -->
|
||||
{(() => {
|
||||
$effect(() => {
|
||||
console.log(double);
|
||||
})
|
||||
})()}
|
||||
{/if}
|
||||
@ -0,0 +1,25 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(),
|
||||
// rescheduling an effect on the new batch that shouldn't run.
|
||||
export default test({
|
||||
async test({ assert, target, logs }) {
|
||||
await tick();
|
||||
const [increment, resolve] = target.querySelectorAll('button');
|
||||
assert.deepEqual(logs, ['delay 0']);
|
||||
|
||||
increment.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, ['delay 0', 'delay 2']);
|
||||
|
||||
// This resolve should trigger the async effect only once
|
||||
resolve.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']);
|
||||
|
||||
resolve.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { untrack } from "svelte";
|
||||
|
||||
let a = $state(0);
|
||||
let b = $state(0);
|
||||
let c = $state(0);
|
||||
|
||||
const queued = [];
|
||||
function delay(v) {
|
||||
console.log('delay ' + v);
|
||||
if (!v) return v;
|
||||
return new Promise(resolve => {
|
||||
queued.push(() => resolve(v));
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (b + c === 0 || b + c > 2) return;
|
||||
console.log('effect run')
|
||||
untrack(() => {
|
||||
b++;
|
||||
c++;
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<button onclick={() => { a++; b++; }}>increment</button>
|
||||
<button onclick={() => queued.shift()?.()}>resolve</button>
|
||||
{await delay(a + b + c)}
|
||||
@ -0,0 +1,31 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(),
|
||||
// rescheduling an effect on the new batch that shouldn't run.
|
||||
export default test({
|
||||
async test({ assert, target, logs }) {
|
||||
await tick();
|
||||
const [increment, shift, pop] = target.querySelectorAll('button');
|
||||
|
||||
increment.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, []);
|
||||
|
||||
// Resolve the blocking await which shouldn't result in the derived execution capturing
|
||||
// the new derived value on the new batch, but on the previous batch which is currently flushing
|
||||
pop.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, [2]);
|
||||
|
||||
// Resolve the non-blocking await which shouldn't result in #commit() rebasing the new batch
|
||||
shift.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, [2]);
|
||||
|
||||
// Resolve the new batch's await
|
||||
shift.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, [2]);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,37 @@
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let double = $derived(count * 2);
|
||||
let count_mirror = $state(0);
|
||||
|
||||
const queued = [];
|
||||
function delay(v) {
|
||||
if (!v) return v;
|
||||
return new Promise(resolve => {
|
||||
queued.push(() => resolve(v));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={() => count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)}</button>
|
||||
<button onclick={() => queued.shift()?.()}>shift</button>
|
||||
<button onclick={() => queued.pop()?.()}>pop</button>
|
||||
|
||||
{#if count}
|
||||
<svelte:boundary>
|
||||
{await delay(count)}
|
||||
{#snippet pending()}loading{/snippet}
|
||||
</svelte:boundary>
|
||||
<!-- inside if block so effects are newly created and therefore added to batch.#new_effects -->
|
||||
<!-- first $effect creates new batch ... -->
|
||||
{(() => {
|
||||
$effect(() => {
|
||||
count_mirror = count;
|
||||
})
|
||||
})()}
|
||||
<!-- ... which second $effect shouldn't write to because the derived execution belongs to the previous batch -->
|
||||
{(() => {
|
||||
$effect(() => {
|
||||
console.log(double);
|
||||
})
|
||||
})()}
|
||||
{/if}
|
||||
@ -0,0 +1,58 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(),
|
||||
// rescheduling an effect on the new batch that shouldn't run.
|
||||
export default test({
|
||||
async test({ assert, target, logs }) {
|
||||
await tick();
|
||||
const [increment, unrelated, resolve] = target.querySelectorAll('button');
|
||||
|
||||
increment.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, []);
|
||||
|
||||
// This resolve
|
||||
// - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing
|
||||
// - shouldn't result in #commit() rebasing the new batch
|
||||
resolve.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, [2]);
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>count 1 | count_mirror 0 | count_mirror_d 0 | unrelated 0</button>
|
||||
<button>unrelated++</button>
|
||||
<button>resolve</button>
|
||||
`
|
||||
);
|
||||
|
||||
// This resolve
|
||||
// - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing
|
||||
// - shouldn't result in #commit() rebasing the new batch
|
||||
unrelated.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, [2]);
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>count 1 | count_mirror 0 | count_mirror_d 0 | unrelated 1</button>
|
||||
<button>unrelated++</button>
|
||||
<button>resolve</button>
|
||||
`
|
||||
);
|
||||
|
||||
// As a result, this resolve shouldn't result in another execution of the effect depending on the derived
|
||||
resolve.click();
|
||||
await tick();
|
||||
assert.deepEqual(logs, [2]);
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>count 1 | count_mirror 1 | count_mirror_d 2 | unrelated 1</button>
|
||||
<button>unrelated++</button>
|
||||
<button>resolve</button>
|
||||
`
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import { untrack } from "svelte";
|
||||
|
||||
let count = $state(0);
|
||||
let double = $derived(count * 2);
|
||||
let count_mirror = $state(0);
|
||||
let unrelated = $state(0);
|
||||
let count_mirror_d = $derived(count_mirror * 2);
|
||||
|
||||
const queued = [];
|
||||
function delay(v) {
|
||||
if (!v) return v;
|
||||
return new Promise(resolve => {
|
||||
queued.push(() => resolve(v));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={() => count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)} | count_mirror_d {count_mirror_d} | unrelated {unrelated}</button>
|
||||
<button onclick={() => unrelated++}>unrelated++</button>
|
||||
<button onclick={() => queued.shift()?.()}>resolve</button>
|
||||
|
||||
{#if count}
|
||||
<!-- inside if block so effects are newly created and therefore added to batch.#new_effects -->
|
||||
<!-- first $effect creates new batch ... -->
|
||||
{(() => {
|
||||
$effect(() => {
|
||||
count_mirror = count;
|
||||
untrack(() => count_mirror_d); // execute derived; should associate value with the right batch
|
||||
})
|
||||
})()}
|
||||
<!-- ... which second $effect shouldn't write to because the derived execution belongs to the previous batch -->
|
||||
{(() => {
|
||||
$effect(() => {
|
||||
console.log(double);
|
||||
})
|
||||
})()}
|
||||
{/if}
|
||||
Loading…
Reference in new issue