From c56ee71653e6386d7155e1c5db673e87acf82f90 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 2 Feb 2025 21:01:43 -0500 Subject: [PATCH] add showPendingAfter and showPendingFor --- .../2-analyze/visitors/SvelteBoundary.js | 2 +- .../internal/client/dom/blocks/boundary.js | 86 +++++++++++++++---- .../samples/async-pending-timeout/_config.js | 42 +++++++++ .../samples/async-pending-timeout/main.svelte | 11 +++ 4 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-pending-timeout/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-pending-timeout/main.svelte diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteBoundary.js index 35af96ba12..0a49d3b5a4 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteBoundary.js @@ -2,7 +2,7 @@ /** @import { Context } from '../types' */ import * as e from '../../../errors.js'; -const valid = ['onerror', 'failed', 'pending']; +const valid = ['onerror', 'failed', 'pending', 'showPendingAfter', 'showPendingFor']; /** * @param {AST.SvelteBoundary} node diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 8272c70800..eaffd07ce3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -4,6 +4,7 @@ import { BOUNDARY_EFFECT, BOUNDARY_SUSPENDED, EFFECT_PRESERVED, + EFFECT_RAN, EFFECT_TRANSPARENT, RENDER_EFFECT } from '../../constants.js'; @@ -33,6 +34,8 @@ import { queue_boundary_micro_task } from '../task.js'; import * as e from '../../../shared/errors.js'; import { DEV } from 'esm-env'; import { from_async_derived, set_from_async_derived } from '../../reactivity/deriveds.js'; +import { raf } from '../../timing.js'; +import { loop } from '../../loop.js'; const ASYNC_INCREMENT = Symbol(); const ASYNC_DECREMENT = Symbol(); @@ -69,9 +72,11 @@ var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT; /** * @param {TemplateNode} node * @param {{ - * onerror?: (error: unknown, reset: () => void) => void, - * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void - * pending?: (anchor: Node) => void + * onerror?: (error: unknown, reset: () => void) => void; + * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void; + * pending?: (anchor: Node) => void; + * showPendingAfter?: number; + * showPendingFor?: number; * }} props * @param {((anchor: Node) => void)} children * @returns {void} @@ -79,6 +84,8 @@ var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT; export function boundary(node, props, children) { var anchor = node; + var parent_boundary = find_boundary(active_effect); + block(() => { /** @type {Effect | null} */ var main_effect = null; @@ -106,6 +113,8 @@ export function boundary(node, props, children) { /** @type {Effect[]} */ var effects = []; + var keep_pending_snippet = false; + /** * @param {() => void} snippet_fn * @returns {Effect | null} @@ -145,6 +154,10 @@ export function boundary(node, props, children) { } function unsuspend() { + if (keep_pending_snippet || async_count > 0) { + return; + } + if ((boundary.f & BOUNDARY_SUSPENDED) !== 0) { boundary.f ^= BOUNDARY_SUSPENDED; } @@ -184,19 +197,70 @@ export function boundary(node, props, children) { } } + /** + * @param {boolean} initial + */ + function show_pending_snippet(initial) { + const pending = props.pending; + + if (pending !== undefined) { + // TODO can this be false? + if (main_effect !== null) { + offscreen_fragment = document.createDocumentFragment(); + move_effect(main_effect, offscreen_fragment); + } + + if (pending_effect === null) { + pending_effect = branch(() => pending(anchor)); + } + + // TODO do we want to differentiate between initial render and updates here? + if (!initial) { + keep_pending_snippet = true; + + var end = raf.now() + (props.showPendingFor ?? 300); + + loop((now) => { + if (now >= end) { + keep_pending_snippet = false; + unsuspend(); + return false; + } + + return true; + }); + } + } else if (parent_boundary) { + throw new Error('TODO show pending snippet on parent'); + } else { + throw new Error('no pending snippet to show'); + } + } + // @ts-ignore We re-use the effect's fn property to avoid allocation of an additional field boundary.fn = (/** @type {unknown} */ input, /** @type {any} */ payload) => { if (input === ASYNC_INCREMENT) { + // post-init, show the pending snippet after a timeout + if ((boundary.f & BOUNDARY_SUSPENDED) === 0 && (boundary.f & EFFECT_RAN) !== 0) { + var start = raf.now(); + var end = start + (props.showPendingAfter ?? 500); + + loop((now) => { + if (async_count === 0) return false; + if (now < end) return true; + + show_pending_snippet(false); + }); + } + boundary.f |= BOUNDARY_SUSPENDED; async_count++; - // TODO post-init, show the pending snippet after a timeout - return; } if (input === ASYNC_DECREMENT) { - if (--async_count === 0) { + if (--async_count === 0 && !keep_pending_snippet) { unsuspend(); if (main_effect !== null) { @@ -307,15 +371,7 @@ export function boundary(node, props, children) { if (async_count > 0) { boundary.f |= BOUNDARY_SUSPENDED; - - if (pending) { - offscreen_fragment = document.createDocumentFragment(); - move_effect(main_effect, offscreen_fragment); - - pending_effect = branch(() => pending(anchor)); - } else { - // TODO trigger pending boundary on parent - } + show_pending_snippet(true); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-timeout/_config.js b/packages/svelte/tests/runtime-runes/samples/async-pending-timeout/_config.js new file mode 100644 index 0000000000..857703c411 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-timeout/_config.js @@ -0,0 +1,42 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target, component, raf }) { + d.resolve('hello'); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + component.promise = (d = deferred()).promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + raf.tick(500); + assert.htmlEqual(target.innerHTML, '

pending

'); + + d.resolve('wheee'); + await tick(); + raf.tick(600); + assert.htmlEqual(target.innerHTML, '

pending

'); + + raf.tick(800); + assert.htmlEqual(target.innerHTML, '

wheee

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-timeout/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-timeout/main.svelte new file mode 100644 index 0000000000..3c6879caee --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-timeout/main.svelte @@ -0,0 +1,11 @@ + + + +

{await promise}

+ + {#snippet pending()} +

pending

+ {/snippet} +