UNPKG

svelte

Version:

Cybernetically enhanced web apps

535 lines (442 loc) 13.3 kB
/** @import { Effect, Source, TemplateNode, } from '#client' */ import { BOUNDARY_EFFECT, DIRTY, EFFECT_PRESERVED, EFFECT_TRANSPARENT, MAYBE_DIRTY } from '#client/constants'; import { HYDRATION_START_ELSE, HYDRATION_START_FAILED } from '../../../../constants.js'; import { component_context, set_component_context } from '../../context.js'; import { handle_error, invoke_error_boundary } from '../../error-handling.js'; import { block, branch, destroy_effect, move_effect, pause_effect } from '../../reactivity/effects.js'; import { active_effect, active_reaction, get, set_active_effect, set_active_reaction } from '../../runtime.js'; import { hydrate_next, hydrate_node, hydrating, next, skip_nodes, set_hydrate_node } from '../hydration.js'; import { queue_micro_task } from '../task.js'; import * as e from '../../errors.js'; import * as w from '../../warnings.js'; import { DEV } from 'esm-env'; import { Batch, schedule_effect } from '../../reactivity/batch.js'; import { internal_set, source } from '../../reactivity/sources.js'; import { tag } from '../../dev/tracing.js'; import { createSubscriber } from '../../../../reactivity/create-subscriber.js'; import { create_text } from '../operations.js'; import { defer_effect } from '../../reactivity/utils.js'; import { set_signal_status } from '../../reactivity/status.js'; /** * @typedef {{ * onerror?: (error: unknown, reset: () => void) => void; * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void; * pending?: (anchor: Node) => void; * }} BoundaryProps */ var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED; /** * @param {TemplateNode} node * @param {BoundaryProps} props * @param {((anchor: Node) => void)} children * @param {((error: unknown) => unknown) | undefined} [transform_error] * @returns {void} */ export function boundary(node, props, children, transform_error) { new Boundary(node, props, children, transform_error); } export class Boundary { /** @type {Boundary | null} */ parent; is_pending = false; /** * API-level transformError transform function. Transforms errors before they reach the `failed` snippet. * Inherited from parent boundary, or defaults to identity. * @type {(error: unknown) => unknown} */ transform_error; /** @type {TemplateNode} */ #anchor; /** @type {TemplateNode | null} */ #hydrate_open = hydrating ? hydrate_node : null; /** @type {BoundaryProps} */ #props; /** @type {((anchor: Node) => void)} */ #children; /** @type {Effect} */ #effect; /** @type {Effect | null} */ #main_effect = null; /** @type {Effect | null} */ #pending_effect = null; /** @type {Effect | null} */ #failed_effect = null; /** @type {DocumentFragment | null} */ #offscreen_fragment = null; #local_pending_count = 0; #pending_count = 0; #pending_count_update_queued = false; /** @type {Set<Effect>} */ #dirty_effects = new Set(); /** @type {Set<Effect>} */ #maybe_dirty_effects = new Set(); /** * A source containing the number of pending async deriveds/expressions. * Only created if `$effect.pending()` is used inside the boundary, * otherwise updating the source results in needless `Batch.ensure()` * calls followed by no-op flushes * @type {Source<number> | null} */ #effect_pending = null; #effect_pending_subscriber = createSubscriber(() => { this.#effect_pending = source(this.#local_pending_count); if (DEV) { tag(this.#effect_pending, '$effect.pending()'); } return () => { this.#effect_pending = null; }; }); /** * @param {TemplateNode} node * @param {BoundaryProps} props * @param {((anchor: Node) => void)} children * @param {((error: unknown) => unknown) | undefined} [transform_error] */ constructor(node, props, children, transform_error) { this.#anchor = node; this.#props = props; this.#children = (anchor) => { var effect = /** @type {Effect} */ (active_effect); effect.b = this; effect.f |= BOUNDARY_EFFECT; children(anchor); }; this.parent = /** @type {Effect} */ (active_effect).b; // Inherit transform_error from parent boundary, or use the provided one, or default to identity this.transform_error = transform_error ?? this.parent?.transform_error ?? ((e) => e); this.#effect = block(() => { if (hydrating) { const comment = /** @type {Comment} */ (this.#hydrate_open); hydrate_next(); const server_rendered_pending = comment.data === HYDRATION_START_ELSE; const server_rendered_failed = comment.data.startsWith(HYDRATION_START_FAILED); if (server_rendered_failed) { // Server rendered the failed snippet - hydrate it. // The serialized error is embedded in the comment: <!--[?<json>--> const serialized_error = JSON.parse(comment.data.slice(HYDRATION_START_FAILED.length)); this.#hydrate_failed_content(serialized_error); } else if (server_rendered_pending) { this.#hydrate_pending_content(); } else { this.#hydrate_resolved_content(); } } else { this.#render(); } }, flags); if (hydrating) { this.#anchor = hydrate_node; } } #hydrate_resolved_content() { try { this.#main_effect = branch(() => this.#children(this.#anchor)); } catch (error) { this.error(error); } } /** * @param {unknown} error The deserialized error from the server's hydration comment */ #hydrate_failed_content(error) { const failed = this.#props.failed; if (!failed) return; this.#failed_effect = branch(() => { failed( this.#anchor, () => error, () => () => {} ); }); } #hydrate_pending_content() { const pending = this.#props.pending; if (!pending) return; this.is_pending = true; this.#pending_effect = branch(() => pending(this.#anchor)); queue_micro_task(() => { var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); var anchor = create_text(); fragment.append(anchor); this.#main_effect = this.#run(() => { Batch.ensure(); return branch(() => this.#children(anchor)); }); if (this.#pending_count === 0) { this.#anchor.before(fragment); this.#offscreen_fragment = null; pause_effect(/** @type {Effect} */ (this.#pending_effect), () => { this.#pending_effect = null; }); this.#resolve(); } }); } #render() { try { this.is_pending = this.has_pending_snippet(); this.#pending_count = 0; this.#local_pending_count = 0; this.#main_effect = branch(() => { this.#children(this.#anchor); }); if (this.#pending_count > 0) { var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); move_effect(this.#main_effect, fragment); const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); this.#pending_effect = branch(() => pending(this.#anchor)); } else { this.#resolve(); } } catch (error) { this.error(error); } } #resolve() { this.is_pending = false; // any effects that were previously deferred should be rescheduled — // after the next traversal (which will happen immediately, due to the // same update that brought us here) the effects will be flushed for (const e of this.#dirty_effects) { set_signal_status(e, DIRTY); schedule_effect(e); } for (const e of this.#maybe_dirty_effects) { set_signal_status(e, MAYBE_DIRTY); schedule_effect(e); } this.#dirty_effects.clear(); this.#maybe_dirty_effects.clear(); } /** * Defer an effect inside a pending boundary until the boundary resolves * @param {Effect} effect */ defer_effect(effect) { defer_effect(effect, this.#dirty_effects, this.#maybe_dirty_effects); } /** * Returns `false` if the effect exists inside a boundary whose pending snippet is shown * @returns {boolean} */ is_rendered() { return !this.is_pending && (!this.parent || this.parent.is_rendered()); } has_pending_snippet() { return !!this.#props.pending; } /** * @template T * @param {() => T} fn */ #run(fn) { var previous_effect = active_effect; var previous_reaction = active_reaction; var previous_ctx = component_context; set_active_effect(this.#effect); set_active_reaction(this.#effect); set_component_context(this.#effect.ctx); try { return fn(); } catch (e) { handle_error(e); return null; } finally { set_active_effect(previous_effect); set_active_reaction(previous_reaction); set_component_context(previous_ctx); } } /** * Updates the pending count associated with the currently visible pending snippet, * if any, such that we can replace the snippet with content once work is done * @param {1 | -1} d */ #update_pending_count(d) { if (!this.has_pending_snippet()) { if (this.parent) { this.parent.#update_pending_count(d); } // if there's no parent, we're in a scope with no pending snippet return; } this.#pending_count += d; if (this.#pending_count === 0) { this.#resolve(); if (this.#pending_effect) { pause_effect(this.#pending_effect, () => { this.#pending_effect = null; }); } if (this.#offscreen_fragment) { this.#anchor.before(this.#offscreen_fragment); this.#offscreen_fragment = null; } } } /** * Update the source that powers `$effect.pending()` inside this boundary, * and controls when the current `pending` snippet (if any) is removed. * Do not call from inside the class * @param {1 | -1} d */ update_pending_count(d) { this.#update_pending_count(d); this.#local_pending_count += d; if (!this.#effect_pending || this.#pending_count_update_queued) return; this.#pending_count_update_queued = true; queue_micro_task(() => { this.#pending_count_update_queued = false; if (this.#effect_pending) { internal_set(this.#effect_pending, this.#local_pending_count); } }); } get_effect_pending() { this.#effect_pending_subscriber(); return get(/** @type {Source<number>} */ (this.#effect_pending)); } /** @param {unknown} error */ error(error) { var onerror = this.#props.onerror; let failed = this.#props.failed; // 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 (!onerror && !failed) { throw error; } if (this.#main_effect) { destroy_effect(this.#main_effect); this.#main_effect = null; } if (this.#pending_effect) { destroy_effect(this.#pending_effect); this.#pending_effect = null; } if (this.#failed_effect) { destroy_effect(this.#failed_effect); this.#failed_effect = null; } if (hydrating) { set_hydrate_node(/** @type {TemplateNode} */ (this.#hydrate_open)); next(); set_hydrate_node(skip_nodes()); } var did_reset = false; var calling_on_error = false; const reset = () => { if (did_reset) { w.svelte_boundary_reset_noop(); return; } did_reset = true; if (calling_on_error) { e.svelte_boundary_reset_onerror(); } if (this.#failed_effect !== null) { pause_effect(this.#failed_effect, () => { this.#failed_effect = null; }); } this.#run(() => { // If the failure happened while flushing effects, current_batch can be null Batch.ensure(); this.#render(); }); }; /** @param {unknown} transformed_error */ const handle_error_result = (transformed_error) => { try { calling_on_error = true; onerror?.(transformed_error, reset); calling_on_error = false; } catch (error) { invoke_error_boundary(error, this.#effect && this.#effect.parent); } if (failed) { this.#failed_effect = this.#run(() => { Batch.ensure(); try { return branch(() => { // errors in `failed` snippets cause the boundary to error again // TODO Svelte 6: revisit this decision, most likely better to go to parent boundary instead var effect = /** @type {Effect} */ (active_effect); effect.b = this; effect.f |= BOUNDARY_EFFECT; failed( this.#anchor, () => transformed_error, () => reset ); }); } catch (error) { invoke_error_boundary(error, /** @type {Effect} */ (this.#effect.parent)); return null; } }); } }; queue_micro_task(() => { // Run the error through the API-level transformError transform (e.g. SvelteKit's handleError) /** @type {unknown} */ var result; try { result = this.transform_error(error); } catch (e) { invoke_error_boundary(e, this.#effect && this.#effect.parent); return; } if ( result !== null && typeof result === 'object' && typeof (/** @type {any} */ (result).then) === 'function' ) { // transformError returned a Promise — wait for it /** @type {any} */ (result).then( handle_error_result, /** @param {unknown} e */ (e) => invoke_error_boundary(e, this.#effect && this.#effect.parent) ); } else { // Synchronous result — handle immediately handle_error_result(result); } }); } } export function pending() { if (active_effect === null) { e.effect_pending_outside_reaction(); } var boundary = active_effect.b; if (boundary === null) { return 0; // TODO eventually we will need this to be global } return boundary.get_effect_pending(); }