fix: wait a microtask for await blocks to reduce UI churn (#11989)

* fix: wait a microtask for await blocks to reduce UI churn

* fix: wait a microtask for await blocks to reduce UI churn

* fix: wait a microtask for await blocks to reduce UI churn

* fix bug

* Make then blocks reactive

* add test

* update test

* update test

* Update packages/svelte/src/internal/client/dom/blocks/await.js

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* Add support for catch block

* slightly more specific naming

* if we use the reserved $$ prefix we dont need to mess around with scope.generate

* omit args for then/catch if unnecessary

* neaten up some old code

* shrink code

* simplify test

* add failing test

* preserve pending blocks

* update test

* fix comment typo

* tidy up

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/12106/head
Dominic Gannaway 6 months ago committed by GitHub
parent e9e7d8b468
commit 6a3e293207
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: wait a microtask for await blocks to reduce UI churn

@ -1,5 +1,6 @@
import * as b from '../../../utils/builders.js'; import * as b from '../../../utils/builders.js';
import { import {
extract_identifiers,
extract_paths, extract_paths,
is_expression_async, is_expression_async,
is_simple_expression, is_simple_expression,
@ -684,3 +685,44 @@ export function with_loc(target, source) {
} }
return target; return target;
} }
/**
* @param {import("estree").Pattern} node
* @param {import("zimmerframe").Context<import("#compiler").SvelteNode, import("./types").ComponentClientTransformState>} context
* @returns {{ id: import("estree").Pattern, declarations: null | import("estree").Statement[] }}
*/
export function create_derived_block_argument(node, context) {
if (node.type === 'Identifier') {
return { id: node, declarations: null };
}
const pattern = /** @type {import('estree').Pattern} */ (context.visit(node));
const identifiers = extract_identifiers(node);
const id = b.id('$$source');
const value = b.id('$$value');
const block = b.block([
b.var(pattern, b.call('$.get', id)),
b.return(b.object(identifiers.map((identifier) => b.prop('init', identifier, identifier))))
]);
const declarations = [b.var(value, create_derived(context.state, b.thunk(block)))];
for (const id of identifiers) {
declarations.push(
b.var(id, create_derived(context.state, b.thunk(b.member(b.call('$.get', value), id))))
);
}
return { id, declarations };
}
/**
* Svelte legacy mode should use safe equals in most places, runes mode shouldn't
* @param {import('./types.js').ComponentClientTransformState} state
* @param {import('estree').Expression} arg
*/
export function create_derived(state, arg) {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', arg);
}

@ -21,7 +21,9 @@ import {
function_visitor, function_visitor,
get_assignment_value, get_assignment_value,
serialize_get_binding, serialize_get_binding,
serialize_set_binding serialize_set_binding,
create_derived,
create_derived_block_argument
} from '../utils.js'; } from '../utils.js';
import { import {
AttributeAliases, AttributeAliases,
@ -646,15 +648,6 @@ function collect_parent_each_blocks(context) {
); );
} }
/**
* Svelte legacy mode should use safe equals in most places, runes mode shouldn't
* @param {import('../types.js').ComponentClientTransformState} state
* @param {import('estree').Expression} arg
*/
function create_derived(state, arg) {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', arg);
}
/** /**
* @param {import('#compiler').Component | import('#compiler').SvelteComponent | import('#compiler').SvelteSelf} node * @param {import('#compiler').Component | import('#compiler').SvelteComponent | import('#compiler').SvelteSelf} node
* @param {string} component_name * @param {string} component_name
@ -2594,6 +2587,45 @@ export const template_visitors = {
AwaitBlock(node, context) { AwaitBlock(node, context) {
context.state.template.push('<!>'); context.state.template.push('<!>');
let then_block;
let catch_block;
if (node.then) {
/** @type {import('estree').Pattern[]} */
const args = [b.id('$$anchor')];
const block = /** @type {import('estree').BlockStatement} */ (context.visit(node.then));
if (node.value) {
const argument = create_derived_block_argument(node.value, context);
args.push(argument.id);
if (argument.declarations !== null) {
block.body.unshift(...argument.declarations);
}
}
then_block = b.arrow(args, block);
}
if (node.catch) {
/** @type {import('estree').Pattern[]} */
const args = [b.id('$$anchor')];
const block = /** @type {import('estree').BlockStatement} */ (context.visit(node.catch));
if (node.error) {
const argument = create_derived_block_argument(node.error, context);
args.push(argument.id);
if (argument.declarations !== null) {
block.body.unshift(...argument.declarations);
}
}
catch_block = b.arrow(args, block);
}
context.state.init.push( context.state.init.push(
b.stmt( b.stmt(
b.call( b.call(
@ -2606,28 +2638,8 @@ export const template_visitors = {
/** @type {import('estree').BlockStatement} */ (context.visit(node.pending)) /** @type {import('estree').BlockStatement} */ (context.visit(node.pending))
) )
: b.literal(null), : b.literal(null),
node.then then_block,
? b.arrow( catch_block
node.value
? [
b.id('$$anchor'),
/** @type {import('estree').Pattern} */ (context.visit(node.value))
]
: [b.id('$$anchor')],
/** @type {import('estree').BlockStatement} */ (context.visit(node.then))
)
: b.literal(null),
node.catch
? b.arrow(
node.error
? [
b.id('$$anchor'),
/** @type {import('estree').Pattern} */ (context.visit(node.error))
]
: [b.id('$$anchor')],
/** @type {import('estree').BlockStatement} */ (context.visit(node.catch))
)
: b.literal(null)
) )
) )
); );

