make await blocks fork-aware

async-another-try-pt-3
Simon Holthausen 5 days ago
parent a6221946cf
commit 7018c09247
No known key found for this signature in database

@ -11,7 +11,9 @@ import {
hydrate_node
} from '../hydration.js';
import { queue_micro_task } from '../task.js';
import { should_defer_append } from '../operations.js';
import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js';
import { ASYNC, DESTROYED, EFFECT_PRESERVED } from '#client/constants';
import { is_runes } from '../../context.js';
import { Batch, current_batch, flushSync, is_flushing_sync } from '../../reactivity/batch.js';
import { BranchManager } from './branches.js';
@ -51,11 +53,38 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
var branches = new BranchManager(node);
block(() => {
// The latest run of the block in the real world, and in each fork. A run is superseded
// by later runs in the same world; a fork's run joins the real world once it is committed
var current = {};
/** @type {Map<Batch, {}>} */
var forks = new Map();
/** @param {Batch} batch */
var settle = (batch) => {
// committed (as opposed to discarded)?
if (!batch.is_fork && forks.has(batch)) current = /** @type {{}} */ (forks.get(batch));
forks.delete(batch);
};
var effect = block(() => {
var batch = /** @type {Batch} */ (current_batch);
var input = get_input();
var run = {};
var destroyed = false;
// Like other async work, a fork's promise resolves into the fork, so that its pending/then/catch
// state stays in the fork until it's committed — unless the block is terminal (has no dependencies),
// in which case it can't re-run and is the same in every batch (see `should_defer_append`)
var fork = should_defer_append() && batch.is_fork;
if (fork) {
forks.set(batch, run);
batch.oncommit(settle);
batch.ondiscard(settle);
} else {
current = run;
// supersedes the runs of forks that have since been committed
for (const b of forks.keys()) if (!b.is_fork) forks.delete(b);
}
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
// @ts-ignore coercing `node` to a `Comment` causes TypeScript and Prettier to fight
@ -75,22 +104,32 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
* @param {() => void} fn
*/
const resolve = (fn) => {
if (destroyed) return;
if ((effect.f & DESTROYED) !== 0 || (run !== current && forks.get(batch) !== run)) return;
resolved = true;
// We don't want to restore the previous batch here; {#await} blocks don't follow the async logic
// we have elsewhere, instead pending/resolve/fail states are each their own batch so to speak.
restore(false);
// ...but it might still be set here. That means a `save(...)` has restored it — but that batch will
// likely already have been committed by the time it resolves, and this resolve should be processed
// in a separate batch. We're not using batch.deactivate()/activate() above because get_input()
// could write to sources, which would then incorrectly create a new batch or could mess with
// async_derived expecting a current_batch to exist.
if (current_batch === batch) {
batch.deactivate();
// a committed fork's run is now the real world's run (no-op for real runs)
if (!batch.is_fork) current = run;
// A fork's promise resolves into the fork — and once committed, into the batch the fork became (or was
// merged into) while that is still pending, so that it lands along with the rest of the fork's state
restore(fork);
var pending = fork && /** @type {Batch} */ (current_batch).linked;
if (!pending) {
// Otherwise, we don't want to restore the previous batch here; {#await} blocks don't follow the async
// logic we have elsewhere, instead pending/resolve/fail states are each their own batch so to speak.
// ...but it might still be set here. That means a `save(...)` has restored it — but that batch will
// likely already have been committed by the time it resolves, and this resolve should be processed
// in a separate batch. We're not using batch.deactivate()/activate() above because get_input()
// could write to sources, which would then incorrectly create a new batch or could mess with
// async_derived expecting a current_batch to exist.
if (fork || current_batch === batch) {
current_batch?.deactivate();
}
}
// Make sure we have a batch, since the branch manager expects one to exist
Batch.ensure();
var target = Batch.ensure();
try {
fn();
@ -98,8 +137,10 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
unset_context(false);
// without this, the DOM does not update until two ticks after the promise
// resolves, which is unexpected behaviour (and somewhat irksome to test)
if (!is_flushing_sync) flushSync();
// resolves, which is unexpected behaviour (and somewhat irksome to test).
// A pending batch is flushed directly, so that no other work runs while it is active
if (pending) target.flush();
else if (!is_flushing_sync) flushSync();
}
};
@ -116,8 +157,8 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
branches.ensure(CATCH, catch_fn && ((target) => catch_fn(target, error)));
if (!catch_fn) {
// Rethrow the error if no catch block exists
throw error.v;
// Rethrow the error if no catch block exists (in a fork, `error.v` isn't it)
throw e;
}
});
}
@ -145,9 +186,11 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
// continue in hydration mode
set_hydrating(true);
}
}, EFFECT_PRESERVED); // branches are created asynchronously: keep the effect even without dependencies
return () => {
destroyed = true;
};
});
// Like the results of async deriveds, the value/error are those of the batch whose run produced
// them: the real world writing them must not overtake a fork's own result (see `Batch#overtake`)
value.e = error.e = effect;
value.f |= ASYNC;
error.f |= ASYNC;
}

