From 0dcc250a00320a49a8119d43f0f363946628fba0 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Fri, 17 Jan 2025 17:48:51 +0000 Subject: [PATCH 1/2] chore: refactor task microtask dispatching + boundary scheduling --- .changeset/eleven-weeks-dance.md | 5 + .../2-analyze/visitors/SvelteBoundary.js | 2 +- .../client/visitors/SvelteBoundary.js | 5 +- .../src/internal/client/dom/blocks/await.js | 4 +- .../internal/client/dom/blocks/boundary.js | 155 +++++++++++++++--- .../src/internal/client/dom/blocks/each.js | 4 +- .../svelte/src/internal/client/dom/css.js | 6 +- .../client/dom/elements/bindings/input.js | 6 +- .../client/dom/elements/bindings/this.js | 4 +- .../internal/client/dom/elements/events.js | 4 +- .../src/internal/client/dom/elements/misc.js | 4 +- .../client/dom/elements/transitions.js | 4 +- .../svelte/src/internal/client/dom/task.js | 66 +++++--- .../src/internal/client/reactivity/effects.js | 16 +- .../svelte/src/internal/client/runtime.js | 15 +- packages/svelte/tests/animation-helpers.js | 4 +- 16 files changed, 225 insertions(+), 79 deletions(-) create mode 100644 .changeset/eleven-weeks-dance.md diff --git a/.changeset/eleven-weeks-dance.md b/.changeset/eleven-weeks-dance.md new file mode 100644 index 000000000000..c382f76a51f8 --- /dev/null +++ b/.changeset/eleven-weeks-dance.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: refactor task microtask dispatching + boundary scheduling 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 d50cb80cb83e..35af96ba122e 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']; +const valid = ['onerror', 'failed', 'pending']; /** * @param {AST.SvelteBoundary} node diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js index 325485d4c003..48402ccc7517 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js @@ -39,7 +39,10 @@ export function SvelteBoundary(node, context) { // Capture the `failed` implicit snippet prop for (const child of node.fragment.nodes) { - if (child.type === 'SnippetBlock' && child.expression.name === 'failed') { + if ( + child.type === 'SnippetBlock' && + (child.expression.name === 'failed' || child.expression.name === 'pending') + ) { // we need to delay the visit of the snippets in case they access a ConstTag that is declared // after the snippets so that the visitor for the const tag can be updated snippets_visits.push(() => { diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index 62b2e4dd0cda..788afa1921b3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -13,7 +13,7 @@ import { set_dev_current_component_function } from '../../runtime.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; -import { queue_micro_task } from '../task.js'; +import { queue_post_micro_task } from '../task.js'; import { UNINITIALIZED } from '../../../../constants.js'; const PENDING = 0; @@ -148,7 +148,7 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { } else { // 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(() => { + queue_post_micro_task(() => { if (!resolved) update(PENDING, true); }); } diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 7f4f000dceae..7261d8522fbd 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,7 +1,13 @@ /** @import { Effect, TemplateNode, } from '#client' */ import { BOUNDARY_EFFECT, EFFECT_TRANSPARENT } from '../../constants.js'; -import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js'; +import { + block, + branch, + destroy_effect, + pause_effect, + resume_effect +} from '../../reactivity/effects.js'; import { active_effect, active_reaction, @@ -20,7 +26,11 @@ import { remove_nodes, set_hydrate_node } from '../hydration.js'; -import { queue_micro_task } from '../task.js'; +import { get_next_sibling } from '../operations.js'; +import { queue_boundary_micro_task } from '../task.js'; + +const ASYNC_INCREMENT = Symbol(); +const ASYNC_DECREMENT = Symbol(); /** * @param {Effect} boundary @@ -49,6 +59,7 @@ function with_boundary(boundary, fn) { * @param {{ * onerror?: (error: unknown, reset: () => void) => void, * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void + * pending?: (anchor: Node) => void * }} props * @param {((anchor: Node) => void)} boundary_fn * @returns {void} @@ -58,14 +69,106 @@ export function boundary(node, props, boundary_fn) { /** @type {Effect} */ var boundary_effect; + /** @type {Effect | null} */ + var async_effect = null; + /** @type {DocumentFragment | null} */ + var async_fragment = null; + var async_count = 0; block(() => { var boundary = /** @type {Effect} */ (active_effect); var hydrate_open = hydrate_node; var is_creating_fallback = false; - // We re-use the effect's fn property to avoid allocation of an additional field - boundary.fn = (/** @type {unknown}} */ error) => { + const render_snippet = (/** @type { () => void } */ snippet_fn) => { + with_boundary(boundary, () => { + is_creating_fallback = true; + + try { + boundary_effect = branch(() => { + snippet_fn(); + }); + } catch (error) { + handle_error(error, boundary, null, boundary.ctx); + } + + reset_is_throwing_error(); + is_creating_fallback = false; + }); + }; + + // @ts-ignore We re-use the effect's fn property to avoid allocation of an additional field + boundary.fn = (/** @type {unknown} */ input) => { + let pending = props.pending; + + if (input === ASYNC_INCREMENT) { + if (!pending) { + return false; + } + + if (async_count++ === 0) { + queue_boundary_micro_task(() => { + if (async_effect || !boundary_effect) { + return; + } + + var effect = boundary_effect; + async_effect = boundary_effect; + + pause_effect( + async_effect, + () => { + /** @type {TemplateNode | null} */ + var node = effect.nodes_start; + var end = effect.nodes_end; + async_fragment = document.createDocumentFragment(); + + while (node !== null) { + /** @type {TemplateNode | null} */ + var sibling = + node === end ? null : /** @type {TemplateNode} */ (get_next_sibling(node)); + + node.remove(); + async_fragment.append(node); + node = sibling; + } + }, + false + ); + + render_snippet(() => { + pending(anchor); + }); + }); + } + + return true; + } + + if (input === ASYNC_DECREMENT) { + if (!pending) { + return false; + } + + if (--async_count === 0) { + queue_boundary_micro_task(() => { + if (!async_effect) { + return; + } + if (boundary_effect) { + destroy_effect(boundary_effect); + } + boundary_effect = async_effect; + async_effect = null; + anchor.before(/** @type {DocumentFragment} */ (async_fragment)); + resume_effect(boundary_effect); + }); + } + + return true; + } + + var error = input; var onerror = props.onerror; let failed = props.failed; @@ -96,25 +199,13 @@ export function boundary(node, props, boundary_fn) { } if (failed) { - // Render the `failed` snippet in a microtask - queue_micro_task(() => { - with_boundary(boundary, () => { - is_creating_fallback = true; - - try { - boundary_effect = branch(() => { - failed( - anchor, - () => error, - () => reset - ); - }); - } catch (error) { - handle_error(error, boundary, null, boundary.ctx); - } - - reset_is_throwing_error(); - is_creating_fallback = false; + queue_boundary_micro_task(() => { + render_snippet(() => { + failed( + anchor, + () => error, + () => reset + ); }); }); } @@ -132,3 +223,21 @@ export function boundary(node, props, boundary_fn) { anchor = hydrate_node; } } + +/** + * @param {Effect | null} effect + * @param {typeof ASYNC_INCREMENT | typeof ASYNC_DECREMENT} trigger + */ +export function trigger_async_boundary(effect, trigger) { + var current = effect; + + while (current !== null) { + if ((current.f & BOUNDARY_EFFECT) !== 0) { + // @ts-ignore + if (current.fn(trigger)) { + return; + } + } + current = current.parent; + } +} diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index b17090948ae7..dc4c133de4e9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -34,7 +34,7 @@ import { import { source, mutable_source, internal_set } from '../../reactivity/sources.js'; import { array_from, is_array } from '../../../shared/utils.js'; import { INERT } from '../../constants.js'; -import { queue_micro_task } from '../task.js'; +import { queue_post_micro_task } from '../task.js'; import { active_effect, active_reaction, get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; @@ -470,7 +470,7 @@ function reconcile(array, state, anchor, render_fn, flags, is_inert, get_key, ge } if (is_animated) { - queue_micro_task(() => { + queue_post_micro_task(() => { if (to_animate === undefined) return; for (item of to_animate) { item.a?.apply(); diff --git a/packages/svelte/src/internal/client/dom/css.js b/packages/svelte/src/internal/client/dom/css.js index 52be36aa1f46..d4340a07eef6 100644 --- a/packages/svelte/src/internal/client/dom/css.js +++ b/packages/svelte/src/internal/client/dom/css.js @@ -1,5 +1,5 @@ import { DEV } from 'esm-env'; -import { queue_micro_task } from './task.js'; +import { queue_post_micro_task } from './task.js'; import { register_style } from '../dev/css.js'; /** @@ -7,8 +7,8 @@ import { register_style } from '../dev/css.js'; * @param {{ hash: string, code: string }} css */ export function append_styles(anchor, css) { - // Use `queue_micro_task` to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results - queue_micro_task(() => { + // Use `queue_post_micro_task` to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results + queue_post_micro_task(() => { var root = anchor.getRootNode(); var target = /** @type {ShadowRoot} */ (root).host diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index ec123d39681d..b8d4b07c9b7e 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -3,7 +3,7 @@ import { render_effect, teardown } from '../../../reactivity/effects.js'; import { listen_to_event_and_reset_event } from './shared.js'; import * as e from '../../../errors.js'; import { is } from '../../../proxy.js'; -import { queue_micro_task } from '../../task.js'; +import { queue_post_micro_task } from '../../task.js'; import { hydrating } from '../../hydration.js'; import { is_runes, untrack } from '../../../runtime.js'; @@ -158,14 +158,14 @@ export function bind_group(inputs, group_index, input, get, set = get) { if (!pending.has(binding_group)) { pending.add(binding_group); - queue_micro_task(() => { + queue_post_micro_task(() => { // necessary to maintain binding group order in all insertion scenarios binding_group.sort((a, b) => (a.compareDocumentPosition(b) === 4 ? -1 : 1)); pending.delete(binding_group); }); } - queue_micro_task(() => { + queue_post_micro_task(() => { if (hydration_mismatch) { var value; diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/this.js b/packages/svelte/src/internal/client/dom/elements/bindings/this.js index 56b0a56e71c4..0ca5039e7c69 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/this.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/this.js @@ -1,7 +1,7 @@ import { STATE_SYMBOL } from '../../../constants.js'; import { effect, render_effect } from '../../../reactivity/effects.js'; import { untrack } from '../../../runtime.js'; -import { queue_micro_task } from '../../task.js'; +import { queue_post_micro_task } from '../../task.js'; /** * @param {any} bound_value @@ -49,7 +49,7 @@ export function bind_this(element_or_component = {}, update, get_value, get_part return () => { // We cannot use effects in the teardown phase, we we use a microtask instead. - queue_micro_task(() => { + queue_post_micro_task(() => { if (parts && is_bound_this(get_value(...parts), element_or_component)) { update(null, ...parts); } diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index f2038f96ada3..4144a13fac66 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -2,7 +2,7 @@ import { teardown } from '../../reactivity/effects.js'; import { define_property, is_array } from '../../../shared/utils.js'; import { hydrating } from '../hydration.js'; -import { queue_micro_task } from '../task.js'; +import { queue_post_micro_task } from '../task.js'; import { FILENAME } from '../../../../constants.js'; import * as w from '../../warnings.js'; import { @@ -77,7 +77,7 @@ export function create_event(event_name, dom, handler, options) { event_name.startsWith('touch') || event_name === 'wheel' ) { - queue_micro_task(() => { + queue_post_micro_task(() => { dom.addEventListener(event_name, target_handler, options); }); } else { diff --git a/packages/svelte/src/internal/client/dom/elements/misc.js b/packages/svelte/src/internal/client/dom/elements/misc.js index 61e513903f76..dab8e84c32f6 100644 --- a/packages/svelte/src/internal/client/dom/elements/misc.js +++ b/packages/svelte/src/internal/client/dom/elements/misc.js @@ -1,6 +1,6 @@ import { hydrating } from '../hydration.js'; import { clear_text_content, get_first_child } from '../operations.js'; -import { queue_micro_task } from '../task.js'; +import { queue_post_micro_task } from '../task.js'; /** * @param {HTMLElement} dom @@ -12,7 +12,7 @@ export function autofocus(dom, value) { const body = document.body; dom.autofocus = true; - queue_micro_task(() => { + queue_post_micro_task(() => { if (document.activeElement === body) { dom.focus(); } diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index b3c16cdd080f..0dd17fad9ff4 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -13,7 +13,7 @@ import { should_intro } from '../../render.js'; import { current_each_item } from '../blocks/each.js'; import { TRANSITION_GLOBAL, TRANSITION_IN, TRANSITION_OUT } from '../../../../constants.js'; import { BLOCK_EFFECT, EFFECT_RAN, EFFECT_TRANSPARENT } from '../../constants.js'; -import { queue_micro_task } from '../task.js'; +import { queue_post_micro_task } from '../task.js'; /** * @param {Element} element @@ -326,7 +326,7 @@ function animate(element, options, counterpart, t2, on_finish) { var a; var aborted = false; - queue_micro_task(() => { + queue_post_micro_task(() => { if (aborted) return; var o = options({ direction: is_intro ? 'in' : 'out' }); a = animate(element, o, counterpart, t2, on_finish); diff --git a/packages/svelte/src/internal/client/dom/task.js b/packages/svelte/src/internal/client/dom/task.js index acb5a5b117f0..8b16b30ebead 100644 --- a/packages/svelte/src/internal/client/dom/task.js +++ b/packages/svelte/src/internal/client/dom/task.js @@ -10,54 +10,70 @@ let is_micro_task_queued = false; let is_idle_task_queued = false; /** @type {Array<() => void>} */ -let current_queued_micro_tasks = []; +let queued_boundary_microtasks = []; /** @type {Array<() => void>} */ -let current_queued_idle_tasks = []; +let queued_post_microtasks = []; +/** @type {Array<() => void>} */ +let queued_idle_tasks = []; -function process_micro_tasks() { - is_micro_task_queued = false; - const tasks = current_queued_micro_tasks.slice(); - current_queued_micro_tasks = []; +export function flush_boundary_micro_tasks() { + const tasks = queued_boundary_microtasks.slice(); + queued_boundary_microtasks = []; run_all(tasks); } -function process_idle_tasks() { - is_idle_task_queued = false; - const tasks = current_queued_idle_tasks.slice(); - current_queued_idle_tasks = []; +export function flush_post_micro_tasks() { + const tasks = queued_post_microtasks.slice(); + queued_post_microtasks = []; run_all(tasks); } +export function flush_idle_tasks() { + if (is_idle_task_queued) { + is_idle_task_queued = false; + const tasks = queued_idle_tasks.slice(); + queued_idle_tasks = []; + run_all(tasks); + } +} + +function flush_all_micro_tasks() { + if (is_micro_task_queued) { + is_micro_task_queued = false; + flush_boundary_micro_tasks(); + flush_post_micro_tasks(); + } +} + /** * @param {() => void} fn */ -export function queue_micro_task(fn) { +export function queue_boundary_micro_task(fn) { if (!is_micro_task_queued) { is_micro_task_queued = true; - queueMicrotask(process_micro_tasks); + queueMicrotask(flush_all_micro_tasks); } - current_queued_micro_tasks.push(fn); + queued_boundary_microtasks.push(fn); } /** * @param {() => void} fn */ -export function queue_idle_task(fn) { - if (!is_idle_task_queued) { - is_idle_task_queued = true; - request_idle_callback(process_idle_tasks); +export function queue_post_micro_task(fn) { + if (!is_micro_task_queued) { + is_micro_task_queued = true; + queueMicrotask(flush_all_micro_tasks); } - current_queued_idle_tasks.push(fn); + queued_post_microtasks.push(fn); } /** - * Synchronously run any queued tasks. + * @param {() => void} fn */ -export function flush_tasks() { - if (is_micro_task_queued) { - process_micro_tasks(); - } - if (is_idle_task_queued) { - process_idle_tasks(); +export function queue_idle_task(fn) { + if (!is_idle_task_queued) { + is_idle_task_queued = true; + request_idle_callback(flush_idle_tasks); } + queued_idle_tasks.push(fn); } diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 428f69281ba3..abcb558c7f83 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -528,15 +528,20 @@ export function unlink_effect(effect) { * A paused effect does not update, and the DOM subtree becomes inert. * @param {Effect} effect * @param {() => void} [callback] + * @param {boolean} [destroy] */ -export function pause_effect(effect, callback) { +export function pause_effect(effect, callback, destroy = true) { /** @type {TransitionManager[]} */ var transitions = []; - pause_children(effect, transitions, true); + pause_children(effect, transitions, true, destroy); run_out_transitions(transitions, () => { - destroy_effect(effect); + if (destroy) { + destroy_effect(effect); + } else { + execute_effect_teardown(effect); + } if (callback) callback(); }); } @@ -561,8 +566,9 @@ export function run_out_transitions(transitions, fn) { * @param {Effect} effect * @param {TransitionManager[]} transitions * @param {boolean} local + * @param {boolean} [destroy] */ -export function pause_children(effect, transitions, local) { +export function pause_children(effect, transitions, local, destroy = true) { if ((effect.f & INERT) !== 0) return; effect.f ^= INERT; @@ -582,7 +588,7 @@ export function pause_children(effect, transitions, local) { // TODO we don't need to call pause_children recursively with a linked list in place // it's slightly more involved though as we have to account for `transparent` changing // through the tree. - pause_children(child, transitions, transparent ? local : false); + pause_children(child, transitions, transparent ? local : false, destroy); child = sibling; } } diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index eca5ee94f907..aba037c4a36b 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -27,7 +27,11 @@ import { DISCONNECTED, BOUNDARY_EFFECT } from './constants.js'; -import { flush_tasks } from './dom/task.js'; +import { + flush_idle_tasks, + flush_boundary_micro_tasks, + flush_post_micro_tasks +} from './dom/task.js'; import { add_owner } from './dev/ownership.js'; import { internal_set, set, source } from './reactivity/sources.js'; import { destroy_derived, execute_derived, update_derived } from './reactivity/deriveds.js'; @@ -737,11 +741,12 @@ function flush_queued_effects(effects) { } } -function process_deferred() { +function flushed_deferred() { is_micro_task_queued = false; if (flush_count > 1001) { return; } + // flush_before_process_microtasks(); const previous_queued_root_effects = queued_root_effects; queued_root_effects = []; flush_queued_root_effects(previous_queued_root_effects); @@ -763,7 +768,7 @@ export function schedule_effect(signal) { if (scheduler_mode === FLUSH_MICROTASK) { if (!is_micro_task_queued) { is_micro_task_queued = true; - queueMicrotask(process_deferred); + queueMicrotask(flushed_deferred); } } @@ -882,7 +887,9 @@ export function flush_sync(fn) { var result = fn?.(); - flush_tasks(); + flush_boundary_micro_tasks(); + flush_post_micro_tasks(); + flush_idle_tasks(); if (queued_root_effects.length > 0 || root_effects.length > 0) { flush_sync(); } diff --git a/packages/svelte/tests/animation-helpers.js b/packages/svelte/tests/animation-helpers.js index dcbb06292305..e37c2563af5e 100644 --- a/packages/svelte/tests/animation-helpers.js +++ b/packages/svelte/tests/animation-helpers.js @@ -1,6 +1,6 @@ import { flushSync } from 'svelte'; import { raf as svelte_raf } from 'svelte/internal/client'; -import { queue_micro_task } from '../src/internal/client/dom/task.js'; +import { queue_post_micro_task } from '../src/internal/client/dom/task.js'; export const raf = { animations: new Set(), @@ -132,7 +132,7 @@ class Animation { /** @param {() => {}} fn */ set onfinish(fn) { if (this.#duration === 0) { - queue_micro_task(fn); + queue_post_micro_task(fn); } else { this.#onfinish = () => { fn(); From 8687643c8979aedc8f9293e3324b5a3e5981ce9c Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sat, 18 Jan 2025 13:34:07 +0000 Subject: [PATCH 2/2] WIP --- packages/svelte/src/ambient.d.ts | 2 + .../phases/2-analyze/visitors/Attribute.js | 6 + .../2-analyze/visitors/CallExpression.js | 50 +++++++- .../phases/2-analyze/visitors/Identifier.js | 22 +++- .../2-analyze/visitors/VariableDeclarator.js | 52 ++++++--- .../3-transform/client/transform-client.js | 20 +++- .../phases/3-transform/client/types.d.ts | 5 +- .../phases/3-transform/client/utils.js | 107 +++++++++++++++++- .../3-transform/client/visitors/Program.js | 16 ++- .../client/visitors/VariableDeclaration.js | 4 + .../client/visitors/shared/component.js | 25 +++- .../client/visitors/shared/declarations.js | 1 + packages/svelte/src/compiler/phases/nodes.js | 1 + packages/svelte/src/compiler/phases/scope.js | 1 + packages/svelte/src/compiler/types/index.d.ts | 5 + .../svelte/src/internal/client/constants.js | 29 ++--- .../internal/client/dom/blocks/boundary.js | 95 +++++++++++++++- packages/svelte/src/internal/client/index.js | 5 +- .../src/internal/client/reactivity/effects.js | 53 ++++++--- .../src/internal/client/reactivity/sources.js | 1 - .../svelte/src/internal/client/runtime.js | 41 ++++++- packages/svelte/src/utils.js | 1 + packages/svelte/types/index.d.ts | 2 + 23 files changed, 473 insertions(+), 71 deletions(-) diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index fbcecba8e47c..560633c235b2 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -500,3 +500,5 @@ declare namespace $host { /** @deprecated */ export const toString: never; } + +declare function $await(value: Promise): [V, undefined | Promise]; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index 9d801e095e8d..738a2da6b04e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -64,6 +64,12 @@ export function Attribute(node, context) { node.metadata.expression.has_state ||= chunk.metadata.expression.has_state; node.metadata.expression.has_call ||= chunk.metadata.expression.has_call; + chunk.metadata.expression.dependencies.forEach((dependency) => + node.metadata.expression.dependencies.add(dependency) + ); + chunk.metadata.expression.async_dependencies.forEach((dependency) => + node.metadata.expression.async_dependencies.add(dependency) + ); } if (is_event_attribute(node)) { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 9f51cd61de6d..0073e11ea9dc 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -3,7 +3,7 @@ /** @import { Context } from '../types' */ import { get_rune } from '../../scope.js'; import * as e from '../../../errors.js'; -import { get_parent, unwrap_optional } from '../../../utils/ast.js'; +import { extract_identifiers, get_parent, unwrap_optional } from '../../../utils/ast.js'; import { is_pure, is_safe_identifier } from './shared/utils.js'; import { dev, locate_node, source } from '../../../state.js'; import * as b from '../../../utils/builders.js'; @@ -123,6 +123,52 @@ export function CallExpression(node, context) { break; + case '$await': { + if (node.arguments.length !== 1) { + e.rune_invalid_arguments_length(node, rune, 'exactly one argument'); + } + + const declarator = context.path.at(-1); + const declaration = context.path.at(-2); + const program = context.path.at(-3); + + if (context.state.ast_type !== 'instance') { + throw new Error('TODO: $await can only be used at the top-level of a component'); + } + if ( + declarator?.type !== 'VariableDeclarator' || + declarator?.id.type !== 'ArrayPattern' || + declaration?.type !== 'VariableDeclaration' || + declaration?.declarations.length !== 1 || + context.state.function_depth !== 1 || + program?.type !== 'Program' + ) { + throw new Error('TODO: invalid usage of $await in component'); + } + + const [async_derived, derived_promise] = declarator.id.elements; + + if (async_derived) { + for (const id of extract_identifiers(async_derived)) { + const binding = context.state.scope.get(id.name); + if (binding !== null) { + binding.kind = 'async_derived'; + } + } + } + + if (derived_promise) { + for (const id of extract_identifiers(derived_promise)) { + const binding = context.state.scope.get(id.name); + if (binding !== null) { + binding.kind = 'derived'; + } + } + } + + break; + } + case '$inspect': if (node.arguments.length < 1) { e.rune_invalid_arguments_length(node, rune, 'one or more arguments'); @@ -207,7 +253,7 @@ export function CallExpression(node, context) { } // `$inspect(foo)` or `$derived(foo) should not trigger the `static-state-reference` warning - if (rune === '$inspect' || rune === '$derived') { + if (rune === '$inspect' || rune === '$derived' || rune === '$await') { context.next({ ...context.state, function_depth: context.state.function_depth + 1 }); } else { context.next(); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js index 79dccd5a7cf5..7854d6f08e6e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js @@ -88,9 +88,25 @@ export function Identifier(node, context) { } if (binding) { - if (context.state.expression) { - context.state.expression.dependencies.add(binding); - context.state.expression.has_state ||= binding.kind !== 'normal'; + if (binding.kind === 'async_derived') { + debugger + } + + const expression = context.state.expression; + + if (expression) { + expression.dependencies.add(binding); + + if ( + binding.kind === 'async_derived' + ) { + expression.async_dependencies.add(binding); + } + expression.has_state ||= binding.kind !== 'normal'; + + binding.async_dependencies.forEach((dep) => { + expression.async_dependencies.add(dep); + }); } if ( diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js index a7d08d315d8f..d9c1e04aa9b3 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js @@ -6,6 +6,7 @@ import { ensure_no_module_import_conflict, validate_identifier_name } from './sh import * as e from '../../../errors.js'; import { extract_paths } from '../../../utils/ast.js'; import { equal } from '../../../utils/assert.js'; +import { create_expression_metadata } from '../../nodes.js'; /** * @param {VariableDeclarator} node @@ -29,21 +30,42 @@ export function VariableDeclarator(node, context) { rune === '$state.raw' || rune === '$derived' || rune === '$derived.by' || - rune === '$props' + rune === '$props' || + rune === '$await' ) { - for (const path of paths) { - // @ts-ignore this fails in CI for some insane reason - const binding = /** @type {Binding} */ (context.state.scope.get(path.node.name)); - binding.kind = - rune === '$state' - ? 'state' - : rune === '$state.raw' - ? 'raw_state' - : rune === '$derived' || rune === '$derived.by' - ? 'derived' - : path.is_rest - ? 'rest_prop' - : 'prop'; + let metadata = create_expression_metadata(); + + if (rune !== '$await') { + for (const path of paths) { + // @ts-ignore this fails in CI for some insane reason + const binding = /** @type {Binding} */ (context.state.scope.get(path.node.name)); + binding.kind = + rune === '$state' + ? 'state' + : rune === '$state.raw' + ? 'raw_state' + : rune === '$derived' || rune === '$derived.by' + ? 'derived' + : path.is_rest + ? 'rest_prop' + : 'prop'; + } + } + + context.visit(node.id); + if (node.init) { + context.visit(node.init); + if (node.init.type === 'CallExpression' && node.init.arguments.length > 0) { + context.visit(node.init.arguments[0], { ...context.state, expression: metadata }); + + if (metadata.async_dependencies.size > 0) { + for (const path of paths) { + // @ts-ignore + const binding = /** @type {Binding} */ (context.state.scope.get(path.node.name)); + metadata.async_dependencies.forEach((dep) => binding.async_dependencies.add(dep)); + } + } + } } } @@ -103,6 +125,8 @@ export function VariableDeclarator(node, context) { } } } + + return; } else { if (node.init?.type === 'CallExpression') { const callee = node.init.callee; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 582c32b534ec..dab74121d40b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -174,7 +174,8 @@ export function client_component(analysis, options) { update: /** @type {any} */ (null), after_update: /** @type {any} */ (null), template: /** @type {any} */ (null), - locations: /** @type {any} */ (null) + locations: /** @type {any} */ (null), + target_statements: null }; const module = /** @type {ESTree.Program} */ ( @@ -193,6 +194,8 @@ export function client_component(analysis, options) { walk(/** @type {AST.SvelteNode} */ (analysis.instance.ast), instance_state, visitors) ); + const target_statements = instance_state.target_statements; + const template = /** @type {ESTree.Program} */ ( walk( /** @type {AST.SvelteNode} */ (analysis.template.ast), @@ -351,7 +354,7 @@ export function client_component(analysis, options) { const push_args = [b.id('$$props'), b.literal(analysis.runes)]; if (dev) push_args.push(b.id(analysis.name)); - const component_block = b.block([ + const component_block_statements = [ ...store_setup, ...legacy_reactive_declarations, ...group_binding_declarations, @@ -359,9 +362,16 @@ export function client_component(analysis, options) { .../** @type {ESTree.Statement[]} */ (instance.body), analysis.runes || !analysis.needs_context ? b.empty - : b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)), - .../** @type {ESTree.Statement[]} */ (template.body) - ]); + : b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)) + ]; + + if (target_statements === null) { + component_block_statements.push(.../** @type {ESTree.Statement[]} */ (template.body)); + } else { + target_statements.push(.../** @type {ESTree.Statement[]} */ (template.body)); + } + + const component_block = b.block(component_block_statements); if (!analysis.runes) { // Bind static exports to props so that people can access them with bind:x diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 5c8476de3e3c..f2c84a742650 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -7,7 +7,8 @@ import type { Expression, AssignmentExpression, UpdateExpression, - VariableDeclaration + VariableDeclaration, + Directive } from 'estree'; import type { AST, Namespace, ValidatedCompileOptions } from '#compiler'; import type { TransformState } from '../types.js'; @@ -91,6 +92,8 @@ export interface ComponentClientTransformState extends ClientTransformState { readonly instance_level_snippets: VariableDeclaration[]; /** Snippets hoisted to the module */ readonly module_level_snippets: VariableDeclaration[]; + + target_statements: null | Array; } export interface StateField { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index c59a5544dfb2..13b779b545ac 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -1,10 +1,11 @@ -/** @import { ArrowFunctionExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, Pattern, PrivateIdentifier, Statement } from 'estree' */ +/** @import { ArrowFunctionExpression, Expression, CallExpression, VariableDeclarator, FunctionDeclaration, FunctionExpression, Identifier, Pattern, PrivateIdentifier, Statement, VariableDeclaration, ModuleDeclaration, Directive } from 'estree' */ /** @import { AST, Binding } from '#compiler' */ /** @import { ClientTransformState, ComponentClientTransformState, ComponentContext } from './types.js' */ /** @import { Analysis } from '../../types.js' */ /** @import { Scope } from '../../scope.js' */ import * as b from '../../../utils/builders.js'; import { extract_identifiers, is_simple_expression } from '../../../utils/ast.js'; +import { get_rune } from '../../scope.js'; import { PROPS_IS_LAZY_INITIAL, PROPS_IS_IMMUTABLE, @@ -312,3 +313,107 @@ export function create_derived_block_argument(node, context) { export function create_derived(state, arg) { return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', arg); } + +/** + * @param {(ModuleDeclaration | Statement | Directive)[]} statements + * @param {ComponentContext} context + * @returns {[(ModuleDeclaration | Statement | Directive)[], null | (ModuleDeclaration | Statement | Directive)[]]} + */ +export function wrap_unsafe_async_statements(statements, context) { + /** @type {(ModuleDeclaration | Statement | Directive)[]} */ + const new_statements = []; + let target_block_statements = new_statements; + let is_unsafe = true; + + const push_unsafe_statement = (/** @type {Statement} */ statement) => { + if (is_unsafe) { + const block_statments = [statement]; + const script_template = b.stmt(b.call('$.script_effect', b.thunk(b.block(block_statments)))); + target_block_statements.push(script_template); + target_block_statements = block_statments; + is_unsafe = false; + } else { + target_block_statements.push(statement); + } + }; + + for (const statement of statements) { + const visited = /** @type {Statement} */ (context.visit(statement)); + + if ( + statement.type === 'FunctionDeclaration' || + statement.type === 'ClassDeclaration' || + statement.type === 'EmptyStatement' || + statement.type === 'ImportDeclaration' || + statement.type === 'ExportNamedDeclaration' || + statement.type === 'ExportAllDeclaration' || + statement.type === 'ExportDefaultDeclaration' + ) { + target_block_statements.push(visited); + continue; + } + + if (statement.type === 'VariableDeclaration') { + if (statement.declarations.length === 1) { + const declarator = statement.declarations[0]; + const init = declarator.init; + + // Safe declaration + if ( + init == null || + init.type === 'Literal' || + init.type === 'FunctionExpression' || + init.type === 'ArrowFunctionExpression' || + (init.type === 'ArrayExpression' && init.elements.length === 0) || + (init.type === 'ObjectExpression' && init.properties.length === 0) + ) { + target_block_statements.push(visited); + continue; + } + // Handle runes + if (init.type === 'CallExpression') { + const rune = get_rune(init, context.state.scope); + + if (rune === '$props' || rune === '$derived' || rune === '$derived.by') { + target_block_statements.push(visited); + continue; + } + if (rune === '$await') { + target_block_statements.push(visited); + is_unsafe = true; + continue; + } + } + } + // TODO: we can probably better handle multiple declarators + push_unsafe_statement(visited); + continue; + } + + if (statement.type === 'ExpressionStatement') { + const expression = statement.expression; + + // Handle runes + if (expression.type === 'CallExpression') { + const rune = get_rune(expression, context.state.scope); + + if (rune === '$effect' || rune === '$effect.pre') { + target_block_statements.push(visited); + continue; + } + } + + // Assume all expression statement expressions are unsafe + push_unsafe_statement(visited); + continue; + } + + // Assume all other top-level statements are unsafe + push_unsafe_statement(visited); + } + + return [ + new_statements, + new_statements === target_block_statements ? null : target_block_statements + ]; +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Program.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Program.js index 29403ca6edef..55b693b77abd 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Program.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Program.js @@ -1,14 +1,14 @@ /** @import { Expression, ImportDeclaration, MemberExpression, Program } from 'estree' */ /** @import { ComponentContext } from '../types' */ -import { build_getter, is_prop_source } from '../utils.js'; +import { build_getter, is_prop_source, wrap_unsafe_async_statements } from '../utils.js'; import * as b from '../../../../utils/builders.js'; import { add_state_transformers } from './shared/declarations.js'; /** - * @param {Program} _ + * @param {Program} node * @param {ComponentContext} context */ -export function Program(_, context) { +export function Program(node, context) { if (!context.state.analysis.runes) { context.state.transform['$$props'] = { read: (node) => ({ ...node, name: '$$sanitized_props' }) @@ -137,5 +137,15 @@ export function Program(_, context) { add_state_transformers(context); + if (context.state.analysis.instance && context.state.analysis.runes) { + const [statements, target_statements] = wrap_unsafe_async_statements(node.body, context); + context.state.target_statements = target_statements; + + return { + ...node, + body: statements + }; + } + context.next(); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index afb90bbec7f9..94096c6c0995 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -194,6 +194,10 @@ export function VariableDeclaration(node, context) { } continue; } + + if (rune === '$await') { + declarations.push(b.declarator(declarator.id, b.call('$.await_derived', b.thunk(value)))); + } } } else { for (const declarator of node.declarations) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index f509cb41a7d8..12d4aedcdc4a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -167,7 +167,30 @@ export function build_component(node, component_name, context, anchor = context. arg = b.call('$.get', id); } - push_prop(b.get(attribute.name, [b.return(arg)])); + const expression_metadata = attribute.metadata.expression; + const async_dependencies = [...expression_metadata.async_dependencies]; + + if (async_dependencies.length > 0) { + push_prop( + b.get(attribute.name, [ + b.stmt( + b.call( + '$.maybe_yield', + b.thunk( + b.sequence( + async_dependencies.map( + (v) => /** @type {Expression} */ (context.visit(b.id(v.node.name))) + ) + ) + ) + ) + ), + b.return(arg) + ]) + ); + } else { + push_prop(b.get(attribute.name, [b.return(arg)])); + } } else { push_prop(b.init(attribute.name, value)); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js index 0bd8c352f6a9..23d1fb3aff14 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js @@ -20,6 +20,7 @@ export function add_state_transformers(context) { if ( is_state_source(binding, context.state.analysis) || binding.kind === 'derived' || + binding.kind === 'async_derived' || binding.kind === 'legacy_reactive' ) { context.state.transform[name] = { diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 5066833feb8e..f9340419b5f3 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -56,6 +56,7 @@ export function create_attribute(name, start, end, value) { */ export function create_expression_metadata() { return { + async_dependencies: new Set(), dependencies: new Set(), has_state: false, has_call: false diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index 3536dd6a1865..24cf9b464de0 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -105,6 +105,7 @@ export class Scope { node, references: [], legacy_dependencies: [], + async_dependencies: new Set(), initial, reassigned: false, mutated: false, diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index b80b717e426c..856d32982582 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -274,6 +274,7 @@ export interface Binding { | 'state' | 'raw_state' | 'derived' + | 'async_derived' | 'each' | 'snippet' | 'store_sub' @@ -302,6 +303,8 @@ export interface Binding { scope: Scope; /** For `legacy_reactive`: its reactive dependencies */ legacy_dependencies: Binding[]; + /** All the transitive async or prop (as they might be async) bindings that this binding uses */ + async_dependencies: Set; /** Legacy props: the `class` in `{ export klass as class}`. $props(): The `class` in { class: klass } = $props() */ prop_alias: string | null; /** Additional metadata, varies per binding type */ @@ -318,6 +321,8 @@ export interface ExpressionMetadata { has_state: boolean; /** True if the expression involves a call expression (often, it will need to be wrapped in a derived) */ has_call: boolean; + /** All the transitive async or prop (as they might be async) bindings that are used inside this expression */ + async_dependencies: Set; } export * from './template.js'; diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index a4840ce4ebd0..836fb9d4fd4f 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -5,23 +5,26 @@ export const BLOCK_EFFECT = 1 << 4; export const BRANCH_EFFECT = 1 << 5; export const ROOT_EFFECT = 1 << 6; export const BOUNDARY_EFFECT = 1 << 7; -export const UNOWNED = 1 << 8; -export const DISCONNECTED = 1 << 9; -export const CLEAN = 1 << 10; -export const DIRTY = 1 << 11; -export const MAYBE_DIRTY = 1 << 12; -export const INERT = 1 << 13; -export const DESTROYED = 1 << 14; -export const EFFECT_RAN = 1 << 15; +export const TEMPLATE_EFFECT = 1 << 8; +export const AWAIT_EFFECT = 1 << 9; +export const UNOWNED = 1 << 10; +export const DISCONNECTED = 1 << 11; +export const CLEAN = 1 << 12; +export const DIRTY = 1 << 13; +export const MAYBE_DIRTY = 1 << 14; +export const INERT = 1 << 15; +export const DESTROYED = 1 << 16; +export const EFFECT_RAN = 1 << 17; /** 'Transparent' effects do not create a transition boundary */ -export const EFFECT_TRANSPARENT = 1 << 16; +export const EFFECT_TRANSPARENT = 1 << 18; /** Svelte 4 legacy mode props need to be handled with deriveds and be recognized elsewhere, hence the dedicated flag */ -export const LEGACY_DERIVED_PROP = 1 << 17; -export const INSPECT_EFFECT = 1 << 18; -export const HEAD_EFFECT = 1 << 19; -export const EFFECT_HAS_DERIVED = 1 << 20; +export const LEGACY_DERIVED_PROP = 1 << 19; +export const INSPECT_EFFECT = 1 << 20; +export const HEAD_EFFECT = 1 << 21; +export const EFFECT_HAS_DERIVED = 1 << 22; export const STATE_SYMBOL = Symbol('$state'); export const STATE_SYMBOL_METADATA = Symbol('$state metadata'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); +export const PENDING = Symbol(); diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 7261d8522fbd..a023330d44fb 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,6 +1,16 @@ -/** @import { Effect, TemplateNode, } from '#client' */ +/** @import { Effect, Source, TemplateNode, } from '#client' */ -import { BOUNDARY_EFFECT, EFFECT_TRANSPARENT } from '../../constants.js'; +import { UNINITIALIZED } from '../../../../constants.js'; +import { + AWAIT_EFFECT, + BOUNDARY_EFFECT, + DESTROYED, + DIRTY, + EFFECT_TRANSPARENT, + INERT, + PENDING +} from '../../constants.js'; +import { derived } from '../../reactivity/deriveds.js'; import { block, branch, @@ -8,6 +18,7 @@ import { pause_effect, resume_effect } from '../../reactivity/effects.js'; +import { internal_set, source } from '../../reactivity/sources.js'; import { active_effect, active_reaction, @@ -16,7 +27,12 @@ import { set_active_effect, set_active_reaction, set_component_context, - reset_is_throwing_error + reset_is_throwing_error, + get, + set_is_within_await, + untrack, + schedule_effect, + set_signal_status } from '../../runtime.js'; import { hydrate_next, @@ -27,7 +43,7 @@ import { set_hydrate_node } from '../hydration.js'; import { get_next_sibling } from '../operations.js'; -import { queue_boundary_micro_task } from '../task.js'; +import { flush_boundary_micro_tasks, queue_boundary_micro_task } from '../task.js'; const ASYNC_INCREMENT = Symbol(); const ASYNC_DECREMENT = Symbol(); @@ -240,4 +256,75 @@ export function trigger_async_boundary(effect, trigger) { } current = current.parent; } + throw new Error('Cannot use `$await` without a parent ``'); +} + +/** + * @template V + * @param {() => Promise} fn + */ +export function await_derived(fn) { + var current = /** @type {Effect} */ (active_effect); + /** @type {Source} */ + var value = source(UNINITIALIZED); + // We mark the source signal as inert as it's value + // can throw if in an async pending state + value.f ^= INERT; + /** @type {Promise | typeof UNINITIALIZED} */ + var previous_promise = UNINITIALIZED; + var derived_promise = derived(fn); + + block(() => { + var promise = get(derived_promise); + get(value); + + var should_suspend = previous_promise !== promise; + previous_promise = promise; + + if (should_suspend) { + trigger_async_boundary(current, ASYNC_INCREMENT); + set_is_within_await(true); + + // If we're updating, then we need to flush the boundary microtasks + if (current.parent?.first !== null) { + flush_boundary_micro_tasks(); + } + + if (promise) { + promise.then((v) => { + if (previous_promise !== promise || (current.f & DESTROYED) !== 0) { + return; + } + internal_set(value, v); + //set_signal_status(current, DIRTY); + trigger_async_boundary(current, ASYNC_DECREMENT); + }); + + promise.catch((e) => { + handle_error(e, current, null, current.ctx); + }); + } + } + + return value.v; + }, AWAIT_EFFECT); + + var pending = derived(() => { + var promise = get(derived_promise); + if (previous_promise === promise) { + return null; + } + + // Wait a microtask to let the UI flush + return promise.then((r) => r); + }); + + return [value, pending]; +} + +/** + * @param {() => any} fn + */ +export function maybe_yield(fn) { + untrack(fn); } diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 2bf58c51f75d..063cd53f5f59 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -107,7 +107,8 @@ export { template_effect, effect, user_effect, - user_pre_effect + user_pre_effect, + script_effect } from './reactivity/effects.js'; export { mutable_state, mutate, set, state } from './reactivity/sources.js'; export { @@ -129,7 +130,7 @@ export { update_store, mark_store_binding } from './reactivity/store.js'; -export { boundary } from './dom/blocks/boundary.js'; +export { boundary, await_derived, maybe_yield } from './dom/blocks/boundary.js'; export { set_text } from './render.js'; export { get, diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index abcb558c7f83..187533ef9675 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -16,7 +16,8 @@ import { set_is_flushing_effect, set_signal_status, untrack, - skip_reaction + skip_reaction, + is_within_await } from '../runtime.js'; import { DIRTY, @@ -36,7 +37,8 @@ import { HEAD_EFFECT, MAYBE_DIRTY, EFFECT_HAS_DERIVED, - BOUNDARY_EFFECT + BOUNDARY_EFFECT, + TEMPLATE_EFFECT } from '../constants.js'; import { set } from './sources.js'; import * as e from '../errors.js'; @@ -118,27 +120,36 @@ function create_effect(type, fn, sync, push = true) { effect.component_function = dev_current_component_function; } - if (sync) { - var previously_flushing_effect = is_flushing_effect; + var should_run_effect = + !is_within_await || + (type & BRANCH_EFFECT) !== 0 || + ((type & BLOCK_EFFECT) !== 0 && (type & TEMPLATE_EFFECT) === 0); - try { - set_is_flushing_effect(true); - update_effect(effect); - effect.f |= EFFECT_RAN; - } catch (e) { - destroy_effect(effect); - throw e; - } finally { - set_is_flushing_effect(previously_flushing_effect); + if (should_run_effect) { + if (sync) { + var previously_flushing_effect = is_flushing_effect; + + try { + set_is_flushing_effect(true); + update_effect(effect); + effect.f |= EFFECT_RAN; + } catch (e) { + destroy_effect(effect); + throw e; + } finally { + set_is_flushing_effect(previously_flushing_effect); + } + } else if (fn !== null) { + schedule_effect(effect); } - } else if (fn !== null) { - schedule_effect(effect); } // if an effect has no dependencies, no DOM and no teardown function, // don't bother adding it to the effect tree var inert = + should_run_effect && sync && + !is_within_await && effect.deps === null && effect.first === null && effect.nodes_start === null && @@ -352,7 +363,7 @@ export function template_effect(fn) { value: '{expression}' }); } - return block(fn); + return block(fn, TEMPLATE_EFFECT); } /** @@ -371,6 +382,16 @@ export function branch(fn, push = true) { return create_effect(RENDER_EFFECT | BRANCH_EFFECT, fn, true, push); } +/** + * @param {(() => void)} fn + */ +export function script_effect(fn) { + if (active_effect === null || !is_within_await) { + return fn(); + } + return create_effect(RENDER_EFFECT, () => untrack(fn), true); +} + /** * @param {Effect} effect */ diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 4500a7c5a84a..3fdd351e4e06 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -34,7 +34,6 @@ import { import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { get_stack } from '../dev/tracing.js'; - export let inspect_effects = new Set(); /** diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index aba037c4a36b..3c263c18bd16 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -25,7 +25,9 @@ import { ROOT_EFFECT, LEGACY_DERIVED_PROP, DISCONNECTED, - BOUNDARY_EFFECT + BOUNDARY_EFFECT, + AWAIT_EFFECT, + PENDING } from './constants.js'; import { flush_idle_tasks, @@ -58,6 +60,7 @@ let last_scheduled_effect = null; export let is_flushing_effect = false; export let is_destroying_effect = false; +export let is_within_await = false; /** @param {boolean} value */ export function set_is_flushing_effect(value) { @@ -69,6 +72,11 @@ export function set_is_destroying_effect(value) { is_destroying_effect = value; } +/** @param {boolean} value */ +export function set_is_within_await(value) { + is_within_await = value; +} + // Handle effect queues /** @type {Effect[]} */ @@ -572,6 +580,7 @@ export function update_effect(effect) { var previous_effect = active_effect; var previous_component_context = component_context; + var previous_is_within_await = is_within_await; active_effect = effect; @@ -614,9 +623,16 @@ export function update_effect(effect) { dev_effect_stack.push(effect); } } catch (error) { + if (error === PENDING) { + set_signal_status(effect, DIRTY); + return; + } handle_error(error, effect, previous_effect, previous_component_context || effect.ctx); } finally { active_effect = previous_effect; + if ((flags & AWAIT_EFFECT) === 0 || previous_effect === null) { + is_within_await = previous_is_within_await; + } if (DEV) { dev_current_component_function = previous_component_fn; @@ -746,7 +762,6 @@ function flushed_deferred() { if (flush_count > 1001) { return; } - // flush_before_process_microtasks(); const previous_queued_root_effects = queued_root_effects; queued_root_effects = []; flush_queued_root_effects(previous_queued_root_effects); @@ -810,7 +825,7 @@ function process_effects(effect, collected_effects) { var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; var sibling = current_effect.next; - if (!is_skippable_branch && (flags & INERT) === 0) { + if (!is_skippable_branch && ((flags & INERT) === 0 || (flags & AWAIT_EFFECT) !== 0)) { if ((flags & RENDER_EFFECT) !== 0) { if (is_branch) { current_effect.f ^= CLEAN; @@ -820,7 +835,11 @@ function process_effects(effect, collected_effects) { update_effect(current_effect); } } catch (error) { - handle_error(error, current_effect, null, current_effect.ctx); + if (error === PENDING) { + set_signal_status(current_effect, DIRTY); + } else { + handle_error(error, current_effect, null, current_effect.ctx); + } } } @@ -1013,7 +1032,16 @@ export function get(signal) { } } - return signal.v; + value = signal.v; + + if ( + (flags & INERT) !== 0 && + (is_within_await || (active_effect !== null && (active_effect.f & INERT) !== 0)) + ) { + throw PENDING; + } + + return value; } /** @@ -1269,6 +1297,9 @@ export function exclude_from_object(obj, keys) { * @returns {void} */ export function push(props, runes = false, fn) { + if (is_within_await && !runes) { + throw new Error('Cannot use $await in a component that renders a legacy component') + } component_context = { p: component_context, c: null, diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index 76486d32ac4e..a41ea9d4850d 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -429,6 +429,7 @@ const RUNES = /** @type {const} */ ([ '$effect.pre', '$effect.tracking', '$effect.root', + '$await', '$inspect', '$inspect().with', '$inspect.trace', diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index d00b2b01ed18..21cd626cdf42 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -3157,4 +3157,6 @@ declare namespace $host { export const toString: never; } +declare function $async(value: Promise): [V, undefined | Promise]; + //# sourceMappingURL=index.d.ts.map