diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 57a34ed3fa..1bb591d754 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -37,361 +37,400 @@ import { from_async_derived, set_from_async_derived } from '../../reactivity/der import { raf } from '../../timing.js'; import { loop } from '../../loop.js'; -const ASYNC_INCREMENT = Symbol(); -const ASYNC_DECREMENT = Symbol(); -const ADD_CALLBACK = Symbol(); -const ADD_RENDER_EFFECT = Symbol(); -const ADD_EFFECT = Symbol(); -const COMMIT = Symbol(); +/** @type {Boundary | null} */ +export let active_boundary = null; -/** - * @param {Effect} boundary - * @param {() => Effect | null} fn - * @returns {Effect | null} - */ -function with_boundary(boundary, fn) { - var previous_effect = active_effect; - var previous_reaction = active_reaction; - var previous_ctx = component_context; - - set_active_effect(boundary); - set_active_reaction(boundary); - set_component_context(boundary.ctx); - - try { - return fn(); - } finally { - set_active_effect(previous_effect); - set_active_reaction(previous_reaction); - set_component_context(previous_ctx); - } +/** @param {Boundary | null} boundary */ +export function set_active_boundary(boundary) { + active_boundary = boundary; } +class Boundary { + /** @type {Boundary | null} */ + #parent; + + /** @type {Effect} */ + #effect; + + /** @type {Set<() => void>} */ + #callbacks = new Set(); + + /** + * @param {TemplateNode} node + * @param {{ + * 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 + */ + constructor(node, props, children) { + var anchor = node; + + this.#parent = active_boundary; + + active_boundary = this; + + var parent_boundary = find_boundary(active_effect); + + this.#effect = block(() => { + /** @type {Effect | null} */ + var main_effect = null; + + /** @type {Effect | null} */ + var pending_effect = null; + + /** @type {Effect | null} */ + var failed_effect = null; + + /** @type {DocumentFragment | null} */ + var offscreen_fragment = null; + + var async_count = 0; + var boundary_effect = /** @type {Effect} */ (active_effect); + var hydrate_open = hydrate_node; + var is_creating_fallback = false; + + /** @type {Effect[]} */ + var render_effects = []; + + /** @type {Effect[]} */ + var effects = []; + + var keep_pending_snippet = false; + + /** + * @param {() => void} snippet_fn + * @returns {Effect | null} + */ + const render_snippet = (snippet_fn) => { + return this.#run(() => { + is_creating_fallback = true; + + try { + return branch(snippet_fn); + } catch (error) { + handle_error(error, boundary_effect, null, boundary_effect.ctx); + return null; + } finally { + reset_is_throwing_error(); + is_creating_fallback = false; + } + }); + }; -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; - * showPendingAfter?: number; - * showPendingFor?: number; - * }} props - * @param {((anchor: Node) => void)} children - * @returns {void} - */ -export function boundary(node, props, children) { - var anchor = node; - - var parent_boundary = find_boundary(active_effect); - - block(() => { - /** @type {Effect | null} */ - var main_effect = null; - - /** @type {Effect | null} */ - var pending_effect = null; - - /** @type {Effect | null} */ - var failed_effect = null; - - /** @type {DocumentFragment | null} */ - var offscreen_fragment = null; - - var async_count = 0; - var boundary = /** @type {Effect} */ (active_effect); - var hydrate_open = hydrate_node; - var is_creating_fallback = false; + const reset = () => { + async_count = 0; - /** @type {Set<() => void>} */ - var callbacks = new Set(); + if ((boundary_effect.f & BOUNDARY_SUSPENDED) !== 0) { + boundary_effect.f ^= BOUNDARY_SUSPENDED; + } - /** @type {Effect[]} */ - var render_effects = []; + if (failed_effect !== null) { + pause_effect(failed_effect, () => { + failed_effect = null; + }); + } - /** @type {Effect[]} */ - var effects = []; + main_effect = this.#run(() => { + is_creating_fallback = false; - var keep_pending_snippet = false; + try { + return branch(() => children(anchor)); + } finally { + reset_is_throwing_error(); + } + }); - /** - * @param {() => void} snippet_fn - * @returns {Effect | null} - */ - function render_snippet(snippet_fn) { - return with_boundary(boundary, () => { - is_creating_fallback = true; + if (async_count > 0) { + boundary_effect.f |= BOUNDARY_SUSPENDED; + show_pending_snippet(true); + } + }; - try { - return branch(snippet_fn); - } catch (error) { - handle_error(error, boundary, null, boundary.ctx); - return null; - } finally { - reset_is_throwing_error(); - is_creating_fallback = false; + const unsuspend = () => { + if (keep_pending_snippet || async_count > 0) { + return; } - }); - } - function reset() { - async_count = 0; + if ((boundary_effect.f & BOUNDARY_SUSPENDED) !== 0) { + boundary_effect.f ^= BOUNDARY_SUSPENDED; + } - if ((boundary.f & BOUNDARY_SUSPENDED) !== 0) { - boundary.f ^= BOUNDARY_SUSPENDED; - } + for (const e of render_effects) { + try { + if (check_dirtiness(e)) { + update_effect(e); + } + } catch (error) { + handle_error(error, e, null, e.ctx); + } + } - if (failed_effect !== null) { - pause_effect(failed_effect, () => { - failed_effect = null; - }); - } + for (const fn of this.#callbacks) fn(); + this.#callbacks.clear(); - main_effect = with_boundary(boundary, () => { - is_creating_fallback = false; + if (pending_effect) { + pause_effect(pending_effect, () => { + pending_effect = null; + }); + } - try { - return branch(() => children(anchor)); - } finally { - reset_is_throwing_error(); + if (offscreen_fragment) { + anchor.before(offscreen_fragment); + offscreen_fragment = null; } - }); - if (async_count > 0) { - boundary.f |= BOUNDARY_SUSPENDED; - show_pending_snippet(true); - } - } + for (const e of effects) { + try { + if (check_dirtiness(e)) { + update_effect(e); + } + } catch (error) { + handle_error(error, e, null, e.ctx); + } + } + }; - function unsuspend() { - if (keep_pending_snippet || async_count > 0) { - return; - } + /** + * @param {boolean} initial + */ + function show_pending_snippet(initial) { + const pending = props.pending; - if ((boundary.f & BOUNDARY_SUSPENDED) !== 0) { - boundary.f ^= BOUNDARY_SUSPENDED; - } + if (pending !== undefined) { + // TODO can this be false? + if (main_effect !== null) { + offscreen_fragment = document.createDocumentFragment(); + move_effect(main_effect, offscreen_fragment); + } - for (const e of render_effects) { - try { - if (check_dirtiness(e)) { - update_effect(e); + if (pending_effect === null) { + pending_effect = branch(() => pending(anchor)); } - } catch (error) { - handle_error(error, e, null, e.ctx); - } - } - for (const fn of callbacks) fn(); - callbacks.clear(); + // TODO do we want to differentiate between initial render and updates here? + if (!initial) { + keep_pending_snippet = true; - if (pending_effect) { - pause_effect(pending_effect, () => { - pending_effect = null; - }); - } + var end = raf.now() + (props.showPendingFor ?? 300); - if (offscreen_fragment) { - anchor.before(offscreen_fragment); - offscreen_fragment = null; - } + loop((now) => { + if (now >= end) { + keep_pending_snippet = false; + unsuspend(); + return false; + } - for (const e of effects) { - try { - if (check_dirtiness(e)) { - update_effect(e); + return true; + }); } - } catch (error) { - handle_error(error, e, null, e.ctx); + } else if (parent_boundary) { + throw new Error('TODO show pending snippet on parent'); + } else { + throw new Error('no pending snippet to show'); } } - } - /** - * @param {boolean} initial - */ - function show_pending_snippet(initial) { - const pending = props.pending; + // @ts-ignore We re-use the effect's fn property to avoid allocation of an additional field + boundary_effect.fn = (/** @type {unknown} */ input, /** @type {any} */ payload) => { + if (input === ASYNC_INCREMENT) { + // post-init, show the pending snippet after a timeout + if ( + (boundary_effect.f & BOUNDARY_SUSPENDED) === 0 && + (boundary_effect.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); + }); + } - if (pending !== undefined) { - // TODO can this be false? - if (main_effect !== null) { - offscreen_fragment = document.createDocumentFragment(); - move_effect(main_effect, offscreen_fragment); - } + boundary_effect.f |= BOUNDARY_SUSPENDED; + async_count++; - if (pending_effect === null) { - pending_effect = branch(() => pending(anchor)); + return; } - // TODO do we want to differentiate between initial render and updates here? - if (!initial) { - keep_pending_snippet = true; + if (input === ASYNC_DECREMENT) { + if (--async_count === 0 && !keep_pending_snippet) { + unsuspend(); - var end = raf.now() + (props.showPendingFor ?? 300); - - loop((now) => { - if (now >= end) { - keep_pending_snippet = false; - unsuspend(); - return false; + if (main_effect !== null) { + // TODO do we also need to `resume_effect` here? + schedule_effect(main_effect); } + } - return true; - }); + return; } - } 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); + if (input === ADD_RENDER_EFFECT) { + render_effects.push(payload); + return; + } - loop((now) => { - if (async_count === 0) return false; - if (now < end) return true; + if (input === ADD_EFFECT) { + effects.push(payload); + return; + } - show_pending_snippet(false); - }); + if (input === COMMIT) { + unsuspend(); + return; } - boundary.f |= BOUNDARY_SUSPENDED; - async_count++; + var error = input; + var onerror = props.onerror; + let failed = props.failed; - return; - } + // If we have nothing to capture the error, or if we hit an error while + // rendering the fallback, re-throw for another boundary to handle + if (is_creating_fallback || (!onerror && !failed)) { + throw error; + } - if (input === ASYNC_DECREMENT) { - if (--async_count === 0 && !keep_pending_snippet) { - unsuspend(); + onerror?.(error, reset); - if (main_effect !== null) { - // TODO do we also need to `resume_effect` here? - schedule_effect(main_effect); - } + if (main_effect) { + destroy_effect(main_effect); + main_effect = null; } - return; - } - - if (input === ADD_CALLBACK) { - callbacks.add(payload); - return; - } + if (pending_effect) { + destroy_effect(pending_effect); + pending_effect = null; + } - if (input === ADD_RENDER_EFFECT) { - render_effects.push(payload); - return; - } + if (failed_effect) { + destroy_effect(failed_effect); + failed_effect = null; + } - if (input === ADD_EFFECT) { - effects.push(payload); - return; - } + if (hydrating) { + set_hydrate_node(hydrate_open); + next(); + set_hydrate_node(remove_nodes()); + } - if (input === COMMIT) { - unsuspend(); - return; - } + if (failed) { + queue_boundary_micro_task(() => { + failed_effect = render_snippet(() => { + failed( + anchor, + () => error, + () => reset + ); + }); + }); + } + }; - var error = input; - var onerror = props.onerror; - let failed = props.failed; + // @ts-ignore + boundary_effect.fn.is_pending = () => props.pending; - // If we have nothing to capture the error, or if we hit an error while - // rendering the fallback, re-throw for another boundary to handle - if (is_creating_fallback || (!onerror && !failed)) { - throw error; + if (hydrating) { + hydrate_next(); } - onerror?.(error, reset); + const pending = props.pending; - if (main_effect) { - destroy_effect(main_effect); - main_effect = null; - } + if (hydrating && pending) { + pending_effect = branch(() => pending(anchor)); - if (pending_effect) { - destroy_effect(pending_effect); - pending_effect = null; - } + // ...now what? we need to start rendering `boundary_fn` offscreen, + // and either insert the resulting fragment (if nothing suspends) + // or keep the pending effect alive until it unsuspends. + // not exactly sure how to do that. - if (failed_effect) { - destroy_effect(failed_effect); - failed_effect = null; - } + // future work: when we have some form of async SSR, we will + // need to use hydration boundary comments to report whether + // the pending or main block was rendered for a given + // boundary, and hydrate accordingly + queueMicrotask(() => { + destroy_effect(/** @type {Effect} */ (pending_effect)); - if (hydrating) { - set_hydrate_node(hydrate_open); - next(); - set_hydrate_node(remove_nodes()); - } - - if (failed) { - queue_boundary_micro_task(() => { - failed_effect = render_snippet(() => { - failed( - anchor, - () => error, - () => reset - ); + main_effect = this.#run(() => { + return branch(() => children(anchor)); }); }); + } else { + main_effect = branch(() => children(anchor)); + + if (async_count > 0) { + boundary_effect.f |= BOUNDARY_SUSPENDED; + show_pending_snippet(true); + } } - }; - // @ts-ignore - boundary.fn.is_pending = () => props.pending; + reset_is_throwing_error(); + }, flags); if (hydrating) { - hydrate_next(); + anchor = hydrate_node; } - const pending = props.pending; - - if (hydrating && pending) { - pending_effect = branch(() => pending(anchor)); - - // ...now what? we need to start rendering `boundary_fn` offscreen, - // and either insert the resulting fragment (if nothing suspends) - // or keep the pending effect alive until it unsuspends. - // not exactly sure how to do that. + active_boundary = this.#parent; + } - // future work: when we have some form of async SSR, we will - // need to use hydration boundary comments to report whether - // the pending or main block was rendered for a given - // boundary, and hydrate accordingly - queueMicrotask(() => { - destroy_effect(/** @type {Effect} */ (pending_effect)); + /** + * @param {() => Effect | null} fn + */ + #run(fn) { + var previous_boundary = active_boundary; + var previous_effect = active_effect; + var previous_reaction = active_reaction; + var previous_ctx = component_context; + + active_boundary = this; + set_active_effect(this.#effect); + set_active_reaction(this.#effect); + set_component_context(this.#effect.ctx); + + try { + return fn(); + } finally { + active_boundary = previous_boundary; + set_active_effect(previous_effect); + set_active_reaction(previous_reaction); + set_component_context(previous_ctx); + } + } - main_effect = with_boundary(boundary, () => { - return branch(() => children(anchor)); - }); - }); - } else { - main_effect = branch(() => children(anchor)); + /** @param {() => void} fn */ + add_callback(fn) { + this.#callbacks.add(fn); + } +} - if (async_count > 0) { - boundary.f |= BOUNDARY_SUSPENDED; - show_pending_snippet(true); - } - } +const ASYNC_INCREMENT = Symbol(); +const ASYNC_DECREMENT = Symbol(); +const ADD_RENDER_EFFECT = Symbol(); +const ADD_EFFECT = Symbol(); +const COMMIT = Symbol(); - reset_is_throwing_error(); - }, flags); +var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT; - if (hydrating) { - anchor = hydrate_node; - } +/** + * @param {TemplateNode} node + * @param {{ + * 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} + */ +export function boundary(node, props, children) { + new Boundary(node, props, children); } /** @@ -500,19 +539,6 @@ export function find_boundary(effect) { return effect; } -/** - * @param {Effect | null} boundary - * @param {Function} fn - */ -export function add_boundary_callback(boundary, fn) { - if (boundary === null) { - throw new Error('TODO'); - } - - // @ts-ignore - boundary.fn(ADD_CALLBACK, fn); -} - /** * @param {Effect} boundary * @param {Effect} effect diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index c72cc54270..e8b4feda99 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -39,7 +39,7 @@ import { queue_micro_task } from '../task.js'; import { active_effect, get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; -import { add_boundary_callback, find_boundary } from './boundary.js'; +import { active_boundary } from './boundary.js'; /** * The row of a keyed each block that is currently updating. We track this @@ -139,7 +139,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var was_empty = false; - var boundary = find_boundary(active_effect); + var boundary = active_boundary; /** @type {Map} */ var offscreen_items = new Map(); @@ -268,9 +268,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f fallback = branch(() => fallback_fn(anchor)); } } else { - var defer = boundary !== null && should_defer_append(); - - if (defer) { + if (boundary !== null && should_defer_append()) { for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); @@ -301,7 +299,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } - add_boundary_callback(boundary, commit); + boundary?.add_callback(commit); } else { commit(); } diff --git a/packages/svelte/src/internal/client/dom/blocks/if.js b/packages/svelte/src/internal/client/dom/blocks/if.js index d8dcfcbd58..2a6a52c446 100644 --- a/packages/svelte/src/internal/client/dom/blocks/if.js +++ b/packages/svelte/src/internal/client/dom/blocks/if.js @@ -10,8 +10,7 @@ import { } from '../hydration.js'; import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js'; import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; -import { active_effect } from '../../runtime.js'; -import { add_boundary_callback, find_boundary } from './boundary.js'; +import { active_boundary } from './boundary.js'; import { create_text, should_defer_append } from '../operations.js'; /** @@ -51,7 +50,7 @@ export function if_block(node, fn, elseif = false) { /** @type {Effect | null} */ var pending_effect = null; - var boundary = find_boundary(active_effect); + var boundary = active_boundary; function commit() { if (offscreen_fragment !== null) { @@ -123,7 +122,7 @@ export function if_block(node, fn, elseif = false) { } if (defer) { - add_boundary_callback(boundary, commit); + boundary?.add_callback(commit); target.remove(); } else { commit(); diff --git a/packages/svelte/src/internal/client/dom/blocks/key.js b/packages/svelte/src/internal/client/dom/blocks/key.js index 8e9c4bce43..4c6cce7d79 100644 --- a/packages/svelte/src/internal/client/dom/blocks/key.js +++ b/packages/svelte/src/internal/client/dom/blocks/key.js @@ -2,10 +2,9 @@ import { UNINITIALIZED } from '../../../../constants.js'; import { block, branch, pause_effect } from '../../reactivity/effects.js'; import { not_equal, safe_not_equal } from '../../reactivity/equality.js'; -import { active_effect } from '../../runtime.js'; import { is_runes } from '../../context.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; -import { add_boundary_callback, find_boundary } from './boundary.js'; +import { active_boundary } from './boundary.js'; import { create_text, should_defer_append } from '../operations.js'; /** @@ -34,7 +33,7 @@ export function key_block(node, get_key, render_fn) { /** @type {DocumentFragment | null} */ var offscreen_fragment = null; - var boundary = find_boundary(active_effect); + var boundary = active_boundary; var changed = is_runes() ? not_equal : safe_not_equal; @@ -68,7 +67,7 @@ export function key_block(node, get_key, render_fn) { pending_effect = branch(() => render_fn(target)); if (defer) { - add_boundary_callback(boundary, commit); + boundary?.add_callback(commit); target.remove(); } else { commit(); diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js index b59c24b029..330150a80c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js @@ -1,10 +1,9 @@ /** @import { TemplateNode, Dom, Effect } from '#client' */ import { EFFECT_TRANSPARENT } from '../../constants.js'; import { block, branch, pause_effect } from '../../reactivity/effects.js'; -import { active_effect } from '../../runtime.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; import { create_text, should_defer_append } from '../operations.js'; -import { add_boundary_callback, find_boundary } from './boundary.js'; +import { active_boundary } from './boundary.js'; /** * @template P @@ -33,7 +32,7 @@ export function component(node, get_component, render_fn) { /** @type {Effect | null} */ var pending_effect = null; - var boundary = find_boundary(active_effect); + var boundary = active_boundary; function commit() { if (effect) { @@ -70,7 +69,7 @@ export function component(node, get_component, render_fn) { } if (defer) { - add_boundary_callback(boundary, commit); + boundary?.add_callback(commit); } else { commit(); }