@ -0,0 +1,63 @@
import { fork, tick } from 'svelte';
import { test } from '../../test';
// A fork that is committed while it still waits for other async work (here: `<p>`) is a pending real batch:
// its {#await} promise is the real world's promise from then on, and as long as the batch is pending,
// the promise's states land along with the rest of the fork's state. Once the batch is done, they
// are their own batches again, like for any real update
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
instance.settle('p', 0);
await tick();
instance.settle('aw', 0);
await tick();
assert.htmlEqual(target.innerHTML, '<p>0</p><q>0</q>');
// resolves before the batch is done; the real world's older promise is obsolete once the fork is committed
instance.set({ a: 1 });
await tick();
assert.htmlEqual(target.innerHTML, '<p>0</p><i>pending</i>');
let f = fork(() => instance.set({ b: 10 }));
await tick();
f.commit();
await tick();
instance.settle('aw', 11);
await tick();
assert.htmlEqual(target.innerHTML, '<p>0</p><i>pending</i>');
instance.settle('aw', 1);
await tick();
assert.htmlEqual(target.innerHTML, '<p>0</p><i>pending</i>');
instance.settle('p', 10);
await tick();
assert.htmlEqual(target.innerHTML, '<p>10</p><q>11</q>');
// resolves after the batch is done
f = fork(() => instance.set({ b: 20 }));
await tick();
f.commit();
await tick();
assert.htmlEqual(target.innerHTML, '<p>10</p><q>11</q>');
instance.settle('p', 20);
await tick();
assert.htmlEqual(target.innerHTML, '<p>20</p><i>pending</i>');
instance.settle('aw', 21);
await tick();
assert.htmlEqual(target.innerHTML, '<p>20</p><q>21</q>');
// a real update after the commit supersedes the fork's promise
f = fork(() => instance.set({ b: 30 }));
await tick();
f.commit();
await tick();
instance.set({ a: 2 });
await tick();
instance.settle('aw', 31);
await tick();
instance.settle('aw', 32);
await tick();
instance.settle('p', 30);
await tick();
assert.htmlEqual(target.innerHTML, '<p>30</p><q>32</q>');
}
});

@ -0,0 +1,29 @@
<script>
let a = $state(0);
let b = $state(0);
/** @type {Map<string, Array<() => void>>} */
const registry = new Map();
function f(tag, value) {
return new Promise((resolve) => {
const key = tag + value;
if (!registry.has(key)) registry.set(key, []);
registry.get(key).push(() => resolve(value));
});
}
export function set(values) {
if ('a' in values) a = values.a;
if ('b' in values) b = values.b;
}
/** resolve the promises created for `tag` and `value` */
export function settle(tag, value) {
for (const r of registry.get(tag + value) ?? []) r();
registry.delete(tag + value);
}
</script>
<p>{await f('p', b)}</p>
{#await f('aw', a + b)}<i>pending</i>{:then v}<q>{v}</q>{/await}

@ -0,0 +1,40 @@
import { fork, tick } from 'svelte';
import { test } from '../../test';
// Resolving a fork's {#await} promise must not interfere with real work happening at the same time
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
instance.settle('a', 0);
instance.settle('b', 0);
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q><s>0|0</s>');
// the real update's pending state is queued while the fork's promise resolves:
// it must not end up in the fork
let f = fork(() => instance.set({ b: 1 }));
await tick();
instance.set({ a: 1 });
instance.settle('b', 1);
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i><s>0|1</s>');
f.discard();
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i><s>0|1</s>');
instance.settle('a', 1);
await tick();
assert.htmlEqual(target.innerHTML, '<q>1</q><s>0|1</s>');
// a real update right after the fork's promise resolved (in the fork) must still show
f = fork(() => instance.set({ b: 2 }));
await tick();
instance.latest('b').then(() => instance.set({ a: 5 }));
instance.settle('b', 2);
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i><s>0|5</s>');
f.discard();
instance.settle('a', 5);
await tick();
assert.htmlEqual(target.innerHTML, '<q>5</q><s>0|5</s>');
}
});

