'use client'; /** * The client's `.catch` boundary. * * `.catch` maps a REC's typed failures to fallback UI. On the client it must be a * real React **error boundary**, not an inline catch: a body that fails part-way * has called *fewer* hooks than a successful render, and the moment that happens * on a re-render React tears the subtree down ("rendered fewer hooks than * expected"). A component that *throws* is discarded by React — partial hooks and * all — so the boundary can render the fallback safely no matter where the * failing `yield*` sat in the body. (The server driver has no hooks, so it * catches inline; this is the client's half only.) * * Recovery: while a fallback is shown, the boundary watches the atoms the body * read before it failed — which include a `query`'s key inputs — and resets when * any of them changes, re-mounting the REC so it re-runs (and succeeds once the * failing input is gone). */ import { Component, Fragment, createContext, createElement, useEffect, type ReactElement, type ReactNode, } from 'react'; import * as Cause from 'effect/Cause'; import { CAUSE_KEY, type CatchApi, type CatchDispatch, type ReadableAtom, } from '#domain/protocol.ts'; import { devConnected, emitDev } from '#infrastructure/devtools-core.ts'; /** Dev only; a bundler replaces `process.env.NODE_ENV` so this is a no-op in prod. */ const isDevMode = (): boolean => typeof process !== 'undefined' && process.env?.['NODE_ENV'] !== 'production'; /** The readable account of a defect: its pretty `Cause` if stashed, else its stack. */ const defectDetail = (error: unknown): string => { const cause = typeof error === 'object' && error !== null ? (error as Record)[CAUSE_KEY] : undefined; return Cause.isCause(cause) ? Cause.pretty(cause) : ((error as Error)?.stack ?? String(error)); }; /** * In development, turn a defect that no `.catch` handled into a readable error — * the pretty-printed `Cause` (stashed by the interpreter) with the failing REC's * name — so it reaches React's overlay/console with component context instead of a * bare message (#57). In production the original error passes through untouched * (same identity, zero overhead). Either way it re-propagates — never swallowed. */ const enrichDefect = (error: unknown, name: string | undefined): unknown => { if (!isDevMode() || typeof error !== 'object' || error === null) { return error; } const where = name === undefined ? '' : ` in <${name}>`; return new Error(`effract: unhandled failure${where}\n\n${defectDetail(error)}`, { cause: error, }); }; /** * A `.catchDefect` fallback absorbed a failure the tag dispatch did not: keep it * dev-visible through the devtools seam (#57). Never a `console.*` — the library * ships silent; in production this is one boolean check. */ export const reportHandledDefect = (error: unknown, name: string | undefined): void => { if (isDevMode() && devConnected()) { emitDev({ kind: 'defect:handled', rec: name ?? 'EffractComponent', detail: defectDetail(error), }); } }; /** * The per-boundary set a REC records its read atoms into, so the boundary can * watch them for a reset. `null` when a REC renders outside any `.catch`. */ export const ReadSink = createContext> | null>(null); const tagOf = (error: unknown): string | undefined => typeof error === 'object' && error !== null && typeof (error as { readonly _tag?: unknown })._tag === 'string' ? (error as { readonly _tag: string })._tag : undefined; interface BoundaryProps { readonly handlers: CatchDispatch; readonly defectHandler: ((defect: unknown, api: CatchApi) => ReactNode) | undefined; readonly sink: Set>; readonly name: string | undefined; readonly children: ReactNode; } /** * Catches a thrown typed error and renders its `.catch` fallback. A suspension (a * thrown thenable) is not an error — React routes it to ``, never here. * A defect, or a tag this REC did not name, renders the `.catchDefect` fallback if * the REC opted into one (tag handlers win; the absorption is dev-reported), and * is otherwise re-thrown so the next boundary up handles it; effract never * *silently* swallows a defect. */ class Boundary extends Component { override state: { error: unknown } = { error: null }; static getDerivedStateFromError(error: unknown): { error: unknown } { return { error }; } private readonly reset = (): void => this.setState({ error: null }); override render(): ReactNode { const { error } = this.state; if (error === null) { return this.props.children; } const tag = tagOf(error); const handler = tag === undefined ? undefined : this.props.handlers[tag]; const fallback = handler ?? this.props.defectHandler; if (fallback === undefined) { // Not this REC's to handle — re-propagate to the next boundary (never // swallowed). In dev, enrich it with the pretty cause + REC name first. throw enrichDefect(error, this.props.name); } if (handler === undefined) { reportHandledDefect(error, this.props.name); } return createElement( Fragment, null, createElement(ResetWatcher, { atoms: this.props.sink, onReset: this.reset }), // `retry` re-mounts the subtree (fresh queries) — a fallback can offer a button. fallback(error, { retry: this.reset }), ); } } /** * While the fallback is shown, subscribe to the atoms the failed body read and * reset the boundary when any changes — so the REC re-mounts and re-runs. */ const ResetWatcher = ({ atoms, onReset, }: { readonly atoms: Set>; readonly onReset: () => void; }): null => { useEffect(() => { const unsubscribes = Array.from(atoms, (atom) => atom.subscribe(onReset)); return () => { for (const unsubscribe of unsubscribes) { unsubscribe(); } }; }, [atoms, onReset]); return null; }; /** * Wrap a REC element in its `.catch`/`.catchDefect` boundary: a fresh read-sink * is provided to the child (which records into it as it reads atoms) and handed * to the boundary (which watches it for a reset). */ export const withCatch = ( handlers: CatchDispatch | undefined, defectHandler: ((defect: unknown, api: CatchApi) => ReactNode) | undefined, child: ReactElement, name?: string, ): ReactElement => { const sink = new Set>(); return createElement( ReadSink.Provider, { value: sink }, createElement(Boundary, { handlers: handlers ?? {}, defectHandler, sink, name, children: child, }), ); };