/** * The React Effect Component *descriptor* — the server-safe half of a REC. * * This module deliberately carries no `'use client'` and imports no React * hooks, no interpreter, and no runtime provider. A `rec(...)` value is pure * data: the component's body plus a stable identity. That is what lets a single * declaration be shared across runtimes — the same descriptor is turned into a * live client fiber by the client `mount` (see `./react/rec.tsx`) and driven on * the server by the server `mount` (see `./server/mount.ts`, selected by the * `react-server` export condition). Neither the body nor the descriptor is * client-only; only the *client interpreter* is. * * A REC is still **not** a React element type — `` is a compile error by * design. You compose RECs the way you compose Effects: with `yield*`, which is * how a child's Effect requirements (`R`) and loading obligation (`S`) bubble up * the tree to the one place that knows the runtime (`mount`), where they are * verified at compile time. */ import type { ReactNode } from 'react'; import type * as Equal from 'effect/Equal'; import { placement, type AnyEffect, type CatchApi, type CatchDispatch, type ErrorsOf, type Hook, type ReadableAtom, type RecBody, type RecHandle, type RecPlacement, type RequirementsOf, type SubscribesOf, type Suspensable, type SuspendsOf, type VolatilityOf, } from '#domain/protocol.ts'; /** Brand identifying a React Effect Component. */ export const RecTypeId: unique symbol = Symbol.for('@tmonier/effract/Rec'); export type RecTypeId = typeof RecTypeId; /** A tagged error — the shape `.catch` dispatches on (`Data.TaggedError`, …). */ type Tagged = { readonly _tag: string }; /** The non-tagged remainder of an error channel — errors `.catch` cannot name. */ type UntaggedErrors = Exclude; /** * The exhaustive handler map for a REC's error channel: one fallback per error * `_tag`, each receiving exactly that error. Omitting a tag the body can fail * with is a compile error (a missing property); an unknown tag is rejected as an * excess property. A REC that cannot fail with a tagged error takes `{}`. */ export type CatchHandlers = { readonly [Tag in Extract['_tag']]: ( error: Extract, api: CatchApi, ) => A; }; /** Any yieldable a REC body may produce — the constraint `rec` infers `Eff` against. */ type AnyYield = | AnyEffect | Hook | RecPlacement | Suspensable | ReadableAtom; interface RecCore extends RecHandle { readonly [RecTypeId]: true; /** Place this REC with props: `yield* Child.with({ ... })`. */ with(props: P): RecPlacement; /** * Render a typed fallback for each error this REC's body can fail with. The * handler map is exhaustive over the error channel `E` and checked at compile * time, so you cannot forget a tag or invent one. Each failure — synchronous * or async — renders its mapped node in place of the component; defects and * any non-tagged errors are left to the nearest React error boundary. * * ```tsx * const Profile = rec(function* () { * const user = yield* query(fetchUser(id), id); // E = NotFound | Unauthorized * return ; * }).catch({ * NotFound: () => , * Unauthorized: () => , * }); * ``` * * Returns a REC whose error channel keeps only the non-tagged remainder * (usually `never`); the loading obligation `S` is untouched. It catches *this* * REC's own `yield*`ed failures; a child REC handles its own (wrap it too). * * The fallback renders in place of the component and **recovers on its own**: * effract watches the atoms the body read (a `query`'s key inputs among them) * and re-runs the REC when one changes, so navigating to an input that no longer * fails brings the component back. The order of the body's `yield*`s does not * matter — a failure part-way through is handled wherever the failing yield sits. */ catch(handlers: CatchHandlers): REC, R, S, V, L>; /** * Opt into a **last-resort fallback** for everything `.catch` cannot name: a * defect (a thrown error, an `Effect.die`), a non-tagged failure, or a tag the * handler map did not list at runtime. Without it those always propagate to * the nearest React error boundary — that default is unchanged; this is the * explicit opt-out for a component that must stay standing no matter what * (a widget in a dashboard, a third-party island). * * ```tsx * const Widget = rec(function* () { * const data = yield* query(load, key); // may die on a bug, not just fail * return ; * }) * .catch({ NotFound: () => }) // typed failures first * .catchDefect((defect, { retry }) => ( // the unnameable remainder * * )); * ``` * * Deliberately not silent: tag handlers always win, the handler receives the * original thrown value untouched, and in development every absorbed defect is * reported through the devtools seam (`kind: 'defect:handled'`) with the * pretty-printed `Cause` — so a swallowed bug is still visible while you build. * Recovery matches `.catch`: the fallback re-mounts when a read atom changes, * and `retry` does so on demand. Returns a REC whose error channel is fully * discharged (`E = never`). */ catchDefect(handler: (defect: unknown, api: CatchApi) => ReactNode): REC; /** * Handle this REC's loading state. A REC that `yield*`s a `suspend`/`query` * carries a loading obligation `S`; `.suspense(fallback)` discharges it by * placing the REC in a real `` boundary, so while it is pending the * `fallback` renders in its place. * * ```tsx * const Page = Profile.suspense(); // Page: REC<…, never> — obligation met * ``` * * Returns a REC with `S` back to `never`. One `.suspense` discharges the whole * subtree beneath it (React Suspense catches every descendant that suspends), * so a single boundary — at any ancestor, or at `mount` via `{ loading }` — * satisfies the obligation the type system otherwise bubbles all the way up. */ suspense(fallback: ReactNode): REC; /** * Acknowledge this REC's volatility — the `.catchDefect` of scheduling. A body * that reads a volatile atom (one born from a high-frequency source) carries a * {@link VolatileNotHandled} obligation `V`, because that subscription * re-renders the component at the source's own frequency. `.hot()` is the * deliberate acknowledgment that this subtree is *meant* to run that hot — * a ticker cell, a live chart leaf — and discharges `V` back to `never`. * * ```tsx * const Ticker = rec(function* () { * const p = yield* price; // price: volatile — V = Volatile * return {p}; * }).hot(); // V discharged; keep the leaf small * ``` * * Deliberately not silent: in development the render ledger reports every * `.hot()` subtree and its render frequency, so an acknowledged hot spot stays * visible while you build — exactly how `.catchDefect` reports every absorbed * defect. Prefer `defer(atom)` at the read when the UI can lag a frame; reach * for `.hot()` only where source-frequency rendering is the point. */ hot(): REC; /** * Memoize this REC's placement — **explicit and type-guided**, never * automatic. The returned REC's client component is wrapped in `React.memo` * with a *bounded* comparator: per prop, `Object.is`, or `Equal.equals` when * both sides implement the `Equal` trait. Nothing ever deep-compares a plain * structure — which is exactly why `.memo()` is compile-gated: it only * returns a REC when every prop is **value-comparable** (a primitive, or an * `Equal.Equal` implementor), and otherwise returns * {@link MemoRequiresComparableProps} naming the offending prop(s). Hold * functions in a service; give structured data an `Equal` implementation. * * ```tsx * const Row = rec(function* (props: { id: string; count: number }) { * return
  • {props.id}: {props.count}
  • ; * }); * const MemoRow = Row.memo(); // ✓ all props value-comparable * // a parent re-render with value-equal props no longer re-runs Row's body * ``` * * Apply it once, at declaration (like `.catch` / `.hot()`): a `.memo()` call * inside a render body would mint a fresh component type every render and * defeat the bailout. A props-free REC qualifies trivially (there is nothing * to compare) and skips parent-driven re-renders entirely — its own atom * subscriptions still re-render it. Reach for `.memo()` when the dev render * ledger attributes re-renders to `props` whose values did not change. */ memo(): [NonComparableProps

    ] extends [never] ? REC : MemoRequiresComparableProps>; } interface RecBareYield { /** Place this REC without props: `yield* Child`. */ [Symbol.iterator](): Iterator, ReactNode>; } /** * A React Effect Component. Yieldable (so `R` and `S` propagate), never a JSX * element type — `` is a compile error by design. Props-free RECs can be * yielded directly (`yield* Child`); RECs with props use `yield* Child.with(p)`. * `E` carries the tagged failures its body can raise (`.catch` discharges them); * `S` carries an unhandled loading obligation (`.suspense` discharges it); * `V` carries an unhandled volatility obligation (`defer` at the read, or * `.hot()`, discharges it); `L` carries the labels of the atoms the tree * subscribes to — the set of writes that can re-render it (see * {@link RendersOn}). `V` and `L` default so existing annotations stay valid. */ export type REC = RecCore & ([Record] extends [P] ? RecBareYield : unknown); /** * The labels a component can re-render on — the union of label literals of * every atom its tree subscribes to. The introspection half of the render * channel: lock it in a type test and a new subscription (a new reason to * re-render) becomes a compile-time diff, reviewable like any other. * * ```ts * type _lock = RendersOn; // 'stats' | 'filter' * ``` */ // oxlint-disable-next-line typescript/no-explicit-any -- extraction position: `P`/`E` sit contravariantly in RecCore, so only `any` matches every instantiation; nothing leaks — the conditional yields `L` alone. export type RendersOn = C extends RecCore ? L : never; const makeRec =

    ( body: RecBody, name: string, catchHandlers?: CatchDispatch, suspenseFallback?: ReactNode, defectHandler?: (defect: unknown, api: CatchApi) => ReactNode, hotAcknowledged?: boolean, memoized?: true, ): REC => { // A `(props: P) => ...` body widens to the handle's `(props: any) => ...` for // free (parameter bivariance), so no assertion is needed to store it. const rec: RecCore & RecBareYield = { [RecTypeId]: true, body, displayName: name, // `exactOptionalPropertyTypes`: only carry a key when its value exists, so a // plain REC has no `catchHandlers`/`suspenseFallback` rather than undefined ones. ...(catchHandlers === undefined ? {} : { catchHandlers }), ...(suspenseFallback === undefined ? {} : { suspenseFallback }), ...(defectHandler === undefined ? {} : { defectHandler }), ...(hotAcknowledged === undefined ? {} : { hotAcknowledged }), ...(memoized === undefined ? {} : { memoized }), with(props: P): RecPlacement { return placement(rec, props); }, catch(handlers: CatchHandlers): REC, R, S, V, L> { // A handler is only ever invoked with the error whose `_tag` selected it, // so erasing the per-tag parameter to `unknown` for storage is sound; the // precise error type is recovered structurally at each `.catch` call site. return makeRec, R, S, V, L>( body, name, handlers as unknown as CatchDispatch, suspenseFallback, defectHandler, hotAcknowledged, memoized, ); }, catchDefect(handler: (defect: unknown, api: CatchApi) => ReactNode): REC { return makeRec( body, name, catchHandlers, suspenseFallback, handler, hotAcknowledged, memoized, ); }, suspense(fallback: ReactNode): REC { return makeRec( body, name, catchHandlers, fallback, defectHandler, hotAcknowledged, memoized, ); }, hot(): REC { // The discharge is type-level; the stored marker only exists so the dev // render ledger can report the acknowledged subtree (see `RecHandle`). return makeRec( body, name, catchHandlers, suspenseFallback, defectHandler, true, memoized, ); }, memo(): [NonComparableProps

    ] extends [never] ? REC : MemoRequiresComparableProps> { // The gate lives in the return type — the same return-position enforcement // as `mount`'s branded errors — so the value is always the memoized REC; // when a prop fails the gate the *type* is the branded tuple, and using // the result as a REC is the compile error that names the offending props. return makeRec( body, name, catchHandlers, suspenseFallback, defectHandler, hotAcknowledged, true, ) as unknown as [NonComparableProps

    ] extends [never] ? REC : MemoRequiresComparableProps>; }, [Symbol.iterator](): Iterator, ReactNode> { return placement(rec, {})[Symbol.iterator](); }, }; Object.defineProperty(rec, 'name', { value: name }); return rec; }; /** * Define a React Effect Component. The body is a generator that may `yield*` * Effect services and effects, `yield* hook(...)` for React hooks, `yield* * suspend(...)` / `query(...)` for async data, and `yield* Child` / `yield* Child.with(props)` to * place other RECs. * * The returned descriptor is server-safe: the same `mount(...)` renders a * hook-free body on the server, and the very same value mounts on the client. * Only a body that itself imports client-only APIs (React hooks) makes its * *module* client-only — the `rec` wrapper never does. */ // The props-free overload carries a defaulted `Props` (never inferable — the // body takes no parameter, so it is always `Record`) purely so // its return type stays generic in `Props`: `.memo()`'s conditional return // type then compares deferred-to-deferred against the implementation // signature, which a concrete `REC, …>` cannot. export function rec< Eff extends AnyYield, A extends ReactNode, Props extends object = Record, >( body: () => Generator, ): REC< Props, ErrorsOf, RequirementsOf, SuspendsOf, VolatilityOf, SubscribesOf >; export function rec( body: (props: Props) => Generator, ): REC< Props, ErrorsOf, RequirementsOf, SuspendsOf, VolatilityOf, SubscribesOf >; export function rec( body: (props: Props) => Generator, ): REC< Props, ErrorsOf, RequirementsOf, SuspendsOf, VolatilityOf, SubscribesOf > { return makeRec< Props, ErrorsOf, RequirementsOf, SuspendsOf, VolatilityOf, SubscribesOf >(body, body.name || 'EffractComponent'); } /** A type error naming the services a runtime is missing for a REC's tree. */ export type MissingServices = readonly ['effract: runtime is missing', Missing]; /** A type error demanding a loading fallback for a tree that can still suspend. */ export type LoadingNotHandled = readonly [ 'effract: loading not handled — add .suspense(fallback), or mount(layer, root, { loading })', ]; /** A type error demanding a scheduling decision for a tree that still reads a volatile atom. */ export type VolatileNotHandled = readonly [ 'effract: volatile subscription not handled — defer(atom) the read, or mark the component .hot()', ]; /** * The prop keys of `P` whose values `.memo()`'s bounded comparator cannot * compare by value — anything that is neither a primitive nor an `Equal.Equal` * implementor (a function, a plain object or array, a JSX node). `never` when * every prop is value-comparable, which is exactly when `.memo()` compiles. */ type NonComparableProps

    = { [K in keyof P]-?: P[K] extends string | number | boolean | bigint | symbol | null | undefined ? never : P[K] extends Equal.Equal ? never : K; }[keyof P]; /** A type error naming the props whose values `.memo()` cannot soundly compare. */ export type MemoRequiresComparableProps = readonly [ 'effract: .memo() requires value-comparable props — hold functions in a service and give structured data an Equal implementation; offending prop(s):', K, ]; /** * A no-service tree's requirement infers as `unknown` (Effect's requirement * channel is contravariant, so `never` widens). Normalise that to `never` so a * runtime-free tree mounts/serves under any layer. */ export type Effective = [unknown] extends [R] ? never : R;