@ -0,0 +1,38 @@
<script>
let a = $state(0);
let b = $state(0);
/** @type {Map<string, Array<() => void>>} */
const registry = new Map();
/** @type {Map<string, Promise<number>>} */
const promises = new Map();
function f(tag, value) {
const promise = new Promise((resolve) => {
const key = tag + value;
if (!registry.has(key)) registry.set(key, []);
registry.get(key).push(() => resolve(value));
});
promises.set(tag, promise);
return promise;
}
export function set(values) {
if ('a' in values) a = values.a;
if ('b' in values) b = values.b;
}
/** resolve the promises created for `tag` and `value` */
export function settle(tag, value) {
for (const r of registry.get(tag + value) ?? []) r();
registry.delete(tag + value);
}
/** the latest promise created for `tag` */
export function latest(tag) {
return promises.get(tag);
}
</script>
{#await f('a', a)}<i>pending</i>{:then v}<q>{v}</q>{/await}
{#await f('b', b)}<i>pending</i>{:then v}<s>{v}|{a}</s>{/await}

@ -0,0 +1,136 @@
import { fork, tick } from 'svelte';
import { test } from '../../test';
// The pending state of a fork's {#await} promise, and its result, must not show in the real world unless
// the fork is committed. Real updates must keep working while a fork is alive, and a promise of the real
// world must still resolve in the real world when a fork re-runs the block
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(0);
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
// pending and resolved in the fork, then discarded
let f = fork(() => instance.set({ b: 1 }));
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
instance.settle(1);
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
f.discard();
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
// rejected in the fork, then discarded
f = fork(() => instance.set({ b: -1 }));
instance.settle(-1);
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
f.discard();
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
// For comparison, a real update: pending, then resolved
instance.set({ b: 2 });
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(2);
await tick();
assert.htmlEqual(target.innerHTML, '<q>2</q>');
// committed while pending: shows the pending state, just like a real update would
f = fork(() => instance.set({ b: 3 }));
await tick();
assert.htmlEqual(target.innerHTML, '<q>2</q>');
await f.commit();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(3);
await tick();
assert.htmlEqual(target.innerHTML, '<q>3</q>');
// a real update while the fork is pending (a + b: real 4 + 3, fork 4 + 10)
f = fork(() => instance.set({ b: 10 }));
await tick();
instance.set({ a: 4 });
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(14);
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(7);
await tick();
assert.htmlEqual(target.innerHTML, '<q>7</q>');
instance.settle(10); // the fork's obsolete promise (from before it saw a = 4)
await tick();
assert.htmlEqual(target.innerHTML, '<q>7</q>');
await f.commit();
assert.htmlEqual(target.innerHTML, '<q>14</q>');
// a real update while the fork is pending, then discarded (real 5 + 10, fork 5 + 20)
f = fork(() => instance.set({ b: 20 }));
await tick();
instance.set({ a: 5 });
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(15);
instance.settle(25);
await tick();
assert.htmlEqual(target.innerHTML, '<q>15</q>');
f.discard();
await tick();
assert.htmlEqual(target.innerHTML, '<q>15</q>');
instance.settle(24);
// a real promise that is still pending when a fork re-runs the block must resolve in the real world
instance.set({ a: 6 });
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
f = fork(() => instance.set({ b: 30 }));
await tick();
instance.settle(16);
await tick();
assert.htmlEqual(target.innerHTML, '<q>16</q>');
f.discard();
instance.settle(36);
await tick();
assert.htmlEqual(target.innerHTML, '<q>16</q>');
// ...also when the fork's promise resolves first, and the fork is committed afterwards
instance.set({ a: 7 });
await tick();
f = fork(() => instance.set({ b: 40 }));
instance.settle(47);
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(17);
await tick();
assert.htmlEqual(target.innerHTML, '<q>17</q>');
await f.commit();
assert.htmlEqual(target.innerHTML, '<q>47</q>');
// ...but once the fork is committed, the real world's older promise is obsolete
instance.set({ a: 8 });
await tick();
f = fork(() => instance.set({ b: 50 }));
await tick();
await f.commit();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(48);
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
instance.settle(58);
await tick();
assert.htmlEqual(target.innerHTML, '<q>58</q>');
// rejected in the fork, then committed
f = fork(() => instance.set({ b: -60 }));
instance.settle(-52);
await tick();
assert.htmlEqual(target.innerHTML, '<q>58</q>');
await f.commit();
assert.htmlEqual(target.innerHTML, '<s>-52</s>');
}
});

@ -0,0 +1,27 @@
<script>
let a = $state(0);
let b = $state(0);
/** @type {Map<number, Array<() => void>>} */
const registry = new Map();
function f(value) {
return new Promise((resolve, reject) => {
if (!registry.has(value)) registry.set(value, []);
registry.get(value).push(() => (value < 0 ? reject(value) : resolve(value)));
});
}
export function set(values) {
if ('a' in values) a = values.a;
if ('b' in values) b = values.b;
}
/** resolve the promises created for `value` */
export function settle(value) {
for (const r of registry.get(value) ?? []) r();
registry.delete(value);
}
</script>
{#await f(a + b)}<i>pending</i>{:then v}<q>{v}</q>{:catch e}<s>{e}</s>{/await}

@ -0,0 +1,23 @@
import { fork, tick } from 'svelte';
import { test } from '../../test';
// A fork re-running an {#await} block must not make the real world's pending promise obsolete
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i>');
const f = fork(() => instance.set_b(1));
await tick();
instance.settle(0);
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
f.discard();
instance.settle(1);
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
}
});

