mirror of https://github.com/sveltejs/svelte
fix: defer batch resolution until earlier intersecting batches have committed (#17162)
This fixes an awkward bug with `each` blocks containing `await`, especially keyed `each` blocks. If you do `array.push(...)` multiple times in distinct batches, something weird happens — to the `each` block, the array looks this... ```js [1] ``` ...then this... ```js [undefined, 2] ``` ...then this... ```js [undefined, undefined, 3] ``` ...and so on. That's because as far as Svelte's reactivity is concerned, what we're _really_ doing is assigning to `array[0]` then `array[1]` then `array[2]`. Those (along with `array.length`) are each backed by independent sources, which we can rewind individually when we need to 'apply' a batch. When it comes to sources that actually _are_ independent, this is useful, since we apply the changes from batch B to the DOM while we're still waiting for a promise in batch A to resolve. But in this case it's not ideal, because you would expect these changes to accumulate. In particular, this fails with keyed `each` blocks because duplicate keys are disallowed (assuming the key function doesn't break on `undefined` _before_ the duplicate check happens). This PR fixes it by stacking batches that are interconnected. Specifically, if a later batch has some (but not all) sources in common with an earlier batch, then when we apply the batch we include the sources from the earlier batch, and block it until the earlier batch commits. When the earlier batch commits, it will check to see if doing so unblocks any later batches, and if so process them. In the course of working on this I realised that `SvelteSet` and `SvelteMap` aren't async-ready — will follow up this PR with ones for those. Fixes #17050 --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>pull/17966/head
parent
7ec156a7b0
commit
0adc22c9ae
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: defer batch resolution until earlier intersecting batches have committed
|
||||
@ -0,0 +1,29 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
async test({ assert, target }) {
|
||||
await tick();
|
||||
const [a, b, resolve] = target.querySelectorAll('button');
|
||||
|
||||
a.click();
|
||||
await tick();
|
||||
b.click();
|
||||
await tick();
|
||||
resolve.click();
|
||||
await tick();
|
||||
resolve.click();
|
||||
await tick();
|
||||
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>a</button>
|
||||
<button>b</button>
|
||||
<button>resolve</button>
|
||||
hi
|
||||
1
|
||||
`
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,21 @@
|
||||
<script>
|
||||
let a = $state(0);
|
||||
let b = $state(0);
|
||||
let a_b = $derived(a * b);
|
||||
|
||||
const queued = [];
|
||||
|
||||
function push(value) {
|
||||
if (!value) return value;
|
||||
return new Promise(resolve => {
|
||||
queued.push(() => resolve(value));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={() => (a++)}>a</button>
|
||||
<button onclick={() => (b++)}>b</button>
|
||||
<button onclick={() => (queued.shift()?.())}>resolve</button>
|
||||
<!-- a_b called in a block effect before being called in an async effect -->
|
||||
{#if a_b}hi{/if}
|
||||
{await push(a_b)}
|
||||
@ -1,102 +0,0 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
skip: true,
|
||||
async test({ assert, target }) {
|
||||
const [add, shift] = target.querySelectorAll('button');
|
||||
|
||||
add.click();
|
||||
await tick();
|
||||
add.click();
|
||||
await tick();
|
||||
add.click();
|
||||
await tick();
|
||||
|
||||
// TODO pending count / number of pushes is off
|
||||
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<p>pending=6 values.length=1 values=[1]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
shift.click();
|
||||
await tick();
|
||||
shift.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<p>pending=4 values.length=2 values=[1,2]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
shift.click();
|
||||
await tick();
|
||||
shift.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<p>pending=2 values.length=3 values=[1,2,3]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
shift.click();
|
||||
await tick();
|
||||
shift.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<p>pending=0 values.length=4 values=[1,2,3,4]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,29 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
async test({ assert, target }) {
|
||||
await tick();
|
||||
|
||||
const [a, b] = target.querySelectorAll('button');
|
||||
|
||||
assert.htmlEqual(target.innerHTML, `<button>a 0</button><button>b 0</button><p>hello</p>`);
|
||||
|
||||
a.click();
|
||||
await tick();
|
||||
assert.htmlEqual(target.innerHTML, `<button>a 0</button><button>b 0</button><p>hello</p>`);
|
||||
|
||||
a.click();
|
||||
await tick();
|
||||
assert.htmlEqual(target.innerHTML, `<button>a 2</button><button>b 0</button><p>hello</p>`);
|
||||
|
||||
a.click();
|
||||
await tick();
|
||||
assert.htmlEqual(target.innerHTML, `<button>a 2</button><button>b 0</button><p>hello</p>`);
|
||||
|
||||
// if we don't skip over the never-resolving promise in the `else` block, we will never update
|
||||
b.click();
|
||||
await tick();
|
||||
assert.htmlEqual(target.innerHTML, `<button>a 3</button><button>b 1</button><p>hello</p>`);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
<script>
|
||||
let a = $state(0);
|
||||
let b = $state(0);
|
||||
let show = $state(true);
|
||||
</script>
|
||||
|
||||
<button onclick={() => (a++, show = !show)}>a {a}</button>
|
||||
<button onclick={() => (b++, show = !show)}>b {b}</button>
|
||||
|
||||
{#if show}
|
||||
<p>hello</p>
|
||||
{:else}
|
||||
{await new Promise(() => {})}
|
||||
{/if}
|
||||
@ -0,0 +1,30 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
async test({ assert, target }) {
|
||||
await tick();
|
||||
const [a_b, b, resolve] = target.querySelectorAll('button');
|
||||
|
||||
a_b.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
'<button>a_b 0_0</button> <button>b 0</button> <button>resolve</button> 0'
|
||||
);
|
||||
|
||||
b.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
'<button>a_b 0_0</button> <button>b 0</button> <button>resolve</button> 0'
|
||||
);
|
||||
|
||||
resolve.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
'<button>a_b 1_2</button> <button>b 2</button> <button>resolve</button> 1'
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,17 @@
|
||||
<script>
|
||||
let a = $state(0);
|
||||
let b = $state(0);
|
||||
let deferreds = [];
|
||||
|
||||
function push(value) {
|
||||
if (!value) return value;
|
||||
return new Promise(resolve => {
|
||||
deferreds.push(() => resolve(value));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={() => {a++;b++}}>a_b {a}_{b}</button>
|
||||
<button onclick={() => (b++)}>b {b}</button>
|
||||
<button onclick={() => (deferreds.shift()?.())}>resolve</button>
|
||||
{await push(a)}
|
||||
@ -0,0 +1,205 @@
|
||||
import { tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
async test({ assert, target }) {
|
||||
await tick();
|
||||
|
||||
const [add, shift, pop] = target.querySelectorAll('button');
|
||||
|
||||
add.click();
|
||||
await tick();
|
||||
add.click();
|
||||
await tick();
|
||||
add.click();
|
||||
await tick();
|
||||
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<button>pop</button>
|
||||
<p>pending=6 values.length=1 values=[1]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
shift.click();
|
||||
await tick();
|
||||
shift.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<button>pop</button>
|
||||
<p>pending=4 values.length=2 values=[1,2]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
shift.click();
|
||||
await tick();
|
||||
shift.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<button>pop</button>
|
||||
<p>pending=2 values.length=3 values=[1,2,3]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
shift.click();
|
||||
await tick();
|
||||
shift.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<button>pop</button>
|
||||
<p>pending=0 values.length=4 values=[1,2,3,4]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
add.click();
|
||||
await tick();
|
||||
add.click();
|
||||
await tick();
|
||||
add.click();
|
||||
await tick();
|
||||
add.click();
|
||||
await tick();
|
||||
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<button>pop</button>
|
||||
<p>pending=8 values.length=4 values=[1,2,3,4]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
// pop should have no effect until earlier promises have also resolved
|
||||
pop.click();
|
||||
await tick();
|
||||
pop.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<button>pop</button>
|
||||
<p>pending=6 values.length=4 values=[1,2,3,4]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
pop.click();
|
||||
await tick();
|
||||
pop.click();
|
||||
await tick();
|
||||
pop.click();
|
||||
await tick();
|
||||
pop.click();
|
||||
await tick();
|
||||
pop.click();
|
||||
await tick();
|
||||
pop.click();
|
||||
await tick();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>add</button>
|
||||
<button>shift</button>
|
||||
<button>pop</button>
|
||||
<p>pending=0 values.length=8 values=[1,2,3,4,5,6,7,8]</p>
|
||||
<div>not keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
<div>5</div>
|
||||
<div>6</div>
|
||||
<div>7</div>
|
||||
<div>8</div>
|
||||
</div>
|
||||
<div>keyed:
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
<div>5</div>
|
||||
<div>6</div>
|
||||
<div>7</div>
|
||||
<div>8</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in new issue