/** * The interpreter — the bridge between React's fiber and Effect's fiber. * * React drives a component by calling its function during a render pass. * `driveRec` runs *inside* that pass: it walks the component's generator * synchronously, and for every `yield*` it decides who answers. * * - a lifted hook → the React hook already ran inline; unwrap its value * - a service Tag → resolve it synchronously from the runtime's context * - a sync Effect → run it synchronously, return its value * - an async Effect → suspend through React's `use`, resuming on the retry * * Because the walk is synchronous and deterministic, the user's hook calls keep * a stable order across renders — they are, and remain, ordinary React hooks. * Nothing here forks React's reconciler; it cooperates with it. */ import * as Cause from 'effect/Cause'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Exit from 'effect/Exit'; import { CAUSE_KEY, isAtom, isHook, isPlacement, isSuspensable, type AnyEffect, type RecGenerator, } from '#domain/protocol.ts'; import type { InterpreterDeps } from '#application/ports.ts'; export interface DriveState { /** * Encounter counter for *every* suspending yield — a `suspend`/`query` and a * raw `yield* asyncEffect` alike — in body order. It keys the resolver's * cross-render store, so a yield keeps a stable cache identity across a * Suspense retry. One counter across both kinds: they interleave in the body, * so a shared counter gives each a distinct position (a per-kind counter would * let a raw-async and a suspensable collide at the same store slot). */ index: 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. * * INVARIANT: every branch below that ultimately calls a React hook (a lifted * hook, an atom read, a suspensable, or the async-effect `use`) must increment * this *exactly once*, *before* the hook may throw or suspend — including on * the throwing render. The `.catch` fast-path in `react/rec.tsx` depends on it; * `#37` pins it with an invariant test. Add a hook-bearing yield kind → add its * increment here. */ hooks: number; /** * Ordinal counter for *raw effect* yields — every yield that reaches * `resolveEffect`, in body order. It gives the dev flip detector a stable * per-yield identity across renders (sync effect yields consume no `index`, * so `index` cannot serve). A plain counter bump; costs nothing in production. */ effects: number; /** * Dev-only memory for the sync↔async flip detector: raw-effect ordinal → did * it resolve synchronously last render. The binding threads *one* map across * an instance's renders (fresh counters, same map, via `driveState(map)`), so * `resolveEffect` can spot a yield whose resolution flipped at the same * ordinal and report it through `InterpreterDeps.reportFlip`. Absent in * production — the detector then does no work at all. */ readonly syncHistory?: Map; } /** * A fresh drive state, all counters zeroed. `syncHistory` (dev only) is the * per-instance resolution memory the flip detector compares against — the * binding passes the same map on every render of one instance. */ export const driveState = (syncHistory?: Map): DriveState => ({ index: 0, hooks: 0, effects: 0, ...(syncHistory === undefined ? {} : { syncHistory }), }); /** * Resolve a single yielded Effect against the runtime. Synchronous effects * (services, pure computation, ref reads) return immediately. An effect that * cannot finish synchronously surfaces as an `AsyncFiberError`; we route it * through React Suspense with a promise cached by encounter order, so the * retry after the promise settles returns the value inline. Any other failure * is a real error and is thrown to the nearest React error boundary. */ const resolveEffect = (effect: AnyEffect, deps: InterpreterDeps, state: DriveState): unknown => { if (!Effect.isEffect(effect)) { throw new TypeError( 'effract: a component body yielded a value that is neither an Effect nor a hook(...). ' + 'Wrap React hooks with `hook(...)`, e.g. `yield* hook(useState(0))`.', ); } const ordinal = state.effects++; const exit = deps.executor.runSyncExit(effect); if (Exit.isSuccess(exit)) { noteResolution(deps, state, ordinal, true); return exit.value; } const squashed = Cause.squash(exit.cause); if (Cause.isAsyncFiberError(squashed)) { noteResolution(deps, state, ordinal, false); // A raw async yield routes through the *same* suspensable store as // `suspend`/`query` — one cancellation standard: load-once (no key), deduped // across every render attempt (pre-commit retries, StrictMode), its fiber // interrupted (finalizers run) on unmount. The probe above already left the // effect *running* on `squashed.fiber`, so we hand the store that very fiber // to join — never re-running the effect — and it interrupts the redundant // fiber a later render attempt produces. The resolver runs TWO real hooks // (subscribe to the cache entry + `use` its promise), so count both. state.hooks += 2; return deps.suspensableResolver.resolve( { fiber: squashed.fiber, key: undefined }, state.index++, ); } // Stash the full Cause on the thrown defect so a dev `.catch` boundary can // pretty-print it with component context (#57). Cheap, on an already-exceptional // path; a primitive throw carries no context and is left as-is. if (typeof squashed === 'object' && squashed !== null) { Object.defineProperty(squashed, CAUSE_KEY, { value: exit.cause, configurable: true }); } throw squashed; }; /** * The dev sync↔async flip detector (ADR 0007). A yielded effect that resolves * synchronously on one render and asynchronously on another changes the hook * count and breaks the Rules of Hooks — an invariant the type system cannot * check (a sync and an async `Effect` share one type). When the binding * threads a `syncHistory` map (dev only), remember how each raw-effect ordinal * resolved and report a flip through the `reportFlip` port; without the map * (production) this is a single `undefined` check. */ const noteResolution = ( deps: InterpreterDeps, state: DriveState, ordinal: number, sync: boolean, ): void => { const history = state.syncHistory; if (history === undefined) { return; } const previous = history.get(ordinal); if (previous !== undefined && previous !== sync) { deps.reportFlip?.(ordinal); } history.set(ordinal, sync); }; /** * Run a React Effect Component body to its rendered result. Creates a fresh * generator per render (generators are single-use); a Suspense retry simply * runs this again from the top, replaying hooks in order and hitting the async * cache and the query resolver for already-started work. */ export const driveRec = ( gen: RecGenerator, deps: InterpreterDeps, state: DriveState = driveState(), ): A => { let step = gen.next(); while (!step.done) { const instruction = step.value; let result: unknown; if (isHook(instruction)) { // The hook already ran inline during render; unwrap its value. deps.traceYield?.('hook', instruction); 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.) deps.traceYield?.('child', instruction); result = deps.placer.place(instruction); } else if (isSuspensable(instruction)) { // Async data (suspend/query): the resolver subscribes the render to the // cache entry (so invalidate / refetch / a background revalidation re-render // it) and `use`s its current promise (suspending, returning, or throwing for // `.catch`). Two real hooks — count both before either may throw. deps.traceYield?.(instruction.key !== undefined ? 'query' : 'suspend', instruction); state.hooks += 2; result = deps.suspensableResolver.resolve(instruction, state.index++); } else if (isAtom(instruction)) { // Reactive state: read the atom's value and subscribe this render to it. deps.traceYield?.('atom', instruction); state.hooks += 1; result = deps.reader.read(instruction); } else if (Context.isKey(instruction) && !Context.isReference(instruction)) { // A bare service Tag — the most common effect yield. Once the runtime's // context has built, resolve it with a direct lookup instead of a full // synchronous fiber run (~20x cheaper per read). While an async layer is // still building the context is `null`, so the read falls through to the // effect path below and keeps its suspend-until-built semantics. (A // `Reference` carries a default the runtime must apply — excluded.) deps.traceYield?.('effect', instruction); const services = deps.executor.serviceContext(); if (services !== null) { noteResolution(deps, state, state.effects++, true); result = Context.get(services, instruction as Context.Key); } else { result = resolveEffect(instruction as unknown as AnyEffect, deps, state); } } else { deps.traceYield?.('effect', instruction); result = resolveEffect(instruction, deps, state); } step = gen.next(result); } return step.value; }; // `.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.