@tmonier/effract
Advanced tools
| import { ReactNode } from "react"; | ||
| import * as Effect from "effect/Effect"; | ||
| //#region src/domain/protocol.d.ts | ||
| /** | ||
| * The one unavoidable `any` in the framework. The heterogeneous yield protocol | ||
| * must accept an Effect of *any* error and requirement variance — there is no | ||
| * other way to express "some Effect" as a generic constraint, which is why | ||
| * Effect's own `Effect.gen` is typed exactly this way. Precise `E` and `R` are | ||
| * recovered below through conditional inference, so this never leaks to users. | ||
| */ | ||
| type AnyEffect = Effect.Effect<any, any, any>; | ||
| /** Brand identifying a lifted React hook instruction. */ | ||
| declare const HookTypeId: unique symbol; | ||
| type HookTypeId = typeof HookTypeId; | ||
| /** | ||
| * A React hook result lifted into the `yield*` channel. The hook itself has | ||
| * already executed (synchronously, during render) by the time the value is | ||
| * wrapped — `hook(useState(0))` calls `useState` inline. Wrapping it only makes | ||
| * the result yieldable, so a component body reads as one uniform stream of | ||
| * `yield*`s whether the value comes from Effect or from React. | ||
| */ | ||
| interface Hook<out A> { | ||
| readonly [HookTypeId]: true; | ||
| readonly value: A; | ||
| [Symbol.iterator](): Iterator<Hook<A>, A>; | ||
| } | ||
| /** | ||
| * Lift an already-evaluated React hook result into the effract yield channel. | ||
| * | ||
| * ```ts | ||
| * const [tab, setTab] = yield* hook(useState('overview')); | ||
| * const ref = yield* hook(useRef<HTMLDivElement>(null)); | ||
| * ``` | ||
| * | ||
| * Because the component body runs synchronously inside React's render pass, the | ||
| * wrapped hook call obeys the Rules of Hooks: same order on every render. | ||
| */ | ||
| declare const hook: <A>(value: A) => Hook<A>; | ||
| /** Type guard: is this yielded instruction a lifted hook? */ | ||
| declare const isHook: (u: unknown) => u is Hook<unknown>; | ||
| /** Brand identifying a suspensable instruction (a `suspend`/`query`). */ | ||
| declare const SuspensableTypeId: unique symbol; | ||
| type SuspensableTypeId = typeof SuspensableTypeId; | ||
| /** | ||
| * The fourth kind of yieldable: a *suspensable* — a component's declared | ||
| * asynchronous dependency. Unlike a raw `yield* effect` (which the interpreter | ||
| * may still suspend on, but silently), yielding a suspensable both suspends for | ||
| * the value *and* contributes a {@link Suspends} obligation to the REC's type, | ||
| * so the loading state must be handled somewhere (`.suspense(...)`, or the | ||
| * `mount` boundary) before the tree compiles. Its `effect` carries the `E` and | ||
| * `R` channels, which bubble as a yielded effect's do — so retries, timeouts and | ||
| * cancellation are just Effect combinators on `effect`, and its failures are | ||
| * catchable with `.catch`. `key` (compared by value) drives refetch; `undefined` | ||
| * means load-once. Both {@link suspend} and {@link query} produce this. | ||
| */ | ||
| interface Suspensable<out A, out E, out R> { | ||
| readonly [SuspensableTypeId]: true; | ||
| readonly effect: Effect.Effect<A, E, R>; | ||
| readonly key: unknown; | ||
| [Symbol.iterator](): Iterator<Suspensable<A, E, R>, A>; | ||
| } | ||
| /** | ||
| * The suspensable primitive: run `effect`, suspend the render until it settles | ||
| * (through React's `use`), and return its value — declaring a loading obligation | ||
| * the type system makes you handle. Load-once: it runs a single time per | ||
| * component instance and position, deduped across every render attempt (including | ||
| * the pre-commit retries React makes when a component suspends before it first | ||
| * commits), and its in-flight fiber is interrupted when the component unmounts. | ||
| * | ||
| * It is the building block {@link query} is made of — reach for `suspend` to | ||
| * opt an effect into Suspense without query's keyed-refetch semantics, or to | ||
| * build your own async abstractions on top. | ||
| * | ||
| * ```ts | ||
| * const config = yield* suspend(loadConfig()); // load-once | ||
| * const feed = yield* suspend(load.pipe(Effect.timeout('2s'))); // policies via Effect | ||
| * ``` | ||
| */ | ||
| declare const suspend: <A, E, R>(effect: Effect.Effect<A, E, R>) => Suspensable<A, E, R>; | ||
| /** | ||
| * A keyed suspensable — {@link suspend} plus refetch. It re-runs `effect` when | ||
| * `key` changes by value (leaving `key` off makes it load-once, exactly like | ||
| * `suspend`). Same guarantees otherwise: deduped across render attempts, | ||
| * interrupted on unmount, failures catchable with `.catch`. | ||
| * | ||
| * ```ts | ||
| * const user = yield* query(fetchUser(id), id); // refetch when id changes | ||
| * ``` | ||
| */ | ||
| declare const query: <A, E, R>(effect: Effect.Effect<A, E, R>, key?: unknown) => Suspensable<A, E, R>; | ||
| /** Type guard: is this yielded instruction a suspensable (`suspend`/`query`)? */ | ||
| declare const isSuspensable: (u: unknown) => u is Suspensable<unknown, unknown, unknown>; | ||
| /** Brand identifying a reactive atom instruction. */ | ||
| declare const AtomTypeId: unique symbol; | ||
| type AtomTypeId = typeof AtomTypeId; | ||
| /** | ||
| * The fifth kind of yieldable: a *reactive atom read*. Reactive state lives in | ||
| * the Effect world — an atom, usually held by a service. Yielding one reads its | ||
| * current value *and* subscribes the component, so it re-renders precisely when | ||
| * that atom changes: `const n = yield* count`. It carries no `E`/`R`/`S` — a | ||
| * read is synchronous, needs nothing, and cannot fail. Derived atoms | ||
| * (`derive(...)`) are `ReadableAtom`s too, so they read and yield identically. | ||
| * The concrete atom is built in the React binding; the domain only names the | ||
| * contract — a value you can read, subscribe to, and `yield*`. | ||
| */ | ||
| interface ReadableAtom<out A> { | ||
| readonly [AtomTypeId]: true; | ||
| /** The current value — read imperatively (in an event handler or a method). */ | ||
| readonly value: A; | ||
| /** Subscribe to changes; returns an unsubscribe. Backs the in-render read. */ | ||
| subscribe(listener: () => void): () => void; | ||
| /** | ||
| * Derive a new read-only atom from *this one's* value — the ergonomic, | ||
| * single-source form of `derive`. The callback receives the value directly (no | ||
| * `$`), and the result recomputes when this atom changes; chain it freely. | ||
| * For a value computed from *several* atoms, reach for the free `derive(($) => …)`. | ||
| * | ||
| * ```ts | ||
| * const count = items.derive((list) => list.length); | ||
| * ``` | ||
| */ | ||
| derive<B>(f: (value: A) => B): ReadableAtom<B>; | ||
| [Symbol.iterator](): Iterator<ReadableAtom<A>, A>; | ||
| } | ||
| /** A writable reactive atom — a {@link ReadableAtom} you can also `set`/`update`. */ | ||
| interface Atom<in out A> extends ReadableAtom<A> { | ||
| set(value: A): void; | ||
| update(update: (previous: A) => A): void; | ||
| } | ||
| /** Type guard: is this yielded instruction a reactive atom read? */ | ||
| declare const isAtom: (u: unknown) => u is ReadableAtom<unknown>; | ||
| /** | ||
| * The phantom marker of an *unhandled loading obligation*. A body that yields a | ||
| * {@link Suspensable} (or places a child that still carries one) has this in its | ||
| * `S` channel; `.suspense(fallback)` — or the `mount` boundary — discharges it | ||
| * back to `never`. It never exists at runtime; it exists only so the type system | ||
| * can insist a loading state is handled somewhere between component and root. The | ||
| * branded field is nominal — it makes `Suspends` distinct from `never` and from | ||
| * any ordinary type, so the discharge checks are exact. Loading is a single | ||
| * catch-all obligation (not a union like the tagged error channel): a pending | ||
| * effect has no tag, so one `.suspense` discharges the whole subtree beneath it. | ||
| */ | ||
| interface Suspends { | ||
| readonly ['@tmonier/effract/loading']: true; | ||
| } | ||
| /** Everything a body may `yield*`: an Effect, a hook, a placement, a query, or an atom read. */ | ||
| type Yieldable<A> = Effect.Effect<A, unknown, unknown> | Hook<A> | RecPlacement<A, unknown, unknown> | Suspensable<A, unknown, unknown> | ReadableAtom<A>; | ||
| /** The generator a React Effect Component body produces. */ | ||
| type RecGenerator<A> = Generator<AnyEffect | Hook<unknown> | RecPlacement<unknown, unknown, unknown> | Suspensable<unknown, unknown, unknown> | ReadableAtom<unknown>, A, unknown>; | ||
| /** A component body: props in, a generator of yields ending in a rendered `A`. */ | ||
| type RecBody<Props, A> = (props: Props) => RecGenerator<A>; | ||
| /** | ||
| * Runtime dispatch table for `.catch`: an error `_tag` maps to the node to | ||
| * render in that error's place. Renderer-agnostic in `A` — the domain never | ||
| * learns that a node is a React element; it only carries the mapping so both the | ||
| * client interpreter and the server driver can honour it. The typed, exhaustive | ||
| * shape users write (`CatchHandlers`) narrows to this at the `.catch` boundary. | ||
| */ | ||
| type CatchDispatch<A> = Readonly<Record<string, (error: unknown) => A>>; | ||
| /** | ||
| * A stable handle to a component's body — the identity a placement points at. | ||
| * The client keys its per-descriptor React component on this object (so a child | ||
| * keeps a stable React type across re-renders); the server drives its `body` | ||
| * directly. Deliberately renderer-agnostic: it names *what* to run, not how. | ||
| */ | ||
| interface RecHandle<A> { | ||
| readonly body: (props: any) => RecGenerator<A>; | ||
| readonly displayName: string; | ||
| /** | ||
| * Typed-error fallbacks for this REC, if it was `.catch`-wrapped: a failure | ||
| * from one of *its own* `yield*`ed effects whose `_tag` is present renders the | ||
| * mapped node instead of propagating. Absent for a plain REC. | ||
| */ | ||
| readonly catchHandlers?: CatchDispatch<A>; | ||
| /** | ||
| * The loading fallback for this REC, if it was `.suspense`-wrapped: the client | ||
| * places it in a real `<Suspense>` boundary so a yielded {@link Suspensable}'s | ||
| * pending state renders this node. Renderer-agnostic in `A`; absent otherwise. | ||
| */ | ||
| readonly suspenseFallback?: A; | ||
| } | ||
| /** Brand identifying a child-REC placement instruction. */ | ||
| declare const PlacementTypeId: unique symbol; | ||
| type PlacementTypeId = typeof PlacementTypeId; | ||
| /** | ||
| * The third kind of yieldable, alongside Effects and Hooks: the placement of a | ||
| * *child* REC into a parent's tree (`yield* Child` / `yield* Child.with(p)`). | ||
| * | ||
| * A placement carries only data — the child's stable `rec` handle and its | ||
| * `props` — never a bound React element. That is what lets one `rec(...)` value | ||
| * be shared across runtimes: the client turns a placement into a real React | ||
| * child fiber, the server drives the child's body inline. The phantom `R` makes | ||
| * the child's Effect requirements flow up through `yield*` to `mount`/`serve`, | ||
| * exactly as a yielded service's requirements do. | ||
| */ | ||
| interface RecPlacement<A, R, S = never> { | ||
| readonly [PlacementTypeId]: true; | ||
| readonly rec: RecHandle<A>; | ||
| readonly props: object; | ||
| /** | ||
| * Phantom carrier for the requirements this placement contributes upward. | ||
| * Covariant (an output position) so a concrete placement — e.g. one needing | ||
| * `Stats` — widens to `RecPlacement<_, unknown>` in a body's yield union, just | ||
| * as `Effect<_, _, Stats>` widens to `AnyEffect`. Never set at runtime. | ||
| */ | ||
| readonly _requirements?: R; | ||
| /** | ||
| * Phantom carrier for the child's unhandled loading obligation `S`. Covariant, | ||
| * like `_requirements`, so a placed child that still suspends bubbles its | ||
| * {@link Suspends} up to the parent's `S` — until an ancestor `.suspense`s it. | ||
| * Never set at runtime. | ||
| */ | ||
| readonly _suspends?: S; | ||
| [Symbol.iterator](): Iterator<RecPlacement<A, R, S>, A>; | ||
| } | ||
| /** | ||
| * Construct a child placement. Single-shot iterable like {@link hook}: `yield*` | ||
| * hands the interpreter the placement instruction, and the value fed back in | ||
| * (the rendered child node) becomes the result of the `yield*`. | ||
| */ | ||
| declare const placement: <A, R, S = never>(rec: RecHandle<A>, props: object) => RecPlacement<A, R, S>; | ||
| /** Type guard: is this yielded instruction a child-REC placement? */ | ||
| declare const isPlacement: (u: unknown) => u is RecPlacement<unknown, unknown, unknown>; | ||
| /** | ||
| * Distribute over the yield union and keep only its Effect members. Hooks are | ||
| * not Effects, so they drop away — leaving just what carries `E` and `R`. | ||
| */ | ||
| type EffectsOnly<Eff> = Eff extends AnyEffect ? Eff : never; | ||
| /** | ||
| * Re-express each yielded placement as an effect carrying only its child's | ||
| * requirements, so a placed child's `R` joins the parent's the same way a | ||
| * yielded service's does — one child, its whole subtree's services bubble up. | ||
| */ | ||
| type PlacementsAsEffects<Eff> = Eff extends RecPlacement<unknown, infer R, unknown> ? Effect.Effect<unknown, unknown, R> : never; | ||
| /** | ||
| * Re-express each yielded suspensable as an effect carrying its `E` and `R`, so | ||
| * a suspensable's errors and requirements join the body's exactly as a raw | ||
| * effect's do. | ||
| */ | ||
| type SuspensablesAsEffects<Eff> = Eff extends Suspensable<unknown, infer E, infer R> ? Effect.Effect<unknown, E, R> : never; | ||
| /** | ||
| * Recover the Effect requirement channel `R` from everything a body yields — | ||
| * services, effects, placed child RECs, and suspensables. A body that needs both | ||
| * `A` and `B` requires `A & B`, which is exactly the intersection TypeScript | ||
| * infers from the contravariant requirement slot. | ||
| */ | ||
| type RequirementsOf<Eff> = [EffectsOnly<Eff> | PlacementsAsEffects<Eff> | SuspensablesAsEffects<Eff>] extends [Effect.Effect<unknown, unknown, infer R>] ? R : never; | ||
| /** Recover the Effect error channel `E` (a union — any yielded effect or suspensable may fail). */ | ||
| type ErrorsOf<Eff> = [EffectsOnly<Eff> | SuspensablesAsEffects<Eff>] extends [Effect.Effect<unknown, infer E, unknown>] ? E : never; | ||
| /** | ||
| * Recover the loading obligation `S`. A yielded {@link Suspensable} contributes | ||
| * {@link Suspends}; a placed child contributes whatever `S` it still carries. | ||
| * The union is `Suspends` if *anything* below is unhandled, and `never` once it | ||
| * all is — the exact condition `mount` (and `.suspense`) check. | ||
| */ | ||
| type SuspendsOf<Eff> = Eff extends Suspensable<unknown, unknown, unknown> ? Suspends : Eff extends RecPlacement<unknown, unknown, infer S> ? S : never; | ||
| //#endregion | ||
| //#region src/infrastructure/rec-core.d.ts | ||
| /** Brand identifying a React Effect Component. */ | ||
| declare const RecTypeId: unique symbol; | ||
| 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<E> = Exclude<E, Tagged>; | ||
| /** | ||
| * 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 `{}`. | ||
| */ | ||
| type CatchHandlers<E, A = ReactNode> = { readonly [Tag in Extract<E, Tagged>['_tag']]: (error: Extract<E, { | ||
| readonly _tag: Tag; | ||
| }>) => A }; | ||
| /** Any yieldable a REC body may produce — the constraint `rec` infers `Eff` against. */ | ||
| type AnyYield = AnyEffect | Hook<unknown> | RecPlacement<ReactNode, unknown, unknown> | Suspensable<unknown, unknown, unknown> | ReadableAtom<unknown>; | ||
| interface RecCore<P, E, R, S> extends RecHandle<ReactNode> { | ||
| readonly [RecTypeId]: true; | ||
| /** Place this REC with props: `yield* Child.with({ ... })`. */ | ||
| with(props: P): RecPlacement<ReactNode, R, S>; | ||
| /** | ||
| * 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 <Card user={user} />; | ||
| * }).catch({ | ||
| * NotFound: () => <Empty />, | ||
| * Unauthorized: () => <Login />, | ||
| * }); | ||
| * ``` | ||
| * | ||
| * 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<E>): REC<P, UntaggedErrors<E>, R, S>; | ||
| /** | ||
| * 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 `<Suspense>` boundary, so while it is pending the | ||
| * `fallback` renders in its place. | ||
| * | ||
| * ```tsx | ||
| * const Page = Profile.suspense(<Skeleton />); // 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<P, E, R, never>; | ||
| } | ||
| interface RecBareYield<R, S> { | ||
| /** Place this REC without props: `yield* Child`. */ | ||
| [Symbol.iterator](): Iterator<RecPlacement<ReactNode, R, S>, ReactNode>; | ||
| } | ||
| /** | ||
| * A React Effect Component. Yieldable (so `R` and `S` propagate), never a JSX | ||
| * element type — `<Rec />` 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). | ||
| */ | ||
| type REC<P, E, R, S> = RecCore<P, E, R, S> & ([Record<never, never>] extends [P] ? RecBareYield<R, S> : unknown); | ||
| /** | ||
| * 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. | ||
| */ | ||
| declare function rec<Eff extends AnyYield, A extends ReactNode>(body: () => Generator<Eff, A, never>): REC<Record<never, never>, ErrorsOf<Eff>, RequirementsOf<Eff>, SuspendsOf<Eff>>; | ||
| declare function rec<Eff extends AnyYield, A extends ReactNode, Props extends object>(body: (props: Props) => Generator<Eff, A, never>): REC<Props, ErrorsOf<Eff>, RequirementsOf<Eff>, SuspendsOf<Eff>>; | ||
| /** A type error naming the services a runtime is missing for a REC's tree. */ | ||
| type MissingServices<Missing> = readonly ['effract: runtime is missing', Missing]; | ||
| /** A type error demanding a loading fallback for a tree that can still suspend. */ | ||
| type LoadingNotHandled = readonly ['effract: loading not handled — add .suspense(fallback), or mount(layer, root, { loading })']; | ||
| /** | ||
| * 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. | ||
| */ | ||
| type Effective<R> = [unknown] extends [R] ? never : R; | ||
| //#endregion | ||
| //#region src/infrastructure/reactivity-core.d.ts | ||
| /** Read an atom inside a `derive`/`observe` selector, subscribing to it. */ | ||
| type Read = <A>(atom: ReadableAtom<A>) => A; | ||
| /** | ||
| * Run `writes` as one atomic notification wave: atoms may be `set` many times | ||
| * inside, but subscribers (and the derived atoms and components downstream) are | ||
| * notified once, after it returns. Returns whatever `writes` returns. | ||
| * | ||
| * ```ts | ||
| * batch(() => { | ||
| * first.set('Ada'); | ||
| * last.set('Lovelace'); | ||
| * }); // one re-render, not two | ||
| * ``` | ||
| */ | ||
| declare const batch: <A>(writes: () => A) => A; | ||
| /** | ||
| * Create a writable reactive atom. Read it with `yield*` in a REC, `.value` | ||
| * imperatively (in an event handler or a service method), or `$(atom)` inside | ||
| * `derive`/`<Observe>`; write it with `.set` / `.update`. Backed by Effect's | ||
| * `AtomRef` for storage, so its value is reachable from anywhere an Effect runs; | ||
| * writes that don't change the value (by `Equal`) notify no one, and writes made | ||
| * inside {@link batch} are coalesced. | ||
| */ | ||
| declare const atom: <A>(initial: A) => Atom<A>; | ||
| /** | ||
| * A keyed collection of atoms — one lazily-created, memoised atom per key, so | ||
| * per-entity state (a row, a cart line, a todo) is a family lookup rather than | ||
| * one giant atom you slice. `family(key)` returns the same atom for equal keys; | ||
| * `make` builds a fresh one the first time a key is seen. `forget`/`clear` drop | ||
| * cached entries (e.g. when an entity is deleted). Non-primitive keys need a | ||
| * `keyOf` to reduce them to a stable map key. | ||
| * | ||
| * ```ts | ||
| * const quantities = atomFamily((_id: string) => atom(1)); | ||
| * quantities('sku-1').update((n) => n + 1); // independent per id | ||
| * ``` | ||
| */ | ||
| interface AtomFamily<K, A> { | ||
| (key: K): A; | ||
| /** Drop the cached atom for `key` (the next lookup makes a fresh one). */ | ||
| forget(key: K): void; | ||
| /** Drop every cached atom. */ | ||
| clear(): void; | ||
| } | ||
| declare const atomFamily: <K, A>(make: (key: K) => A, keyOf?: (key: K) => unknown) => AtomFamily<K, A>; | ||
| /** | ||
| * An *async derived* value: it reads atoms and returns an `Effect`, and reads in | ||
| * a REC with `yield*` like any other atom — but it *suspends* while the effect | ||
| * runs, re-runs when a source atom changes (keyed by the read values, so an | ||
| * unchanged source is not refetched), and contributes the same loading obligation | ||
| * `S` (plus the effect's `E`/`R`) that {@link query} does. Async derivation, still | ||
| * expressed as data in the Effect world: | ||
| * | ||
| * ```ts | ||
| * const price = derive.effect(($) => fetchPrice($(sku))); // suspends; refetches on sku change | ||
| * // in a REC: const p = yield* price; // .suspense(...) must handle the loading | ||
| * ``` | ||
| * | ||
| * It owns no interpreter machinery of its own — yielding it drives, in order, a | ||
| * subscription (so the component re-renders when a source changes) and a keyed | ||
| * `query` (so it suspends and refetches). Both are ordinary yieldables, so the | ||
| * `E`/`R`/`S` channels flow exactly as a hand-written `query` would. | ||
| */ | ||
| interface AsyncDerived<A, E, R> { | ||
| [Symbol.iterator](): Iterator<Suspensable<A, E, R> | ReadableAtom<ReadonlyArray<unknown>>, A>; | ||
| } | ||
| /** | ||
| * Create a *derived* atom from *several* atoms: a read-only reactive value whose | ||
| * `$` tracks exactly the atoms the selector reads, so it recomputes — and its | ||
| * readers re-render — precisely when a tracked atom changes. Derived atoms | ||
| * compose. Keep derivation here, in the Effect world, not in a component. | ||
| * | ||
| * For the common case of deriving from a *single* atom, prefer the method form | ||
| * {@link ReadableAtom.derive | `atom.derive`} — it hands you the value directly, | ||
| * with no `$`: `const count = items.derive((list) => list.length)`. | ||
| * | ||
| * ```ts | ||
| * const total = derive(($) => $(price) * $(qty)); // several sources → the $ form | ||
| * ``` | ||
| * | ||
| * {@link deriveWritable | `derive.writable`} adds a two-way variant; | ||
| * {@link deriveEffect | `derive.effect`} an async, suspending one. | ||
| */ | ||
| declare const derive: (<A>(selector: (read: Read) => A) => ReadableAtom<A>) & { | ||
| writable: <A>(selector: (read: Read) => A, write: (value: A) => void) => Atom<A>; | ||
| effect: <A, E, R>(selector: (read: Read) => Effect.Effect<A, E, R>) => AsyncDerived<A, E, R>; | ||
| }; | ||
| //#endregion | ||
| export { SuspendsOf as A, query as B, ReadableAtom as C, RecPlacement as D, RecHandle as E, isAtom as F, isHook as I, isPlacement as L, SuspensableTypeId as M, Yieldable as N, RequirementsOf as O, hook as P, isSuspensable as R, PlacementTypeId as S, RecGenerator as T, suspend as V, AtomTypeId as _, atomFamily as a, Hook as b, CatchHandlers as c, MissingServices as d, REC as f, Atom as g, AnyEffect as h, atom as i, Suspensable as j, Suspends as k, Effective as l, rec as m, AtomFamily as n, batch as o, RecTypeId as p, Read as r, derive as s, AsyncDerived as t, LoadingNotHandled as u, CatchDispatch as v, RecBody as w, HookTypeId as x, ErrorsOf as y, placement as z }; |
| '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 type { CatchDispatch, ReadableAtom } from '#domain/protocol.ts'; | ||
| /** | ||
| * 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<Set<ReadableAtom<unknown>> | 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<ReactNode>; | ||
| readonly sink: Set<ReadableAtom<unknown>>; | ||
| 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 `<Suspense>`, never here. | ||
| * A defect, or a tag this REC did not name, is re-thrown so the next boundary up | ||
| * handles it; effract never silently swallows those. | ||
| */ | ||
| class Boundary extends Component<BoundaryProps, { error: unknown }> { | ||
| 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]; | ||
| if (handler === undefined) { | ||
| throw error; // not this REC's to handle — re-propagate to the next boundary | ||
| } | ||
| return createElement( | ||
| Fragment, | ||
| null, | ||
| createElement(ResetWatcher, { atoms: this.props.sink, onReset: this.reset }), | ||
| handler(error), | ||
| ); | ||
| } | ||
| } | ||
| /** | ||
| * 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<ReadableAtom<unknown>>; | ||
| 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` 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<ReactNode>, | ||
| child: ReactElement, | ||
| ): ReactElement => { | ||
| const sink = new Set<ReadableAtom<unknown>>(); | ||
| return createElement( | ||
| ReadSink.Provider, | ||
| { value: sink }, | ||
| createElement(Boundary, { handlers, sink, children: child }), | ||
| ); | ||
| }; |
@@ -1,2 +0,2 @@ | ||
| import { A as SuspendsOf, B as query, C as ReadableAtom, D as RecPlacement, E as RecHandle, F as isAtom, I as isHook, L as isPlacement, M as SuspensableTypeId, N as Yieldable, O as RequirementsOf, P as hook, R as isSuspensable, S as PlacementTypeId, T as RecGenerator, V as suspend, _ as AtomTypeId, a as atomFamily, b as Hook, c as CatchHandlers, d as MissingServices, f as REC, g as Atom, h as AnyEffect, i as atom, j as Suspensable, k as Suspends, l as Effective, m as rec, n as AtomFamily, o as batch, p as RecTypeId, r as Read, s as derive, t as AsyncDerived, u as LoadingNotHandled, w as RecBody, x as HookTypeId, y as ErrorsOf, z as placement } from "./reactivity-core-qYQtTog0.mjs"; | ||
| import { A as SuspendsOf, B as query, C as ReadableAtom, D as RecPlacement, E as RecHandle, F as isAtom, I as isHook, L as isPlacement, M as SuspensableTypeId, N as Yieldable, O as RequirementsOf, P as hook, R as isSuspensable, S as PlacementTypeId, T as RecGenerator, V as suspend, _ as AtomTypeId, a as atomFamily, b as Hook, c as CatchHandlers, d as MissingServices, f as REC, g as Atom, h as AnyEffect, i as atom, j as Suspensable, k as Suspends, l as Effective, m as rec, n as AtomFamily, o as batch, p as RecTypeId, r as Read, s as derive, t as AsyncDerived, u as LoadingNotHandled, w as RecBody, x as HookTypeId, y as ErrorsOf, z as placement } from "./reactivity-core-CvxTAdZJ.mjs"; | ||
| import { ReactNode } from "react"; | ||
@@ -3,0 +3,0 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; |
+123
-40
| import { _ as placement, a as derive, c as AtomTypeId, d as SuspensableTypeId, f as hook, g as isSuspensable, h as isPlacement, i as computation, l as HookTypeId, m as isHook, n as atomFamily, o as RecTypeId, p as isAtom, r as batch, s as rec, t as atom, u as PlacementTypeId, v as query, y as suspend } from "./reactivity-core-Ceq7GLk8.mjs"; | ||
| import { Suspense, createContext, createElement, use, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; | ||
| import { Component, Fragment, Suspense, createContext, createElement, use, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; | ||
| import * as Cause from "effect/Cause"; | ||
@@ -25,2 +25,8 @@ import * as Effect from "effect/Effect"; | ||
| */ | ||
| /** A fresh drive state, all counters zeroed. */ | ||
| const driveState = () => ({ | ||
| index: 0, | ||
| queryIndex: 0, | ||
| hooks: 0 | ||
| }); | ||
| /** | ||
@@ -40,2 +46,3 @@ * Resolve a single yielded Effect against the runtime. Synchronous effects | ||
| if (Cause.isAsyncFiberError(squashed)) { | ||
| state.hooks += 1; | ||
| const index = state.index++; | ||
@@ -57,7 +64,3 @@ let slot = deps.cache.get(index); | ||
| */ | ||
| const driveRec = (gen, deps) => { | ||
| const state = { | ||
| index: 0, | ||
| queryIndex: 0 | ||
| }; | ||
| const driveRec = (gen, deps, state = driveState()) => { | ||
| let step = gen.next(); | ||
@@ -67,7 +70,13 @@ while (!step.done) { | ||
| let result; | ||
| if (isHook(instruction)) result = instruction.value; | ||
| else if (isPlacement(instruction)) result = deps.placer.place(instruction); | ||
| else if (isSuspensable(instruction)) result = deps.suspensableResolver.resolve(instruction, state.queryIndex++); | ||
| else if (isAtom(instruction)) result = deps.reader.read(instruction); | ||
| else result = resolveEffect(instruction, deps, state); | ||
| if (isHook(instruction)) { | ||
| state.hooks += 1; | ||
| result = instruction.value; | ||
| } else if (isPlacement(instruction)) result = deps.placer.place(instruction); | ||
| else if (isSuspensable(instruction)) { | ||
| state.hooks += 1; | ||
| result = deps.suspensableResolver.resolve(instruction, state.queryIndex++); | ||
| } else if (isAtom(instruction)) { | ||
| state.hooks += 1; | ||
| result = deps.reader.read(instruction); | ||
| } else result = resolveEffect(instruction, deps, state); | ||
| step = gen.next(result); | ||
@@ -77,28 +86,77 @@ } | ||
| }; | ||
| /** A thenable — how React's `use` signals a suspension. Never a typed failure. */ | ||
| const isThenable = (u) => typeof u === "object" && u !== null && typeof u.then === "function"; | ||
| //#endregion | ||
| //#region src/infrastructure/react/catch-boundary.tsx | ||
| /** | ||
| * Drive a REC body, rendering a typed fallback for a failure it declared via | ||
| * `.catch`. A yielded effect that fails surfaces here as a thrown tagged error — | ||
| * the *same* instance whether it failed synchronously (`Cause.squash`) or | ||
| * asynchronously (React's `use` re-throwing the settled rejection). If its | ||
| * `_tag` names a handler, the handler's node is rendered in place; anything else | ||
| * is re-thrown untouched, so Suspense signals still suspend, defects still reach | ||
| * the nearest error boundary, and an unhandled tag stays a real error. Without a | ||
| * dispatch this is exactly `driveRec`. | ||
| * 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). | ||
| */ | ||
| const driveRecCaught = (gen, deps, handlers) => { | ||
| if (handlers === void 0) return driveRec(gen, deps); | ||
| try { | ||
| return driveRec(gen, deps); | ||
| } catch (thrown) { | ||
| if (isThenable(thrown)) throw thrown; | ||
| const tag = typeof thrown === "object" && thrown !== null ? thrown._tag : void 0; | ||
| if (typeof tag === "string") { | ||
| const handler = handlers[tag]; | ||
| if (handler !== void 0) return handler(thrown); | ||
| } | ||
| throw thrown; | ||
| /** | ||
| * 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`. | ||
| */ | ||
| const ReadSink = createContext(null); | ||
| const tagOf = (error) => typeof error === "object" && error !== null && typeof error._tag === "string" ? error._tag : void 0; | ||
| /** | ||
| * Catches a thrown typed error and renders its `.catch` fallback. A suspension (a | ||
| * thrown thenable) is not an error — React routes it to `<Suspense>`, never here. | ||
| * A defect, or a tag this REC did not name, is re-thrown so the next boundary up | ||
| * handles it; effract never silently swallows those. | ||
| */ | ||
| var Boundary = class extends Component { | ||
| state = { error: null }; | ||
| static getDerivedStateFromError(error) { | ||
| return { error }; | ||
| } | ||
| reset = () => this.setState({ error: null }); | ||
| render() { | ||
| const { error } = this.state; | ||
| if (error === null) return this.props.children; | ||
| const tag = tagOf(error); | ||
| const handler = tag === void 0 ? void 0 : this.props.handlers[tag]; | ||
| if (handler === void 0) throw error; | ||
| return createElement(Fragment, null, createElement(ResetWatcher, { | ||
| atoms: this.props.sink, | ||
| onReset: this.reset | ||
| }), handler(error)); | ||
| } | ||
| }; | ||
| /** | ||
| * 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 }) => { | ||
| 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` 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). | ||
| */ | ||
| const withCatch = (handlers, child) => { | ||
| const sink = /* @__PURE__ */ new Set(); | ||
| return createElement(ReadSink.Provider, { value: sink }, createElement(Boundary, { | ||
| handlers, | ||
| sink, | ||
| children: child | ||
| })); | ||
| }; | ||
| //#endregion | ||
@@ -367,2 +425,6 @@ //#region src/infrastructure/react/reactivity.tsx | ||
| */ | ||
| /** A thenable — how React's `use` signals a suspension. Never a typed failure. */ | ||
| const isThenable = (u) => typeof u === "object" && u !== null && typeof u.then === "function"; | ||
| /** The `_tag` of a tagged error, if this thrown value looks like one. */ | ||
| const errorTag = (error) => typeof error === "object" && error !== null && typeof error._tag === "string" ? error._tag : void 0; | ||
| const useSuspender = () => ({ use }); | ||
@@ -425,12 +487,29 @@ const useRenderCache = () => { | ||
| useQueryClaims(instanceId, usedRef); | ||
| const sink = useContext(ReadSink); | ||
| const reader = sink === null ? atomReader : { read: (atom) => { | ||
| sink.add(atom); | ||
| return atomReader.read(atom); | ||
| } }; | ||
| const lastHooks = useRef(-1); | ||
| const used = /* @__PURE__ */ new Set(); | ||
| const resolver = makeSuspensableResolver(scope, executor, suspender, used); | ||
| const node = driveRecCaught(handle.body(props), { | ||
| const deps = { | ||
| executor, | ||
| suspender, | ||
| cache, | ||
| suspensableResolver: resolver, | ||
| reader: atomReader, | ||
| suspensableResolver: makeSuspensableResolver(scope, executor, suspender, used), | ||
| reader, | ||
| placer: clientPlacer | ||
| }, handle.catchHandlers); | ||
| }; | ||
| const state = driveState(); | ||
| let node; | ||
| try { | ||
| node = driveRec(handle.body(props), deps, state); | ||
| lastHooks.current = state.hooks; | ||
| } catch (thrown) { | ||
| const tag = errorTag(thrown); | ||
| const handler = handle.catchHandlers !== void 0 && tag !== void 0 ? handle.catchHandlers[tag] : void 0; | ||
| if (handler === void 0 || isThenable(thrown) || state.hooks < lastHooks.current) throw thrown; | ||
| node = handler(thrown); | ||
| lastHooks.current = state.hooks; | ||
| } | ||
| usedRef.current = used; | ||
@@ -454,4 +533,6 @@ return node; | ||
| const element = createElement(clientFcFor(placement.rec), placement.props); | ||
| const handlers = placement.rec.catchHandlers; | ||
| const caught = handlers === void 0 ? element : withCatch(handlers, element); | ||
| const fallback = placement.rec.suspenseFallback; | ||
| return fallback === void 0 ? element : createElement(Suspense, { fallback }, element); | ||
| return fallback === void 0 ? caught : createElement(Suspense, { fallback }, caught); | ||
| } }; | ||
@@ -462,3 +543,5 @@ function mount(layer, rec, options) { | ||
| const inner = createElement(clientFcFor(handle), {}); | ||
| const root = fallback === void 0 ? inner : createElement(Suspense, { fallback }, inner); | ||
| const handlers = handle.catchHandlers; | ||
| const caught = handlers === void 0 ? inner : withCatch(handlers, inner); | ||
| const root = fallback === void 0 ? caught : createElement(Suspense, { fallback }, caught); | ||
| return createElement(Runtime, { layer }, root); | ||
@@ -465,0 +548,0 @@ } |
@@ -1,2 +0,2 @@ | ||
| import { A as SuspendsOf, B as query, C as ReadableAtom, D as RecPlacement, E as RecHandle, F as isAtom, L as isPlacement, M as SuspensableTypeId, N as Yieldable, O as RequirementsOf, R as isSuspensable, S as PlacementTypeId, T as RecGenerator, V as suspend, _ as AtomTypeId, a as atomFamily, c as CatchHandlers, d as MissingServices, f as REC, g as Atom, h as AnyEffect, i as atom, j as Suspensable, k as Suspends, l as Effective, m as rec, n as AtomFamily, o as batch, p as RecTypeId, r as Read, s as derive, t as AsyncDerived, v as CatchDispatch, w as RecBody, y as ErrorsOf, z as placement } from "./reactivity-core-qYQtTog0.mjs"; | ||
| import { A as SuspendsOf, B as query, C as ReadableAtom, D as RecPlacement, E as RecHandle, F as isAtom, L as isPlacement, M as SuspensableTypeId, N as Yieldable, O as RequirementsOf, R as isSuspensable, S as PlacementTypeId, T as RecGenerator, V as suspend, _ as AtomTypeId, a as atomFamily, c as CatchHandlers, d as MissingServices, f as REC, g as Atom, h as AnyEffect, i as atom, j as Suspensable, k as Suspends, l as Effective, m as rec, n as AtomFamily, o as batch, p as RecTypeId, r as Read, s as derive, t as AsyncDerived, v as CatchDispatch, w as RecBody, y as ErrorsOf, z as placement } from "./reactivity-core-CvxTAdZJ.mjs"; | ||
| import { ReactNode } from "react"; | ||
@@ -3,0 +3,0 @@ import * as Layer from "effect/Layer"; |
+1
-1
| { | ||
| "name": "@tmonier/effract", | ||
| "version": "0.5.0", | ||
| "version": "0.5.1", | ||
| "description": "React components written as Effect programs. yield* services and React hooks in one render pass; run the same component anywhere.", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
@@ -26,3 +26,2 @@ /** | ||
| type AnyEffect, | ||
| type CatchDispatch, | ||
| type RecGenerator, | ||
@@ -32,3 +31,3 @@ } from '#domain/protocol.ts'; | ||
| interface DriveState { | ||
| export interface DriveState { | ||
| /** Encounter counter for suspended async effects (the load-once cache). */ | ||
@@ -38,4 +37,17 @@ index: number; | ||
| queryIndex: number; | ||
| /** | ||
| * How many *hook-bearing* yields this render processed — hooks, atom reads, | ||
| * suspensables, and async effects (each is a real React hook). It counts a | ||
| * yield the moment it is reached, so a yield that throws (a failure or a | ||
| * suspension) still counts. The client compares it against the last committed | ||
| * render's count to tell whether a caught failure skipped later hooks — in | ||
| * which case rendering the fallback inline would break the Rules of Hooks, so | ||
| * it re-throws to the `.catch` error boundary instead. | ||
| */ | ||
| hooks: number; | ||
| } | ||
| /** A fresh drive state, all counters zeroed. */ | ||
| export const driveState = (): DriveState => ({ index: 0, queryIndex: 0, hooks: 0 }); | ||
| /** | ||
@@ -64,2 +76,3 @@ * Resolve a single yielded Effect against the runtime. Synchronous effects | ||
| if (Cause.isAsyncFiberError(squashed)) { | ||
| state.hooks += 1; // `use` below is a real hook — count it before it may suspend | ||
| const index = state.index++; | ||
@@ -84,4 +97,7 @@ let slot = deps.cache.get(index); | ||
| */ | ||
| export const driveRec = <A>(gen: RecGenerator<A>, deps: InterpreterDeps): A => { | ||
| const state: DriveState = { index: 0, queryIndex: 0 }; | ||
| export const driveRec = <A>( | ||
| gen: RecGenerator<A>, | ||
| deps: InterpreterDeps, | ||
| state: DriveState = driveState(), | ||
| ): A => { | ||
| let step = gen.next(); | ||
@@ -93,5 +109,7 @@ while (!step.done) { | ||
| // The hook already ran inline during render; unwrap its value. | ||
| state.hooks += 1; | ||
| result = instruction.value; | ||
| } else if (isPlacement(instruction)) { | ||
| // A child REC: hand it to the renderer to place as a real React child. | ||
| // (A placement is not a hook — it is not counted.) | ||
| result = deps.placer.place(instruction); | ||
@@ -101,6 +119,8 @@ } else if (isSuspensable(instruction)) { | ||
| // key, and (backed by a cross-render store) interrupts on unmount. Keyed by | ||
| // encounter order. | ||
| // encounter order. Its `use` is a hook — count it before it may throw. | ||
| state.hooks += 1; | ||
| result = deps.suspensableResolver.resolve(instruction, state.queryIndex++); | ||
| } else if (isAtom(instruction)) { | ||
| // Reactive state: read the atom's value and subscribe this render to it. | ||
| state.hooks += 1; | ||
| result = deps.reader.read(instruction); | ||
@@ -115,44 +135,5 @@ } else { | ||
| /** A thenable — how React's `use` signals a suspension. Never a typed failure. */ | ||
| const isThenable = (u: unknown): u is PromiseLike<unknown> => | ||
| typeof u === 'object' && u !== null && typeof (u as { then?: unknown }).then === 'function'; | ||
| /** | ||
| * Drive a REC body, rendering a typed fallback for a failure it declared via | ||
| * `.catch`. A yielded effect that fails surfaces here as a thrown tagged error — | ||
| * the *same* instance whether it failed synchronously (`Cause.squash`) or | ||
| * asynchronously (React's `use` re-throwing the settled rejection). If its | ||
| * `_tag` names a handler, the handler's node is rendered in place; anything else | ||
| * is re-thrown untouched, so Suspense signals still suspend, defects still reach | ||
| * the nearest error boundary, and an unhandled tag stays a real error. Without a | ||
| * dispatch this is exactly `driveRec`. | ||
| */ | ||
| export const driveRecCaught = <A>( | ||
| gen: RecGenerator<A>, | ||
| deps: InterpreterDeps, | ||
| handlers: CatchDispatch<A> | undefined, | ||
| ): A => { | ||
| if (handlers === undefined) { | ||
| return driveRec(gen, deps); | ||
| } | ||
| try { | ||
| return driveRec(gen, deps); | ||
| } catch (thrown) { | ||
| // A suspension (thenable thrown by `use`) must propagate so React can wait. | ||
| if (isThenable(thrown)) { | ||
| throw thrown; | ||
| } | ||
| const tag = | ||
| typeof thrown === 'object' && thrown !== null | ||
| ? (thrown as { readonly _tag?: unknown })._tag | ||
| : undefined; | ||
| if (typeof tag === 'string') { | ||
| const handler = handlers[tag]; | ||
| if (handler !== undefined) { | ||
| return handler(thrown); | ||
| } | ||
| } | ||
| // A defect or an error tag this REC did not name — not ours to swallow. | ||
| throw thrown; | ||
| } | ||
| }; | ||
| // `.catch` is applied by the client (a React error boundary + an inline | ||
| // fast-path; see `react/rec.tsx`) and by the server driver (inline, no hooks) — | ||
| // each maps a thrown tagged error to its handler. The interpreter only surfaces | ||
| // the failure by throwing it; it does not dispatch on `.catch` itself. |
@@ -15,3 +15,3 @@ /** | ||
| import * as Layer from 'effect/Layer'; | ||
| import { hook, mount, query, rec, suspend } from '../../index.client.ts'; | ||
| import { atom, hook, mount, query, rec, suspend } from '../../index.client.ts'; | ||
@@ -236,2 +236,80 @@ Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); | ||
| }); | ||
| it('a refetch that fails renders the .catch fallback in place (siblings survive)', async () => { | ||
| // Mirrors the playground: a query keyed by a service atom, initially succeeds, | ||
| // then the key changes to one whose fetch fails with a typed error. Gated | ||
| // promises make the async settlement deterministic. | ||
| const gates = new Map<number, { promise: Promise<string>; settle: () => void }>(); | ||
| const gateFor = (id: number): { promise: Promise<string>; settle: () => void } => { | ||
| const existing = gates.get(id); | ||
| if (existing) { | ||
| return existing; | ||
| } | ||
| let settle: () => void = () => {}; | ||
| const promise = new Promise<string>((resolve, reject) => { | ||
| settle = () => (id === 9 ? reject(new NotFound({ id })) : resolve(`product-${id}`)); | ||
| }); | ||
| const gate = { promise, settle }; | ||
| gates.set(id, gate); | ||
| return gate; | ||
| }; | ||
| const load = (id: number): Effect.Effect<string, NotFound> => | ||
| Effect.tryPromise({ try: () => gateFor(id).promise, catch: (e) => e as NotFound }); | ||
| const sku = atom(1); | ||
| const tag = atom('•'); | ||
| // A reactive read comes AFTER the catchable query on purpose: a failure skips | ||
| // it, so the render calls fewer hooks than the prior success. `.catch` is a | ||
| // React error boundary, so the throwing render is discarded (no "rendered | ||
| // fewer hooks" teardown) and the fallback shows regardless of yield order. | ||
| const Product = rec(function* () { | ||
| const id = yield* sku; | ||
| const name = yield* query(load(id), id); // refetch when the id changes | ||
| const mark = yield* tag; // ← skipped when the query throws | ||
| return ( | ||
| <span data-x="name"> | ||
| {name} {mark} | ||
| </span> | ||
| ); | ||
| }) | ||
| .catch({ NotFound: (e) => <span data-x="err">missing {e.id}</span> }) | ||
| .suspense(<i>loading</i>); | ||
| const Page = rec(function* () { | ||
| return ( | ||
| <div> | ||
| <span data-x="sibling">nav</span> | ||
| {yield* Product} | ||
| </div> | ||
| ); | ||
| }); | ||
| const root = createRoot(container); | ||
| await act(async () => root.render(mount(Layer.empty, Page))); | ||
| await act(async () => { | ||
| gateFor(1).settle(); | ||
| await gateFor(1).promise; | ||
| }); | ||
| await flush(); | ||
| expect(container.querySelector('[data-x="name"]')?.textContent).toContain('product-1'); | ||
| // Refetch to a failing id — the .catch fallback renders in place, and the | ||
| // sibling survives (the tree must NOT unmount). | ||
| await act(async () => sku.set(9)); | ||
| await act(async () => { | ||
| gateFor(9).settle(); | ||
| await gateFor(9).promise.catch(() => {}); | ||
| }); | ||
| await flush(); | ||
| expect(container.querySelector('[data-x="sibling"]')).not.toBeNull(); | ||
| expect(container.querySelector('[data-x="err"]')?.textContent).toBe('missing 9'); | ||
| // Recovery: because the REC rendered its fallback *inline* (it stayed mounted | ||
| // and subscribed), navigating back to a valid id re-renders and refetches. | ||
| await act(async () => sku.set(1)); | ||
| await flush(); | ||
| expect(container.querySelector('[data-x="err"]')).toBeNull(); | ||
| expect(container.querySelector('[data-x="name"]')?.textContent).toContain('product-1'); | ||
| await act(async () => root.unmount()); | ||
| }); | ||
| }); | ||
@@ -238,0 +316,0 @@ |
@@ -28,2 +28,3 @@ 'use client'; | ||
| use, | ||
| useContext, | ||
| useEffect, | ||
@@ -36,5 +37,6 @@ useRef, | ||
| import type * as Layer from 'effect/Layer'; | ||
| import { driveRecCaught } from '#application/interpreter.ts'; | ||
| import type { Placer, RenderCache, Suspender } from '#application/ports.ts'; | ||
| import type { RecHandle, RecPlacement } from '#domain/protocol.ts'; | ||
| import { driveRec, driveState } from '#application/interpreter.ts'; | ||
| import type { Placer, Reader, RenderCache, Suspender } from '#application/ports.ts'; | ||
| import type { CatchDispatch, RecHandle, RecPlacement } from '#domain/protocol.ts'; | ||
| import { ReadSink, withCatch } from '#infrastructure/react/catch-boundary.tsx'; | ||
| import { atomReader } from '#infrastructure/react/reactivity.tsx'; | ||
@@ -56,2 +58,14 @@ import { | ||
| /** A thenable — how React's `use` signals a suspension. Never a typed failure. */ | ||
| const isThenable = (u: unknown): u is PromiseLike<unknown> => | ||
| typeof u === 'object' && u !== null && typeof (u as { then?: unknown }).then === 'function'; | ||
| /** The `_tag` of a tagged error, if this thrown value looks like one. */ | ||
| const errorTag = (error: unknown): string | undefined => | ||
| typeof error === 'object' && | ||
| error !== null && | ||
| typeof (error as { readonly _tag?: unknown })._tag === 'string' | ||
| ? (error as { readonly _tag: string })._tag | ||
| : undefined; | ||
| const useSuspender = (): Suspender => ({ use }); | ||
@@ -127,17 +141,53 @@ | ||
| useQueryClaims(instanceId, usedRef); | ||
| // When inside a `.catch`, record each atom this body reads so the boundary can | ||
| // watch them for a reset. Reads still go through `atomReader` (per-atom | ||
| // `useSyncExternalStore`, tearing-safe); recording is a side note. | ||
| const sink = useContext(ReadSink); | ||
| const reader: Reader = | ||
| sink === null | ||
| ? atomReader | ||
| : { | ||
| read: (atom) => { | ||
| sink.add(atom); | ||
| return atomReader.read(atom); | ||
| }, | ||
| }; | ||
| // The hook count of this instance's last *committed* render, so a caught | ||
| // failure can tell whether it skipped later hooks. | ||
| const lastHooks = useRef(-1); | ||
| const used = new Set<string>(); | ||
| const resolver = makeSuspensableResolver(scope, executor, suspender, used); | ||
| const node = driveRecCaught( | ||
| handle.body(props), | ||
| { | ||
| executor, | ||
| suspender, | ||
| cache, | ||
| suspensableResolver: resolver, | ||
| reader: atomReader, | ||
| placer: clientPlacer, | ||
| }, | ||
| handle.catchHandlers, | ||
| ); | ||
| const deps = { | ||
| executor, | ||
| suspender, | ||
| cache, | ||
| suspensableResolver: resolver, | ||
| reader, | ||
| placer: clientPlacer, | ||
| }; | ||
| const state = driveState(); | ||
| let node: ReactNode; | ||
| try { | ||
| node = driveRec(handle.body(props), deps, state); | ||
| // Committing render (no suspension) — remember its hook count. | ||
| lastHooks.current = state.hooks; | ||
| } catch (thrown) { | ||
| const tag = errorTag(thrown); | ||
| const handler = | ||
| handle.catchHandlers !== undefined && tag !== undefined | ||
| ? handle.catchHandlers[tag] | ||
| : undefined; | ||
| // Not ours to catch inline — let it propagate: a suspension (to `<Suspense>`), | ||
| // a REC without a matching `.catch`, or a defect (to the nearest boundary). | ||
| // Also re-throw a caught failure that skipped later hooks (fewer than the | ||
| // last committed render): rendering the fallback inline would break the Rules | ||
| // of Hooks, so the `.catch` error boundary handles it instead (same fallback, | ||
| // and it recovers when a read atom changes). See `catch-boundary.tsx`. | ||
| if (handler === undefined || isThenable(thrown) || state.hooks < lastHooks.current) { | ||
| throw thrown; | ||
| } | ||
| node = handler(thrown); | ||
| lastHooks.current = state.hooks; // the fallback committed too | ||
| } | ||
| // Reached only when the body did not suspend, i.e. this render will commit — | ||
@@ -166,4 +216,8 @@ // so the claim effects always reconcile against a real, complete used set. | ||
| const element = createElement(clientFcFor(placement.rec), placement.props); | ||
| const handlers = placement.rec.catchHandlers as CatchDispatch<ReactNode> | undefined; | ||
| const caught = handlers === undefined ? element : withCatch(handlers, element); | ||
| const fallback = placement.rec.suspenseFallback; | ||
| return fallback === undefined ? element : createElement(Suspense, { fallback }, element); | ||
| // Suspense outside the catch boundary: a pending query suspends to the | ||
| // fallback here; a *failed* one throws to the catch boundary just inside. | ||
| return fallback === undefined ? caught : createElement(Suspense, { fallback }, caught); | ||
| }, | ||
@@ -216,4 +270,6 @@ }; | ||
| const inner = createElement(clientFcFor(handle), {}); | ||
| const root = fallback === undefined ? inner : createElement(Suspense, { fallback }, inner); | ||
| const handlers = handle.catchHandlers as CatchDispatch<ReactNode> | undefined; | ||
| const caught = handlers === undefined ? inner : withCatch(handlers, inner); | ||
| const root = fallback === undefined ? caught : createElement(Suspense, { fallback }, caught); | ||
| return createElement(Runtime<ROut, LE>, { layer }, root); | ||
| } |
@@ -87,2 +87,8 @@ /** | ||
| * 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. | ||
| */ | ||
@@ -89,0 +95,0 @@ catch(handlers: CatchHandlers<E>): REC<P, UntaggedErrors<E>, R, S>; |
| import { ReactNode } from "react"; | ||
| import * as Effect from "effect/Effect"; | ||
| //#region src/domain/protocol.d.ts | ||
| /** | ||
| * The one unavoidable `any` in the framework. The heterogeneous yield protocol | ||
| * must accept an Effect of *any* error and requirement variance — there is no | ||
| * other way to express "some Effect" as a generic constraint, which is why | ||
| * Effect's own `Effect.gen` is typed exactly this way. Precise `E` and `R` are | ||
| * recovered below through conditional inference, so this never leaks to users. | ||
| */ | ||
| type AnyEffect = Effect.Effect<any, any, any>; | ||
| /** Brand identifying a lifted React hook instruction. */ | ||
| declare const HookTypeId: unique symbol; | ||
| type HookTypeId = typeof HookTypeId; | ||
| /** | ||
| * A React hook result lifted into the `yield*` channel. The hook itself has | ||
| * already executed (synchronously, during render) by the time the value is | ||
| * wrapped — `hook(useState(0))` calls `useState` inline. Wrapping it only makes | ||
| * the result yieldable, so a component body reads as one uniform stream of | ||
| * `yield*`s whether the value comes from Effect or from React. | ||
| */ | ||
| interface Hook<out A> { | ||
| readonly [HookTypeId]: true; | ||
| readonly value: A; | ||
| [Symbol.iterator](): Iterator<Hook<A>, A>; | ||
| } | ||
| /** | ||
| * Lift an already-evaluated React hook result into the effract yield channel. | ||
| * | ||
| * ```ts | ||
| * const [tab, setTab] = yield* hook(useState('overview')); | ||
| * const ref = yield* hook(useRef<HTMLDivElement>(null)); | ||
| * ``` | ||
| * | ||
| * Because the component body runs synchronously inside React's render pass, the | ||
| * wrapped hook call obeys the Rules of Hooks: same order on every render. | ||
| */ | ||
| declare const hook: <A>(value: A) => Hook<A>; | ||
| /** Type guard: is this yielded instruction a lifted hook? */ | ||
| declare const isHook: (u: unknown) => u is Hook<unknown>; | ||
| /** Brand identifying a suspensable instruction (a `suspend`/`query`). */ | ||
| declare const SuspensableTypeId: unique symbol; | ||
| type SuspensableTypeId = typeof SuspensableTypeId; | ||
| /** | ||
| * The fourth kind of yieldable: a *suspensable* — a component's declared | ||
| * asynchronous dependency. Unlike a raw `yield* effect` (which the interpreter | ||
| * may still suspend on, but silently), yielding a suspensable both suspends for | ||
| * the value *and* contributes a {@link Suspends} obligation to the REC's type, | ||
| * so the loading state must be handled somewhere (`.suspense(...)`, or the | ||
| * `mount` boundary) before the tree compiles. Its `effect` carries the `E` and | ||
| * `R` channels, which bubble as a yielded effect's do — so retries, timeouts and | ||
| * cancellation are just Effect combinators on `effect`, and its failures are | ||
| * catchable with `.catch`. `key` (compared by value) drives refetch; `undefined` | ||
| * means load-once. Both {@link suspend} and {@link query} produce this. | ||
| */ | ||
| interface Suspensable<out A, out E, out R> { | ||
| readonly [SuspensableTypeId]: true; | ||
| readonly effect: Effect.Effect<A, E, R>; | ||
| readonly key: unknown; | ||
| [Symbol.iterator](): Iterator<Suspensable<A, E, R>, A>; | ||
| } | ||
| /** | ||
| * The suspensable primitive: run `effect`, suspend the render until it settles | ||
| * (through React's `use`), and return its value — declaring a loading obligation | ||
| * the type system makes you handle. Load-once: it runs a single time per | ||
| * component instance and position, deduped across every render attempt (including | ||
| * the pre-commit retries React makes when a component suspends before it first | ||
| * commits), and its in-flight fiber is interrupted when the component unmounts. | ||
| * | ||
| * It is the building block {@link query} is made of — reach for `suspend` to | ||
| * opt an effect into Suspense without query's keyed-refetch semantics, or to | ||
| * build your own async abstractions on top. | ||
| * | ||
| * ```ts | ||
| * const config = yield* suspend(loadConfig()); // load-once | ||
| * const feed = yield* suspend(load.pipe(Effect.timeout('2s'))); // policies via Effect | ||
| * ``` | ||
| */ | ||
| declare const suspend: <A, E, R>(effect: Effect.Effect<A, E, R>) => Suspensable<A, E, R>; | ||
| /** | ||
| * A keyed suspensable — {@link suspend} plus refetch. It re-runs `effect` when | ||
| * `key` changes by value (leaving `key` off makes it load-once, exactly like | ||
| * `suspend`). Same guarantees otherwise: deduped across render attempts, | ||
| * interrupted on unmount, failures catchable with `.catch`. | ||
| * | ||
| * ```ts | ||
| * const user = yield* query(fetchUser(id), id); // refetch when id changes | ||
| * ``` | ||
| */ | ||
| declare const query: <A, E, R>(effect: Effect.Effect<A, E, R>, key?: unknown) => Suspensable<A, E, R>; | ||
| /** Type guard: is this yielded instruction a suspensable (`suspend`/`query`)? */ | ||
| declare const isSuspensable: (u: unknown) => u is Suspensable<unknown, unknown, unknown>; | ||
| /** Brand identifying a reactive atom instruction. */ | ||
| declare const AtomTypeId: unique symbol; | ||
| type AtomTypeId = typeof AtomTypeId; | ||
| /** | ||
| * The fifth kind of yieldable: a *reactive atom read*. Reactive state lives in | ||
| * the Effect world — an atom, usually held by a service. Yielding one reads its | ||
| * current value *and* subscribes the component, so it re-renders precisely when | ||
| * that atom changes: `const n = yield* count`. It carries no `E`/`R`/`S` — a | ||
| * read is synchronous, needs nothing, and cannot fail. Derived atoms | ||
| * (`derive(...)`) are `ReadableAtom`s too, so they read and yield identically. | ||
| * The concrete atom is built in the React binding; the domain only names the | ||
| * contract — a value you can read, subscribe to, and `yield*`. | ||
| */ | ||
| interface ReadableAtom<out A> { | ||
| readonly [AtomTypeId]: true; | ||
| /** The current value — read imperatively (in an event handler or a method). */ | ||
| readonly value: A; | ||
| /** Subscribe to changes; returns an unsubscribe. Backs the in-render read. */ | ||
| subscribe(listener: () => void): () => void; | ||
| /** | ||
| * Derive a new read-only atom from *this one's* value — the ergonomic, | ||
| * single-source form of `derive`. The callback receives the value directly (no | ||
| * `$`), and the result recomputes when this atom changes; chain it freely. | ||
| * For a value computed from *several* atoms, reach for the free `derive(($) => …)`. | ||
| * | ||
| * ```ts | ||
| * const count = items.derive((list) => list.length); | ||
| * ``` | ||
| */ | ||
| derive<B>(f: (value: A) => B): ReadableAtom<B>; | ||
| [Symbol.iterator](): Iterator<ReadableAtom<A>, A>; | ||
| } | ||
| /** A writable reactive atom — a {@link ReadableAtom} you can also `set`/`update`. */ | ||
| interface Atom<in out A> extends ReadableAtom<A> { | ||
| set(value: A): void; | ||
| update(update: (previous: A) => A): void; | ||
| } | ||
| /** Type guard: is this yielded instruction a reactive atom read? */ | ||
| declare const isAtom: (u: unknown) => u is ReadableAtom<unknown>; | ||
| /** | ||
| * The phantom marker of an *unhandled loading obligation*. A body that yields a | ||
| * {@link Suspensable} (or places a child that still carries one) has this in its | ||
| * `S` channel; `.suspense(fallback)` — or the `mount` boundary — discharges it | ||
| * back to `never`. It never exists at runtime; it exists only so the type system | ||
| * can insist a loading state is handled somewhere between component and root. The | ||
| * branded field is nominal — it makes `Suspends` distinct from `never` and from | ||
| * any ordinary type, so the discharge checks are exact. Loading is a single | ||
| * catch-all obligation (not a union like the tagged error channel): a pending | ||
| * effect has no tag, so one `.suspense` discharges the whole subtree beneath it. | ||
| */ | ||
| interface Suspends { | ||
| readonly ['@tmonier/effract/loading']: true; | ||
| } | ||
| /** Everything a body may `yield*`: an Effect, a hook, a placement, a query, or an atom read. */ | ||
| type Yieldable<A> = Effect.Effect<A, unknown, unknown> | Hook<A> | RecPlacement<A, unknown, unknown> | Suspensable<A, unknown, unknown> | ReadableAtom<A>; | ||
| /** The generator a React Effect Component body produces. */ | ||
| type RecGenerator<A> = Generator<AnyEffect | Hook<unknown> | RecPlacement<unknown, unknown, unknown> | Suspensable<unknown, unknown, unknown> | ReadableAtom<unknown>, A, unknown>; | ||
| /** A component body: props in, a generator of yields ending in a rendered `A`. */ | ||
| type RecBody<Props, A> = (props: Props) => RecGenerator<A>; | ||
| /** | ||
| * Runtime dispatch table for `.catch`: an error `_tag` maps to the node to | ||
| * render in that error's place. Renderer-agnostic in `A` — the domain never | ||
| * learns that a node is a React element; it only carries the mapping so both the | ||
| * client interpreter and the server driver can honour it. The typed, exhaustive | ||
| * shape users write (`CatchHandlers`) narrows to this at the `.catch` boundary. | ||
| */ | ||
| type CatchDispatch<A> = Readonly<Record<string, (error: unknown) => A>>; | ||
| /** | ||
| * A stable handle to a component's body — the identity a placement points at. | ||
| * The client keys its per-descriptor React component on this object (so a child | ||
| * keeps a stable React type across re-renders); the server drives its `body` | ||
| * directly. Deliberately renderer-agnostic: it names *what* to run, not how. | ||
| */ | ||
| interface RecHandle<A> { | ||
| readonly body: (props: any) => RecGenerator<A>; | ||
| readonly displayName: string; | ||
| /** | ||
| * Typed-error fallbacks for this REC, if it was `.catch`-wrapped: a failure | ||
| * from one of *its own* `yield*`ed effects whose `_tag` is present renders the | ||
| * mapped node instead of propagating. Absent for a plain REC. | ||
| */ | ||
| readonly catchHandlers?: CatchDispatch<A>; | ||
| /** | ||
| * The loading fallback for this REC, if it was `.suspense`-wrapped: the client | ||
| * places it in a real `<Suspense>` boundary so a yielded {@link Suspensable}'s | ||
| * pending state renders this node. Renderer-agnostic in `A`; absent otherwise. | ||
| */ | ||
| readonly suspenseFallback?: A; | ||
| } | ||
| /** Brand identifying a child-REC placement instruction. */ | ||
| declare const PlacementTypeId: unique symbol; | ||
| type PlacementTypeId = typeof PlacementTypeId; | ||
| /** | ||
| * The third kind of yieldable, alongside Effects and Hooks: the placement of a | ||
| * *child* REC into a parent's tree (`yield* Child` / `yield* Child.with(p)`). | ||
| * | ||
| * A placement carries only data — the child's stable `rec` handle and its | ||
| * `props` — never a bound React element. That is what lets one `rec(...)` value | ||
| * be shared across runtimes: the client turns a placement into a real React | ||
| * child fiber, the server drives the child's body inline. The phantom `R` makes | ||
| * the child's Effect requirements flow up through `yield*` to `mount`/`serve`, | ||
| * exactly as a yielded service's requirements do. | ||
| */ | ||
| interface RecPlacement<A, R, S = never> { | ||
| readonly [PlacementTypeId]: true; | ||
| readonly rec: RecHandle<A>; | ||
| readonly props: object; | ||
| /** | ||
| * Phantom carrier for the requirements this placement contributes upward. | ||
| * Covariant (an output position) so a concrete placement — e.g. one needing | ||
| * `Stats` — widens to `RecPlacement<_, unknown>` in a body's yield union, just | ||
| * as `Effect<_, _, Stats>` widens to `AnyEffect`. Never set at runtime. | ||
| */ | ||
| readonly _requirements?: R; | ||
| /** | ||
| * Phantom carrier for the child's unhandled loading obligation `S`. Covariant, | ||
| * like `_requirements`, so a placed child that still suspends bubbles its | ||
| * {@link Suspends} up to the parent's `S` — until an ancestor `.suspense`s it. | ||
| * Never set at runtime. | ||
| */ | ||
| readonly _suspends?: S; | ||
| [Symbol.iterator](): Iterator<RecPlacement<A, R, S>, A>; | ||
| } | ||
| /** | ||
| * Construct a child placement. Single-shot iterable like {@link hook}: `yield*` | ||
| * hands the interpreter the placement instruction, and the value fed back in | ||
| * (the rendered child node) becomes the result of the `yield*`. | ||
| */ | ||
| declare const placement: <A, R, S = never>(rec: RecHandle<A>, props: object) => RecPlacement<A, R, S>; | ||
| /** Type guard: is this yielded instruction a child-REC placement? */ | ||
| declare const isPlacement: (u: unknown) => u is RecPlacement<unknown, unknown, unknown>; | ||
| /** | ||
| * Distribute over the yield union and keep only its Effect members. Hooks are | ||
| * not Effects, so they drop away — leaving just what carries `E` and `R`. | ||
| */ | ||
| type EffectsOnly<Eff> = Eff extends AnyEffect ? Eff : never; | ||
| /** | ||
| * Re-express each yielded placement as an effect carrying only its child's | ||
| * requirements, so a placed child's `R` joins the parent's the same way a | ||
| * yielded service's does — one child, its whole subtree's services bubble up. | ||
| */ | ||
| type PlacementsAsEffects<Eff> = Eff extends RecPlacement<unknown, infer R, unknown> ? Effect.Effect<unknown, unknown, R> : never; | ||
| /** | ||
| * Re-express each yielded suspensable as an effect carrying its `E` and `R`, so | ||
| * a suspensable's errors and requirements join the body's exactly as a raw | ||
| * effect's do. | ||
| */ | ||
| type SuspensablesAsEffects<Eff> = Eff extends Suspensable<unknown, infer E, infer R> ? Effect.Effect<unknown, E, R> : never; | ||
| /** | ||
| * Recover the Effect requirement channel `R` from everything a body yields — | ||
| * services, effects, placed child RECs, and suspensables. A body that needs both | ||
| * `A` and `B` requires `A & B`, which is exactly the intersection TypeScript | ||
| * infers from the contravariant requirement slot. | ||
| */ | ||
| type RequirementsOf<Eff> = [EffectsOnly<Eff> | PlacementsAsEffects<Eff> | SuspensablesAsEffects<Eff>] extends [Effect.Effect<unknown, unknown, infer R>] ? R : never; | ||
| /** Recover the Effect error channel `E` (a union — any yielded effect or suspensable may fail). */ | ||
| type ErrorsOf<Eff> = [EffectsOnly<Eff> | SuspensablesAsEffects<Eff>] extends [Effect.Effect<unknown, infer E, unknown>] ? E : never; | ||
| /** | ||
| * Recover the loading obligation `S`. A yielded {@link Suspensable} contributes | ||
| * {@link Suspends}; a placed child contributes whatever `S` it still carries. | ||
| * The union is `Suspends` if *anything* below is unhandled, and `never` once it | ||
| * all is — the exact condition `mount` (and `.suspense`) check. | ||
| */ | ||
| type SuspendsOf<Eff> = Eff extends Suspensable<unknown, unknown, unknown> ? Suspends : Eff extends RecPlacement<unknown, unknown, infer S> ? S : never; | ||
| //#endregion | ||
| //#region src/infrastructure/rec-core.d.ts | ||
| /** Brand identifying a React Effect Component. */ | ||
| declare const RecTypeId: unique symbol; | ||
| 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<E> = Exclude<E, Tagged>; | ||
| /** | ||
| * 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 `{}`. | ||
| */ | ||
| type CatchHandlers<E, A = ReactNode> = { readonly [Tag in Extract<E, Tagged>['_tag']]: (error: Extract<E, { | ||
| readonly _tag: Tag; | ||
| }>) => A }; | ||
| /** Any yieldable a REC body may produce — the constraint `rec` infers `Eff` against. */ | ||
| type AnyYield = AnyEffect | Hook<unknown> | RecPlacement<ReactNode, unknown, unknown> | Suspensable<unknown, unknown, unknown> | ReadableAtom<unknown>; | ||
| interface RecCore<P, E, R, S> extends RecHandle<ReactNode> { | ||
| readonly [RecTypeId]: true; | ||
| /** Place this REC with props: `yield* Child.with({ ... })`. */ | ||
| with(props: P): RecPlacement<ReactNode, R, S>; | ||
| /** | ||
| * 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 <Card user={user} />; | ||
| * }).catch({ | ||
| * NotFound: () => <Empty />, | ||
| * Unauthorized: () => <Login />, | ||
| * }); | ||
| * ``` | ||
| * | ||
| * 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). | ||
| */ | ||
| catch(handlers: CatchHandlers<E>): REC<P, UntaggedErrors<E>, R, S>; | ||
| /** | ||
| * 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 `<Suspense>` boundary, so while it is pending the | ||
| * `fallback` renders in its place. | ||
| * | ||
| * ```tsx | ||
| * const Page = Profile.suspense(<Skeleton />); // 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<P, E, R, never>; | ||
| } | ||
| interface RecBareYield<R, S> { | ||
| /** Place this REC without props: `yield* Child`. */ | ||
| [Symbol.iterator](): Iterator<RecPlacement<ReactNode, R, S>, ReactNode>; | ||
| } | ||
| /** | ||
| * A React Effect Component. Yieldable (so `R` and `S` propagate), never a JSX | ||
| * element type — `<Rec />` 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). | ||
| */ | ||
| type REC<P, E, R, S> = RecCore<P, E, R, S> & ([Record<never, never>] extends [P] ? RecBareYield<R, S> : unknown); | ||
| /** | ||
| * 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. | ||
| */ | ||
| declare function rec<Eff extends AnyYield, A extends ReactNode>(body: () => Generator<Eff, A, never>): REC<Record<never, never>, ErrorsOf<Eff>, RequirementsOf<Eff>, SuspendsOf<Eff>>; | ||
| declare function rec<Eff extends AnyYield, A extends ReactNode, Props extends object>(body: (props: Props) => Generator<Eff, A, never>): REC<Props, ErrorsOf<Eff>, RequirementsOf<Eff>, SuspendsOf<Eff>>; | ||
| /** A type error naming the services a runtime is missing for a REC's tree. */ | ||
| type MissingServices<Missing> = readonly ['effract: runtime is missing', Missing]; | ||
| /** A type error demanding a loading fallback for a tree that can still suspend. */ | ||
| type LoadingNotHandled = readonly ['effract: loading not handled — add .suspense(fallback), or mount(layer, root, { loading })']; | ||
| /** | ||
| * 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. | ||
| */ | ||
| type Effective<R> = [unknown] extends [R] ? never : R; | ||
| //#endregion | ||
| //#region src/infrastructure/reactivity-core.d.ts | ||
| /** Read an atom inside a `derive`/`observe` selector, subscribing to it. */ | ||
| type Read = <A>(atom: ReadableAtom<A>) => A; | ||
| /** | ||
| * Run `writes` as one atomic notification wave: atoms may be `set` many times | ||
| * inside, but subscribers (and the derived atoms and components downstream) are | ||
| * notified once, after it returns. Returns whatever `writes` returns. | ||
| * | ||
| * ```ts | ||
| * batch(() => { | ||
| * first.set('Ada'); | ||
| * last.set('Lovelace'); | ||
| * }); // one re-render, not two | ||
| * ``` | ||
| */ | ||
| declare const batch: <A>(writes: () => A) => A; | ||
| /** | ||
| * Create a writable reactive atom. Read it with `yield*` in a REC, `.value` | ||
| * imperatively (in an event handler or a service method), or `$(atom)` inside | ||
| * `derive`/`<Observe>`; write it with `.set` / `.update`. Backed by Effect's | ||
| * `AtomRef` for storage, so its value is reachable from anywhere an Effect runs; | ||
| * writes that don't change the value (by `Equal`) notify no one, and writes made | ||
| * inside {@link batch} are coalesced. | ||
| */ | ||
| declare const atom: <A>(initial: A) => Atom<A>; | ||
| /** | ||
| * A keyed collection of atoms — one lazily-created, memoised atom per key, so | ||
| * per-entity state (a row, a cart line, a todo) is a family lookup rather than | ||
| * one giant atom you slice. `family(key)` returns the same atom for equal keys; | ||
| * `make` builds a fresh one the first time a key is seen. `forget`/`clear` drop | ||
| * cached entries (e.g. when an entity is deleted). Non-primitive keys need a | ||
| * `keyOf` to reduce them to a stable map key. | ||
| * | ||
| * ```ts | ||
| * const quantities = atomFamily((_id: string) => atom(1)); | ||
| * quantities('sku-1').update((n) => n + 1); // independent per id | ||
| * ``` | ||
| */ | ||
| interface AtomFamily<K, A> { | ||
| (key: K): A; | ||
| /** Drop the cached atom for `key` (the next lookup makes a fresh one). */ | ||
| forget(key: K): void; | ||
| /** Drop every cached atom. */ | ||
| clear(): void; | ||
| } | ||
| declare const atomFamily: <K, A>(make: (key: K) => A, keyOf?: (key: K) => unknown) => AtomFamily<K, A>; | ||
| /** | ||
| * An *async derived* value: it reads atoms and returns an `Effect`, and reads in | ||
| * a REC with `yield*` like any other atom — but it *suspends* while the effect | ||
| * runs, re-runs when a source atom changes (keyed by the read values, so an | ||
| * unchanged source is not refetched), and contributes the same loading obligation | ||
| * `S` (plus the effect's `E`/`R`) that {@link query} does. Async derivation, still | ||
| * expressed as data in the Effect world: | ||
| * | ||
| * ```ts | ||
| * const price = derive.effect(($) => fetchPrice($(sku))); // suspends; refetches on sku change | ||
| * // in a REC: const p = yield* price; // .suspense(...) must handle the loading | ||
| * ``` | ||
| * | ||
| * It owns no interpreter machinery of its own — yielding it drives, in order, a | ||
| * subscription (so the component re-renders when a source changes) and a keyed | ||
| * `query` (so it suspends and refetches). Both are ordinary yieldables, so the | ||
| * `E`/`R`/`S` channels flow exactly as a hand-written `query` would. | ||
| */ | ||
| interface AsyncDerived<A, E, R> { | ||
| [Symbol.iterator](): Iterator<Suspensable<A, E, R> | ReadableAtom<ReadonlyArray<unknown>>, A>; | ||
| } | ||
| /** | ||
| * Create a *derived* atom from *several* atoms: a read-only reactive value whose | ||
| * `$` tracks exactly the atoms the selector reads, so it recomputes — and its | ||
| * readers re-render — precisely when a tracked atom changes. Derived atoms | ||
| * compose. Keep derivation here, in the Effect world, not in a component. | ||
| * | ||
| * For the common case of deriving from a *single* atom, prefer the method form | ||
| * {@link ReadableAtom.derive | `atom.derive`} — it hands you the value directly, | ||
| * with no `$`: `const count = items.derive((list) => list.length)`. | ||
| * | ||
| * ```ts | ||
| * const total = derive(($) => $(price) * $(qty)); // several sources → the $ form | ||
| * ``` | ||
| * | ||
| * {@link deriveWritable | `derive.writable`} adds a two-way variant; | ||
| * {@link deriveEffect | `derive.effect`} an async, suspending one. | ||
| */ | ||
| declare const derive: (<A>(selector: (read: Read) => A) => ReadableAtom<A>) & { | ||
| writable: <A>(selector: (read: Read) => A, write: (value: A) => void) => Atom<A>; | ||
| effect: <A, E, R>(selector: (read: Read) => Effect.Effect<A, E, R>) => AsyncDerived<A, E, R>; | ||
| }; | ||
| //#endregion | ||
| export { SuspendsOf as A, query as B, ReadableAtom as C, RecPlacement as D, RecHandle as E, isAtom as F, isHook as I, isPlacement as L, SuspensableTypeId as M, Yieldable as N, RequirementsOf as O, hook as P, isSuspensable as R, PlacementTypeId as S, RecGenerator as T, suspend as V, AtomTypeId as _, atomFamily as a, Hook as b, CatchHandlers as c, MissingServices as d, REC as f, Atom as g, AnyEffect as h, atom as i, Suspensable as j, Suspends as k, Effective as l, rec as m, AtomFamily as n, batch as o, RecTypeId as p, Read as r, derive as s, AsyncDerived as t, LoadingNotHandled as u, CatchDispatch as v, RecBody as w, HookTypeId as x, ErrorsOf as y, placement as z }; |
232108
6.3%32
3.23%4753
6.91%