/** * The yield protocol that lets a single generator body speak two languages at * once: Effect (services, effects) and React (hooks). This module is the pure * heart of the framework — it knows nothing about React or about how effects * are executed. It only defines *what* can flow through `yield*` and how the * interpreter recognises each kind. * * Two kinds of value travel through `yield*`: * * yield* SomeService // an Effect (a service Tag is itself an Effect) * yield* hook(useState(0)) // a Hook: a real React hook call, lifted in * * Both implement the single-shot iterator protocol that `yield*` delegates to, * so the interpreter receives the instruction, resolves it, and feeds the * result back into the generator — exactly how `Effect.gen` drives Tags. */ import * as Effect from 'effect/Effect'; import * as Option from 'effect/Option'; import type * as Scope from 'effect/Scope'; /** * 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. */ // oxlint-disable-next-line typescript/no-explicit-any export type AnyEffect = Effect.Effect; /** Brand identifying a lifted React hook instruction. */ export const HookTypeId = Symbol.for('@tmonier/effract/Hook'); export 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. */ export interface Hook { readonly [HookTypeId]: true; readonly value: A; [Symbol.iterator](): Iterator, 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(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. */ export const hook = (value: A): Hook => { const self: Hook = { [HookTypeId]: true, value, [Symbol.iterator]() { let yielded = false; return { next(sent?: unknown): IteratorResult, A> { if (yielded) { return { done: true, value: sent as A }; } yielded = true; return { done: false, value: self }; }, }; }, }; return self; }; /** Type guard: is this yielded instruction a lifted hook? */ export const isHook = (u: unknown): u is Hook => typeof u === 'object' && u !== null && HookTypeId in u; /** Brand identifying a suspensable instruction (a `suspend`/`query`). */ export const SuspensableTypeId = Symbol.for('@tmonier/effract/Suspensable'); export 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. */ export interface Suspensable { readonly [SuspensableTypeId]: true; readonly effect: Effect.Effect; readonly key: unknown; /** Invalidation tags — `invalidate(tag)` re-runs every query carrying it. */ readonly tags?: ReadonlyArray; /** * How long (ms) a settled value stays *fresh*. Within it, a read serves the * cached value with no refetch. Past it, the read still serves the cached value * immediately but triggers a background revalidation (stale-while-revalidate). * `0` (the default) means always-fresh-once: no automatic revalidation. */ readonly staleTime?: number; /** * When true, this effect is run against a **component-lifetime Scope** the * interpreter creates on first need and closes on unmount — so an * `acquireRelease` resource yielded here has its finalizer run exactly once when * the component goes away. Set by {@link scoped}. */ readonly scoped?: boolean; [Symbol.iterator](): Iterator, A>; } /** Options for {@link query}: invalidation `tags` and a `staleTime` (ms). */ export interface QueryOptions { readonly tags?: ReadonlyArray; readonly staleTime?: number; } const makeSuspensable = ( effect: Effect.Effect, key: unknown, options?: QueryOptions, scoped?: boolean, ): Suspensable => { const self: Suspensable = { [SuspensableTypeId]: true, effect, key, ...(options?.tags === undefined ? {} : { tags: options.tags }), ...(options?.staleTime === undefined ? {} : { staleTime: options.staleTime }), ...(scoped === undefined ? {} : { scoped }), [Symbol.iterator]() { let yielded = false; return { next(sent?: unknown): IteratorResult, A> { if (yielded) { return { done: true, value: sent as A }; } yielded = true; return { done: false, value: self }; }, }; }, }; return self; }; /** * 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 * ``` */ export const suspend = (effect: Effect.Effect): Suspensable => makeSuspensable(effect, undefined); /** * 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 * const feed = yield* query(loadFeed, 'feed', { tags: ['feed'], staleTime: 30_000 }); * ``` * * `options.tags` opt the query into invalidation (`invalidate(tag)` re-runs it); * `options.staleTime` (ms) enables stale-while-revalidate. */ const queryImpl = ( effect: Effect.Effect, key?: unknown, options?: QueryOptions, ): Suspensable => makeSuspensable(effect, key, options); /** * A conditionally-enabled query — the dependent-query case ("fetch the profile * only once a userId exists"). When `enabled` is `false` the `effect` never * fires and the read resolves immediately to `Option.none()`; when `true` it runs * and the read resolves to `Option.some(value)`. Toggling `enabled` (part of the * refetch key) re-runs it. The read value is always an `Option`, so the type * stays honest about the disabled case. * * ```ts * const profile = yield* query.enabled(fetchProfile(userId), userId, userId !== undefined); * // profile: Option — none while disabled, some once it runs * ``` */ const enabledQuery = ( effect: Effect.Effect, key: unknown, enabled: boolean, options?: QueryOptions, ): Suspensable, E, R> => makeSuspensable( enabled ? Effect.map(effect, Option.some) : Effect.succeed(Option.none()), [key, enabled], options, ); export const query = Object.assign(queryImpl, { enabled: enabledQuery }); /** * Yield a **scoped resource** whose finalizer runs when the component unmounts. * The effect may `Effect.acquireRelease` (or otherwise require a `Scope`); effract * creates one `Scope` per component instance on first need and closes it on * unmount (StrictMode-safe), so the resource is acquired once and released exactly * once. The `Scope` is provided here, so it does **not** bubble to `mount` — only * the effect's other requirements do. Like `suspend`, it carries the loading * obligation `S` (acquisition may be async). * * ```ts * const socket = yield* scoped( * Effect.acquireRelease(openSocket(url), (s) => Effect.sync(() => s.close())), * ); * ``` */ export const scoped = ( effect: Effect.Effect, ): Suspensable> => makeSuspensable( effect as unknown as Effect.Effect>, undefined, undefined, true, ); /** Type guard: is this yielded instruction a suspensable (`suspend`/`query`)? */ export const isSuspensable = (u: unknown): u is Suspensable => typeof u === 'object' && u !== null && SuspensableTypeId in u; /** Brand identifying a reactive atom instruction. */ export const AtomTypeId = Symbol.for('@tmonier/effract/Atom'); export 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 — but it does carry the * render channel: `V`, the volatility obligation ({@link Volatile}) of a * high-frequency source, and `L`, the label literal naming what a subscribed * component re-renders on. 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*`. */ export interface ReadableAtom { 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 explicit-sources * `derive([a, b], ([x, y]) => …)`. * The result inherits this atom's `V` and `L` — a derive of a volatile atom * stays volatile, and attribution keeps pointing at the source label. * * ```ts * const count = items.derive((list) => list.length); * ``` */ derive(f: (value: A) => B): ReadableAtom; [Symbol.iterator](): Iterator, A>; } /** A writable reactive atom — a {@link ReadableAtom} you can also `set`/`update`. */ export interface Atom extends ReadableAtom< A, V, L > { set(value: A): void; update(update: (previous: A) => A): void; } /** Type guard: is this yielded instruction a reactive atom read? */ export const isAtom = (u: unknown): u is ReadableAtom => typeof u === 'object' && u !== null && AtomTypeId in u; /** * 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. */ export interface Suspends { readonly ['@tmonier/effract/loading']: true; } /** * The phantom marker of an *unhandled volatility obligation*. A body that reads * a high-frequency atom (one born volatile, e.g. from `atom.fromStream`) — or * places a child that still carries one — has this in its `V` channel: the * subscription re-renders the component on every emission, so `mount` refuses * the tree until the read is deferred (`defer(atom)`) or the component * deliberately acknowledges the heat (`.hot()`), discharging it back to * `never`. It never exists at runtime; it exists only so the type system can * insist a hot subscription is scheduled somewhere between component and root. * The branded field is nominal — it makes `Volatile` distinct from `never` and * from any ordinary type, so the discharge checks are exact. */ export interface Volatile { readonly ['@tmonier/effract/volatile']: true; } /** Everything a body may `yield*`: an Effect, a hook, a placement, a query, or an atom read. */ export type Yieldable = | Effect.Effect | Hook | RecPlacement | Suspensable | ReadableAtom; /** The generator a React Effect Component body produces. */ export type RecGenerator = Generator< | AnyEffect | Hook | RecPlacement | Suspensable | ReadableAtom, A, unknown >; /** A component body: props in, a generator of yields ending in a rendered `A`. */ export type RecBody = (props: Props) => RecGenerator; /** * The API a `.catch` fallback receives alongside the error: a `retry` that * re-attempts the component's body (re-mounting under a boundary, or re-rendering * inline), so a fallback can offer a retry button beyond the automatic * atom-watch reset. */ export interface CatchApi { readonly retry: () => void; } /** * Non-enumerable key under which the interpreter stashes the original Effect * `Cause` on a thrown defect, so a dev boundary can pretty-print the full cause * with component context before re-propagating it (#57). Absent on tagged * failures (those are handled by `.catch`, never re-thrown). */ export const CAUSE_KEY = Symbol.for('effract/cause'); /** * Runtime dispatch table for `.catch`: an error `_tag` maps to the node to * render in that error's place, given the error and a {@link CatchApi}. * 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. */ export type CatchDispatch = Readonly 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. */ export interface RecHandle { // oxlint-disable-next-line typescript/no-explicit-any -- a stored body accepts whatever props its placement supplies; precise props are recovered at the call site. readonly body: (props: any) => RecGenerator; 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; /** * The explicit last-resort fallback for this REC, if it was * `.catchDefect`-wrapped: a failure `.catch` did not handle — a defect, an * untagged failure, an unnamed tag — renders this node instead of propagating. * Tag handlers always win; in dev an absorbed defect is surfaced through the * devtools seam so the catch-all is never silent (#57). Absent for a plain REC, * where such failures propagate to the nearest React error boundary. */ readonly defectHandler?: (defect: unknown, api: CatchApi) => A; /** * The loading fallback for this REC, if it was `.suspense`-wrapped: the client * places it in a real `` boundary so a yielded {@link Suspensable}'s * pending state renders this node. Renderer-agnostic in `A`; absent otherwise. */ readonly suspenseFallback?: A; /** * The acknowledged-volatile marker for this REC, if it was `.hot()`-wrapped: * the author deliberately accepted that this subtree re-renders at the source * frequency of its volatile subscriptions. Purely informational at render time * (the discharge itself is type-level) — the dev render ledger reads it to * report hot subtrees, so the acknowledgment is never silent. Absent for a * plain REC. */ readonly hotAcknowledged?: boolean; /** * The explicit-memoization marker for this REC, if it was `.memo()`-wrapped: * the client wraps its React component in `React.memo` with a *bounded*, * value-aware props comparator (`Object.is` per key, or the `Equal` trait * when both sides implement it). The comparator's soundness is proved at the * `.memo()` call site — the method only type-checks when every prop is a * primitive or an `Equal` implementor — so the binding never deep-compares a * plain structure. Absent for a plain REC, which re-renders with its parent * exactly like any React component. */ readonly memoized?: true; } /** Brand identifying a child-REC placement instruction. */ export const PlacementTypeId = Symbol.for('@tmonier/effract/Placement'); export 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. */ export interface RecPlacement { readonly [PlacementTypeId]: true; readonly rec: RecHandle; 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; /** * Phantom carrier for the child's unhandled volatility obligation `V`. * Covariant, like `_suspends`, so a placed child that still reads a volatile * atom bubbles its {@link Volatile} up to the parent's `V` — until an ancestor * defers the read or goes `.hot()`. Never set at runtime. */ readonly _volatile?: V; [Symbol.iterator](): Iterator, 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*`. */ // Class-backed so the iterator machinery lives on shared prototypes: a list // re-render creates one placement per row per pass, and per-object closures // were measurable in the render profile. class PlacementIterator implements Iterator, A> { private yielded = false; constructor(private readonly self: RecPlacement) {} next(sent?: unknown): IteratorResult, A> { if (this.yielded) { return { done: true, value: sent as A }; } this.yielded = true; return { done: false, value: this.self }; } } class PlacementImpl { declare readonly [PlacementTypeId]: true; constructor( readonly rec: RecHandle, readonly props: object, ) {} [Symbol.iterator](): Iterator, A> { return new PlacementIterator(this as unknown as RecPlacement); } } Object.defineProperty(PlacementImpl.prototype, PlacementTypeId, { value: true }); export const placement = ( rec: RecHandle, props: object, ): RecPlacement => new PlacementImpl(rec, props) as unknown as RecPlacement; /** Type guard: is this yielded instruction a child-REC placement? */ export const isPlacement = (u: unknown): u is RecPlacement => typeof u === 'object' && u !== null && PlacementTypeId in u; /** * 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 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 extends RecPlacement ? Effect.Effect : 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 extends Suspensable ? Effect.Effect : 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. */ export type RequirementsOf = [ EffectsOnly | PlacementsAsEffects | SuspensablesAsEffects, ] extends [Effect.Effect] ? R : never; /** Recover the Effect error channel `E` (a union — any yielded effect or suspensable may fail). */ export type ErrorsOf = [EffectsOnly | SuspensablesAsEffects] extends [ Effect.Effect, ] ? 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. */ export type SuspendsOf = Eff extends Suspensable ? Suspends : Eff extends RecPlacement ? S : never; /** * Recover the volatility obligation `V`. A yielded volatile atom read * contributes {@link Volatile}; a placed child contributes whatever `V` it * still carries. The union is `Volatile` if *anything* below is undischarged, * and `never` once it all is — the exact condition `mount` (and * `defer`/`.hot()`) check. */ export type VolatilityOf = Eff extends ReadableAtom ? V : Eff extends RecPlacement ? V2 : never; /** * Recover the subscription labels `L` — the union of label literals whose * atoms a body reads, which is exactly the set of writes that can re-render * it. Placements deliberately contribute `never`: a child re-renders on its * own subscriptions without re-rendering its parent, so a child's labels do * not bubble. Backs the public `RendersOn` introspection. */ export type SubscribesOf = Eff extends ReadableAtom ? L : never;