@ -613,7 +613,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
scopes.set(node.value, value_scope); scopes.set(node.value, value_scope);
context.visit(node.value, { scope: value_scope }); context.visit(node.value, { scope: value_scope });
for (const id of extract_identifiers(node.value)) { for (const id of extract_identifiers(node.value)) {
then_scope.declare(id, 'normal', 'const'); then_scope.declare(id, 'derived', 'const');
value_scope.declare(id, 'normal', 'const'); value_scope.declare(id, 'normal', 'const');
} }
} }
@ -627,7 +627,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
scopes.set(node.error, error_scope); scopes.set(node.error, error_scope);
context.visit(node.error, { scope: error_scope }); context.visit(node.error, { scope: error_scope });
for (const id of extract_identifiers(node.error)) { for (const id of extract_identifiers(node.error)) {
catch_scope.declare(id, 'normal', 'const'); catch_scope.declare(id, 'derived', 'const');
error_scope.declare(id, 'normal', 'const'); error_scope.declare(id, 'normal', 'const');
} }
} }

@ -7,111 +7,135 @@ import {
set_current_reaction, set_current_reaction,
set_dev_current_component_function set_dev_current_component_function
} from '../../runtime.js'; } from '../../runtime.js';
import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js'; import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js';
import { INERT } from '../../constants.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { queue_micro_task } from '../task.js';
import { hydrating } from '../hydration.js';
import { set, source } from '../../reactivity/sources.js';
const PENDING = 0;
const THEN = 1;
const CATCH = 2;
/** /**
* @template V * @template V
* @param {Comment} anchor * @param {Comment} anchor
* @param {(() => Promise<V>)} get_input * @param {(() => Promise<V>)} get_input
* @param {null | ((anchor: Node) => void)} pending_fn * @param {null | ((anchor: Node) => void)} pending_fn
* @param {null | ((anchor: Node, value: V) => void)} then_fn * @param {null | ((anchor: Node, value: import('#client').Source<V>) => void)} then_fn
* @param {null | ((anchor: Node, error: unknown) => void)} catch_fn * @param {null | ((anchor: Node, error: unknown) => void)} catch_fn
* @returns {void} * @returns {void}
*/ */
export function await_block(anchor, get_input, pending_fn, then_fn, catch_fn) { export function await_block(anchor, get_input, pending_fn, then_fn, catch_fn) {
const component_context = current_component_context; var component_context = current_component_context;
/** @type {any} */
let component_function;
if (DEV) {
component_function = component_context?.function ?? null;
}
/** @type {any} */ /** @type {any} */
let input; var component_function = DEV ? component_context?.function : null;
/** @type {V | Promise<V>} */
var input;
/** @type {import('#client').Effect | null} */ /** @type {import('#client').Effect | null} */
let pending_effect; var pending_effect;
/** @type {import('#client').Effect | null} */ /** @type {import('#client').Effect | null} */
let then_effect; var then_effect;
/** @type {import('#client').Effect | null} */ /** @type {import('#client').Effect | null} */
let catch_effect; var catch_effect;
var input_source = source(/** @type {V} */ (undefined));
var error_source = source(undefined);
var resolved = false;
/** /**
* @param {(anchor: Comment, value: any) => void} fn * @param {PENDING | THEN | CATCH} state
* @param {any} value * @param {boolean} restore
*/ */
function create_effect(fn, value) { function update(state, restore) {
resolved = true;
if (restore) {
set_current_effect(effect); set_current_effect(effect);
set_current_reaction(effect); // TODO do we need both? set_current_reaction(effect); // TODO do we need both?
set_current_component_context(component_context); set_current_component_context(component_context);
if (DEV) { if (DEV) set_dev_current_component_function(component_function);
set_dev_current_component_function(component_function);
} }
var e = branch(() => fn(anchor, value));
if (DEV) { if (state === PENDING && pending_fn) {
set_dev_current_component_function(null); if (pending_effect) resume_effect(pending_effect);
else pending_effect = branch(() => pending_fn(anchor));
} }
if (state === THEN && then_fn) {
if (then_effect) resume_effect(then_effect);
else then_effect = branch(() => then_fn(anchor, input_source));
}
if (state === CATCH && catch_fn) {
if (catch_effect) resume_effect(catch_effect);
else catch_effect = branch(() => catch_fn(anchor, error_source));
}
if (state !== PENDING && pending_effect) {
pause_effect(pending_effect, () => (pending_effect = null));
}
if (state !== THEN && then_effect) {
pause_effect(then_effect, () => (then_effect = null));
}
if (state !== CATCH && catch_effect) {
pause_effect(catch_effect, () => (catch_effect = null));
}
if (restore) {
if (DEV) set_dev_current_component_function(null);
set_current_component_context(null); set_current_component_context(null);
set_current_reaction(null); set_current_reaction(null);
set_current_effect(null); set_current_effect(null);
// without this, the DOM does not update until two ticks after the promise, // without this, the DOM does not update until two ticks after the promise
// resolves which is unexpected behaviour (and somewhat irksome to test) // resolves, which is unexpected behaviour (and somewhat irksome to test)
flush_sync(); flush_sync();
}
return e;
} }
const effect = block(() => { var effect = block(() => {
if (input === (input = get_input())) return; if (input === (input = get_input())) return;
if (is_promise(input)) { if (is_promise(input)) {
const promise = /** @type {Promise<any>} */ (input); var promise = input;
if (pending_fn) { resolved = false;
if (pending_effect && (pending_effect.f & INERT) === 0) {
destroy_effect(pending_effect);
}
pending_effect = branch(() => pending_fn(anchor));
}
if (then_effect) pause_effect(then_effect);
if (catch_effect) pause_effect(catch_effect);
promise.then( promise.then(
(value) => { (value) => {
if (promise !== input) return; if (promise !== input) return;
if (pending_effect) pause_effect(pending_effect); set(input_source, value);
update(THEN, true);
if (then_fn) {
then_effect = create_effect(then_fn, value);
}
}, },
(error) => { (error) => {
if (promise !== input) return; if (promise !== input) return;
if (pending_effect) pause_effect(pending_effect); set(error_source, error);
update(CATCH, true);
if (catch_fn) {
catch_effect = create_effect(catch_fn, error);
}
} }
); );
} else {
if (pending_effect) pause_effect(pending_effect);
if (catch_effect) pause_effect(catch_effect);
if (then_fn) { if (hydrating) {
if (then_effect) { if (pending_fn) {
destroy_effect(then_effect); pending_effect = branch(() => pending_fn(anchor));
} }
} else {
then_effect = branch(() => then_fn(anchor, input)); // Wait a microtask before checking if we should show the pending state as
// the promise might have resolved by the next microtask.
queue_micro_task(() => {
if (!resolved) update(PENDING, true);
});
} }
} else {
set(input_source, input);
update(THEN, false);
} }
// Inert effects are proactively detached from the effect tree. Returning a noop // Inert effects are proactively detached from the effect tree. Returning a noop

@ -22,6 +22,7 @@ export default test({
prop3: { prop7: 'seven' }, prop3: { prop7: 'seven' },
prop4: { prop10: 'ten' } prop4: { prop10: 'ten' }
})); }));
await Promise.resolve();
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `

@ -118,9 +118,8 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<p class="then" foo="0.5">43</p> <p class="then" foo="0.5">44</p>
<p class="pending" foo="0.5">loading...</p> <p class="pending" foo="0.5">loading...</p>
<p class="then" foo="0.0">44</p>
` `
); );
@ -159,9 +158,8 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<p class="then" foo="0.6">44</p> <p class="then" foo="0.6">45</p>
<p class="pending" foo="0.4">loading...</p> <p class="pending" foo="0.4">loading...</p>
<p class="then" foo="0.0">45</p>
` `
); );
@ -169,9 +167,8 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<p class="then" foo="0.4">44</p> <p class="then" foo="0.8">45</p>
<p class="pending" foo="0.2">loading...</p> <p class="pending" foo="0.2">loading...</p>
<p class="then" foo="0.2">45</p>
` `
); );
@ -183,10 +180,8 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<p class="then" foo="0.4">44</p> <p class="then" foo="0.8">45</p>
<p class="pending" foo="0.2">loading...</p> <p class="pending" foo="0.2">loading...</p>
<p class="then" foo="0.2">45</p>
<p class="pending" foo="0.0">loading...</p>
` `
); );
@ -195,10 +190,8 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<p class="then" foo="0.3">44</p> <p class="then" foo="0.7">45</p>
<p class="pending" foo="0.1">loading...</p> <p class="pending" foo="0.3">loading...</p>
<p class="then" foo="0.1">45</p>
<p class="pending" foo="0.1">loading...</p>
` `
); );
@ -207,11 +200,8 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<p class="then" foo="0.3">44</p> <p class="then" foo="0.7">46</p>
<p class="pending" foo="0.1">loading...</p> <p class="pending" foo="0.3">loading...</p>
<p class="then" foo="0.1">45</p>
<p class="pending" foo="0.1">loading...</p>
<p class="then" foo="0.0">46</p>
` `
); );
@ -219,20 +209,12 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<p class="then" foo="0.2">44</p> <p class="then" foo="0.8">46</p>
<p class="then" foo="0.1">46</p> <p class="pending" foo="0.2">loading...</p>
` `
); );
raf.tick((time += 20)); raf.tick((time += 20));
assert.htmlEqual(
target.innerHTML,
`
<p class="then" foo="0.3">46</p>
`
);
raf.tick((time += 70));
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `

@ -183,7 +183,9 @@ async function run_test_variant(
if (str.slice(0, i).includes('logs')) { if (str.slice(0, i).includes('logs')) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log = (...args) => logs.push(...args); console.log = (...args) => {
logs.push(...args);
};
} }
if (str.slice(0, i).includes('hydrate')) { if (str.slice(0, i).includes('hydrate')) {

@ -0,0 +1,24 @@
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
const [b1, b2] = target.querySelectorAll('button');
b1.click();
await Promise.resolve();
assert.htmlEqual(
target.innerHTML,
`<p>pending</p><button>Show Promise A</button><button>Show Promise B</button>`
);
b2.click();
await Promise.resolve();
await Promise.resolve();
assert.htmlEqual(
target.innerHTML,
`<p>pending</p><button>Show Promise A</button><button>Show Promise B</button>`
);
assert.deepEqual(logs, ['rendering pending block']);
}
});

@ -0,0 +1,17 @@
<script>
const a = new Promise(() => {});
const b = new Promise(() => {});
let promise = $state(a);
</script>
{#await promise}
{console.log('rendering pending block')}
<p>pending</p>
{:then value}
{console.log('rendering then block')}
<p>then {value}</p>
{/await}
<button onclick={() => (promise = a)}>Show Promise A</button>
<button onclick={() => (promise = b)}>Show Promise B</button>

@ -0,0 +1,21 @@
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
const [b1, b2, b3, b4] = target.querySelectorAll('button');
b1.click();
await Promise.resolve();
b2.click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
b3.click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
b4.click();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(logs, ['pending', 'a', 'b', 'c', 'pending']);
}
});

