/** * errorScope — setup-time error boundary for the calling component's subtree * (docs/rfc-async.md §4). Not a wrapper component: call it inside setup and * the component's own subtree is scoped. * * const Widget = component((ctx) => { * errorScope({ fallback: (e, retry) => }); * return () => ; * }); * * Catches: the component's own render throws, and descendant setup / render / * reactive re-render throws (routed here by handleComponentError's parent- * chain walk). Also receives unhandled data errors bubbled by `match()` * (a cell errored with no `error` arm). * * Does NOT catch: fetcher rejections (they land on the cell's `.error` — * value-first), or DOM event-handler throws (those go to app `onError`). * * `retry` is a real teardown: the subtree renders under a keyed Fragment * whose key bumps on retry, forcing unmount (every descendant effect * stopped, onUnmounted run) + fresh mount — not a flag flip over stale state. */ import { signal, batch } from '@sigx/reactivity'; import { getCurrentInstance } from './component-lifecycle.js'; import { createToken } from './di/token.js'; import { jsx, Fragment, type JSXElement } from './jsx-runtime.js'; import type { ViewFn } from './component-types.js'; import type { ComponentInstance } from './app-types.js'; import { errorScopeOutsideSetupError } from './errors.js'; export interface ErrorScopeOptions { /** Rendered in place of the subtree while errored. Omitted ⇒ renders nothing. */ fallback?: (error: Error, retry: () => void) => JSXElement; /** Observer — called before the fallback renders. Its own throws are swallowed. */ onError?: (error: Error, instance: ComponentInstance | null, info: string) => void; } /** DI token under which a scope's handle lives on the owning ctx. @internal */ export const ERROR_SCOPE_TOKEN = createToken('sigx:errorScope'); /** * Server-rendered error staged for the NEXT errorScope() call — the SSR * hydrator seeds it right before hydrating a component whose boundary * record carries a server-caught scope error, so the scope starts in the * errored state, the fallback hydrates against the server's fallback HTML, * and `retry` is live (a real remount). @internal */ let _pendingScopeError: Error | null = null; /** Stage (or clear, with null) the server-caught error for the next scope. @internal */ export function seedErrorScopeError(error: Error | null): void { _pendingScopeError = error; } /** @internal */ export interface ErrorScopeHandle { /** Returns true when this scope takes the error (renders its fallback). */ handle(err: Error, instance: ComponentInstance | null, info: string): boolean; } interface InternalErrorScope { state: { error: Error | null; generation: number }; retry: () => void; fallback?: (error: Error, retry: () => void) => JSXElement; } /** * Scope the calling component's subtree. Setup-only. */ export function errorScope(options: ErrorScopeOptions): void { const ctx = getCurrentInstance(); if (!ctx) { throw errorScopeOutsideSetupError(); } const node = ctx as unknown as { provides?: Map; __errorScope?: InternalErrorScope }; if (node.__errorScope) { if (__DEV__) { console.warn('[errorScope] called twice in one component setup — the second call is ignored.'); } return; } // A server-caught error staged by the hydrator seeds the scope errored: // the first render is the fallback (matching the server's fallback HTML) // and retry() performs the remount the server could not. const seeded = _pendingScopeError; _pendingScopeError = null; const state = signal({ error: seeded, generation: 0 }); // Synchronous mirror of `state.error !== null`: handle() must decide // (and dedupe) synchronously, but the reactive write is deferred below. let errored = seeded !== null; const retry = () => { errored = false; batch(() => { // The generation key bump forces the keyed subtree Fragment to // remount (different-key patch = unmount + mount): descendant // effects stop, onUnmounted hooks run, state starts fresh. state.generation++; state.error = null; }); }; const handle: ErrorScopeHandle = { handle(err, instance, info) { // Already showing the fallback — a throw from the fallback itself // (or a sibling error racing in) bubbles to the next scope up. if (errored) return false; errored = true; if (options.onError) { try { options.onError(err, instance, info); } catch (observerErr) { console.error('[errorScope] onError observer threw:', observerErr); } } // Descendant setup/first-render throws surface while THIS // component's render effect is still on the stack, and its // re-entrant notifications are dropped (render-loop guard). // Step out of the effect frame before the reactive write. queueMicrotask(() => { if (errored) state.error = err; }); return true; }, }; (node.provides ??= new Map()).set(ERROR_SCOPE_TOKEN, handle); node.__errorScope = { state, retry, fallback: options.fallback }; } /** * Wrap a component's render fn with its errorScope view: fallback while * errored, otherwise the subtree under the generation-keyed Fragment. * Called by the renderer after setup returns (renderFn exists only then). * * @internal */ export function wrapErrorScopeRender(original: ViewFn, es: InternalErrorScope): ViewFn { return () => { const err = es.state.error; if (err) { return es.fallback ? es.fallback(err, es.retry) : null; } return jsx(Fragment, { children: [original()] }, String(es.state.generation)); }; } /** @internal — renderer hook: wrap if the ctx carries a scope. */ export function applyErrorScope(ctx: unknown, renderFn: ViewFn): ViewFn { const es = (ctx as { __errorScope?: InternalErrorScope }).__errorScope; return es ? wrapErrorScopeRender(renderFn, es) : renderFn; }