@ -0,0 +1,25 @@
<script>
let b = $state(0);
/** @type {Map<number, Array<() => void>>} */
const registry = new Map();
function f(value) {
return new Promise((resolve) => {
if (!registry.has(value)) registry.set(value, []);
registry.get(value).push(() => resolve(value));
});
}
export function set_b(v) {
b = v;
}
/** resolve the promises created for `value` */
export function settle(value) {
for (const r of registry.get(value) ?? []) r();
registry.delete(value);
}
</script>
{#await f(b)}<i>pending</i>{:then v}<q>{v}</q>{/await}

@ -0,0 +1,25 @@
import { fork, tick } from 'svelte';
import { test } from '../../test';
// An {#await} block without dependencies can't re-run, so it is the same in every batch: if it's
// created in a fork, a real batch that adopts the fork's branch must see its states as well
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
const f = fork(() => instance.set({ show: true, x: 1 }));
await tick();
assert.htmlEqual(target.innerHTML, '<p>0</p>');
instance.set({ show: true });
await tick();
assert.htmlEqual(target.innerHTML, '<i>pending</i><p>0</p>');
instance.settle(5);
await tick();
assert.htmlEqual(target.innerHTML, '<q>5</q><p>0</p>');
f.discard();
await tick();
assert.htmlEqual(target.innerHTML, '<q>5</q><p>0</p>');
}
});

@ -0,0 +1,19 @@
<script>
let show = $state(false);
let x = $state(0);
/** @type {(value: number) => void} */
let resolve;
const promise = new Promise((r) => (resolve = r));
export function set(values) {
if ('show' in values) show = values.show;
if ('x' in values) x = values.x;
}
export function settle(value) {
resolve(value);
}
</script>
{#if show}{#await promise}<i>pending</i>{:then v}<q>{v}</q>{/await}{/if}<p>{x}</p>

@ -0,0 +1,89 @@
import { fork, tick } from 'svelte';
import { test } from '../../test';
// A fork's {#await} results (then and catch) must not end up in the real world
// unless the fork is committed
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
// resolved, then discarded
let f = fork(() => instance.set_b(2));
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
f.discard();
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
assert.equal(instance.pending(), 0); // no refetch in the real world
// rejected, then discarded
f = fork(() => instance.set_b(-1));
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
f.discard();
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
// resolved, then committed
f = fork(() => instance.set_b(3));
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>0</q>');
await f.commit();
assert.htmlEqual(target.innerHTML, '<q>3</q>');
assert.equal(instance.pending(), 0);
// rejected, then committed
f = fork(() => instance.set_b(-2));
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>3</q>');
await f.commit();
assert.htmlEqual(target.innerHTML, '<s>-2</s>');
// For comparison, a real update: without a pending snippet, the previous
// content is removed (a microtask later), and the result shows once it resolves
instance.set_b(4);
await tick();
assert.htmlEqual(target.innerHTML, '');
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>4</q>');
// committed while pending (after the pending state was reached in the fork):
// the real world is in the same state as after a real update
f = fork(() => instance.set_b(5));
await tick();
assert.htmlEqual(target.innerHTML, '<q>4</q>');
await f.commit();
assert.htmlEqual(target.innerHTML, '');
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>5</q>');
assert.equal(instance.pending(), 0);
// committed synchronously (`fork` flushes synchronously, which reaches the pending state)
f = fork(() => instance.set_b(6));
assert.htmlEqual(target.innerHTML, '<q>5</q>');
f.commit();
assert.htmlEqual(target.innerHTML, '');
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>6</q>');
assert.equal(instance.pending(), 0);
// discarded while pending, resolved afterwards
f = fork(() => instance.set_b(7));
await tick();
f.discard();
instance.resolve_all();
await tick();
assert.htmlEqual(target.innerHTML, '<q>6</q>');
assert.equal(instance.pending(), 0);
}
});

@ -0,0 +1,24 @@
<script>
let b = $state(0);
const registry = [];
function f(value) {
return new Promise((resolve, reject) =>
registry.push(() => (value < 0 ? reject(value) : resolve(value)))
);
}
export function set_b(v) {
b = v;
}
export function resolve_all() {
for (const r of registry.splice(0)) r();
}
export function pending() {
return registry.length;
}
</script>
{#await f(b) then v}<q>{v}</q>{:catch e}<s>{e}</s>{/await}
Loading…
Cancel
Save