@ -0,0 +1,21 @@
<script>
const promise_a = Promise.resolve('a');
const promise_b = Promise.resolve('b');
const promise_c = Promise.resolve('c');
const promise_d = new Promise(() => {});
let current_promise = $state(promise_a);
</script>
{#await current_promise}
{console.log('pending')}
{:then value}
{console.log(value)}
{:catch}
{console.log('error')}
{/await}
<button onclick={()=>{current_promise = promise_a}}>Show Promise A</button>
<button onclick={()=>{current_promise = promise_b}}>Show Promise B</button>
<button onclick={()=>{current_promise = promise_c}}>Show Promise C</button>
<button onclick={()=>{current_promise = promise_d}}>Show Promise D</button>

@ -0,0 +1,27 @@
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
const [b1, b2] = target.querySelectorAll('button');
b1.click();
await Promise.resolve();
assert.htmlEqual(
target.innerHTML,
`<p>then a</p><button>Show Promise A</button><button>Show Promise B</button>`
);
b2.click();
await Promise.resolve();
assert.htmlEqual(
target.innerHTML,
`<p>then a</p><button>Show Promise A</button><button>Show Promise B</button>`
);
await Promise.resolve();
await Promise.resolve();
assert.htmlEqual(
target.innerHTML,
`<p>then b</p><button>Show Promise A</button><button>Show Promise B</button>`
);
assert.deepEqual(logs, ['rendering pending block', 'rendering then block']);
}
});

@ -0,0 +1,17 @@
<script>
const a = Promise.resolve('a');
const b = Promise.resolve('b');
let promise = $state(a);
</script>
{#await promise}
{console.log('rendering pending block')}
<p>pending</p>
{:then value}
{console.log('rendering then block')}
<p>then {value}</p>
{/await}
<button onclick={() => (promise = a)}>Show Promise A</button>
<button onclick={() => (promise = b)}>Show Promise B</button>
Loading…
Cancel
Save