/** * Ports the interpreter depends on. Both are capabilities the *infrastructure* * supplies — an Effect runtime and React's `use` — so the reconciler itself * stays free of any concrete runtime or renderer and can be unit-tested with * plain fakes. */ import type * as Effect from 'effect/Effect'; import type * as Context from 'effect/Context'; import type * as Exit from 'effect/Exit'; import type * as Fiber from 'effect/Fiber'; import type { AnyEffect, ReadableAtom, RecPlacement } from '#domain/protocol.ts'; /** * Something that can run an Effect. Backed in production by a `ManagedRuntime` * built once at the `mount` boundary, which already carries the resolved * service environment — so `runSyncExit` resolves services synchronously and * only genuinely asynchronous work falls through to `runPromise`. */ export interface Executor { readonly runSyncExit: (effect: AnyEffect) => Exit.Exit; /** * The runtime's built service context, or `null` while the layer is still * building. The interpreter's fast path for `yield* ServiceTag` — the * overwhelmingly common yield — reads the service straight out of this * context (~20x cheaper than a synchronous fiber run). `null` (an async * layer mid-build) falls back to `runSyncExit`, preserving the * suspend-until-built semantics of the first render. */ readonly serviceContext: () => Context.Context | null; /** * Run an effect to a promise. An optional `AbortSignal` interrupts the fiber * when it fires (closing scopes, running finalizers) — the seam a query uses * to cancel its in-flight request on unmount. */ readonly runPromise: (effect: AnyEffect, signal?: AbortSignal) => Promise; /** * Run an effect to a typed {@link Exit.Exit}, preserving its `A` and `E`. The * runtime provides `R`, so it is a free phantom here — the seam a `mutation` * uses to run its effect and route the typed failure to rollback + `error`. */ readonly runPromiseExit: (effect: Effect.Effect) => Promise>; /** * Await an *already-running* fiber's result as a promise, without re-running * its effect. Raw `yield* asyncEffect` uses this: the sync/async probe * (`runSyncExit`) leaves the effect running on a fiber (surfaced by the * `AsyncFiberError`), so effract *joins* that very fiber rather than executing * the effect a second time — one execution, and it stays interruptible. */ readonly join: (fiber: Fiber.Fiber) => Promise; /** Interrupt a running fiber (closing scopes, running finalizers), fire-and-forget. */ readonly interrupt: (fiber: Fiber.Fiber) => void; /** * Run `effect` against a fresh `Scope`, returning its promise and a `close` that * runs the scope's finalizers. Backs a component-lifetime `scoped(effect)`: the * store keeps `close` as the entry's canceller, so the resource is released when * the component unmounts. */ readonly withScope: (effect: AnyEffect) => { readonly promise: Promise; readonly close: () => void; }; } /** * React's `use`, injected. Given a stable promise it suspends the render until * the promise settles, then returns the value (or throws to the nearest error * boundary). The interpreter never imports React — it asks for this instead. */ export interface Suspender { readonly use: (promise: Promise) => A; } /** * A suspending yield the resolver must settle, in one of two shapes: * * - `effect` — a yielded {@link Suspensable} (`suspend`/`query`): the resolver * runs it (with an `AbortSignal`) on cache miss. * - `fiber` — a raw `yield* asyncEffect`: its effect is *already running* on * this fiber (the sync/async probe left it so), so the resolver *joins* it * rather than re-running, and interrupts a redundant fiber from a later * render attempt. * * `key` drives dedup/refetch (`undefined` = load-once, which raw async always is). */ export type SuspendRequest = | { readonly effect: AnyEffect; readonly key: unknown; readonly tags?: ReadonlyArray; readonly staleTime?: number; /** Run against a component-lifetime `Scope`, closed on unmount (see `scoped`). */ readonly scoped?: boolean; } | { readonly fiber: Fiber.Fiber; readonly key: unknown }; /** * Resolves a suspending yield to its value, suspending until it settles. Both a * yielded {@link Suspensable} and a raw async yield route through this one * resolver — the single cancellation standard. Injected because the cache must * outlive the component's render attempts: React re-renders a component that * suspends *before it first commits* with fresh refs, so a per-render cache can't * dedupe that. The React adapter backs this with a store keyed by (component, * encounter order, key) that lives outside the render lifecycle — claimed on * commit, released (and the fiber interrupted) on unmount. `index` is the * encounter order within the body (one counter across every suspending yield). */ export interface SuspensableResolver { resolve(request: SuspendRequest, index: number): unknown; } /** * Turns a child-REC placement into a rendered node. Injected because *how* a * child is placed is renderer-specific — on the client it becomes a real React * child fiber (`createElement`); the interpreter itself must stay React-free, so * it types the child's node as `unknown` and feeds the result straight back into * the generator. `place` is a *method* deliberately: its parameter is bivariant, * so the concrete renderer can declare the node type it actually produces (e.g. * `ReactNode`) without the interpreter — or the renderer — needing a cast. */ export interface Placer { place(placement: RecPlacement): unknown; } /** * Reads a yielded reactive atom to its current value and subscribes the render * to it. Injected because the subscription is React's (`useSyncExternalStore`) — * the interpreter stays React-free and just asks for the value. Called once per * atom read, in the body's deterministic order, so the underlying hook keeps a * stable order across renders. */ export interface Reader { read(atom: ReadableAtom): A; } export interface InterpreterDeps { readonly executor: Executor; readonly suspender: Suspender; readonly suspensableResolver: SuspensableResolver; readonly reader: Reader; readonly placer: Placer; /** * Dev-only render-trace hook, called once per yield in order with its kind and * the raw instruction (the React binding resolves a label and emits it to the * devtools inspector). The binding supplies it only while the inspector is * connected, so production does no work — the interpreter just skips the call. */ readonly traceYield: | (( kind: 'effect' | 'atom' | 'query' | 'suspend' | 'hook' | 'child', instruction: unknown, ) => void) | undefined; /** * Dev-only sync↔async flip report, called with the ordinal (among the body's * raw effect yields) of a `yield* effect` that resolved synchronously on one * render and asynchronously on another — the runtime backstop for the * invariant the type system cannot see (see `DriveState.syncHistory`). The * binding supplies it only in development, so production skips the check * entirely; the binding also dedupes, so one instance reports each flipping * yield at most once. */ readonly reportFlip: ((index: number) => void) | undefined; }