'use client';
/**
* The client binding for React Effect Components: it turns a (runtime-agnostic)
* REC descriptor into a real React component whose body effract interprets
* *inside* React's render pass, and provides `mount`, the boundary that supplies
* the browser runtime.
*
* const Dashboard = rec(function* () {
* const stats = yield* Stats; // a service
* const [n, setN] = yield* hook(useState(0)); // a real React hook
* return {yield* StatBadge}{n}; // a child REC, yielded
* });
*
* mount(AppLive, Dashboard); // ← compile error if AppLive lacks a needed service
*
* The descriptor itself (`rec`) lives in `../rec-core.tsx` and is
* server-safe; this module adds only the client half — the in-render
* interpreter and hook dispatch — so the same `rec(...)` value also `serve`s on
* the server. At runtime a REC renders as an ordinary React component (own
* fiber, hooks, reconciliation); the yield is only how it is placed and how `R`
* flows.
*/
import {
Suspense,
createElement,
memo,
use,
useContext,
useEffect,
useReducer,
useRef,
type FunctionComponent,
type ReactElement,
type ReactNode,
} from 'react';
import * as Equal from 'effect/Equal';
import type * as Layer from 'effect/Layer';
import * as ManagedRuntime from 'effect/ManagedRuntime';
import { driveRec, driveState } from '#application/interpreter.ts';
import type {
Executor,
InterpreterDeps,
Reader,
Suspender,
SuspensableResolver,
} from '#application/ports.ts';
import type {
CatchDispatch,
Hook,
ReadableAtom,
RecHandle,
RecPlacement,
Suspensable,
} from '#domain/protocol.ts';
import {
devConnected,
emitDev,
recordRender,
type RenderEvent,
type RenderTrigger,
type TraceEntry,
} from '#infrastructure/devtools-core.ts';
import type { QueryCache } from '#infrastructure/query-cache.ts';
import { ReadSink, reportHandledDefect, withCatch } from '#infrastructure/react/catch-boundary.tsx';
import { AutoSeed } from '#infrastructure/react/hydration.tsx';
import { atomReader } from '#infrastructure/react/reactivity.tsx';
import { atomNameOf } from '#infrastructure/reactivity-core.ts';
import {
makeSuspensableResolver,
nextInstanceId,
reconcileClaims,
releaseClaims,
runtimeScopeOf,
scopeOf,
} from '#infrastructure/react/suspensable-store.ts';
import type {
Effective,
LoadingNotHandled,
MissingServices,
REC,
VolatileNotHandled,
} from '#infrastructure/rec-core.tsx';
import { Runtime, useRuntimeHandles } from '#infrastructure/react/runtime.tsx';
/** A thenable — how React's `use` signals a suspension. Never a typed failure. */
const isThenable = (u: unknown): u is PromiseLike =>
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;
/** React's `use`, in the interpreter's port shape — one shared object. */
const SUSPENDER: Suspender = { use };
/**
* Dev only; a bundler replaces `process.env.NODE_ENV` so this folds to a
* constant in prod. Read once at module load — a live `process.env` lookup on
* every render is measurable across hundreds of components.
*/
const DEV_BUILD: boolean =
typeof process !== 'undefined' && process.env?.['NODE_ENV'] !== 'production';
const isDevMode = (): boolean => DEV_BUILD;
/**
* The per-instance memory behind the dev render ledger (ADR 0007, decision 5):
* what the previous render read/received, so this render's cause can be
* attributed — plus the flip detector's resolution history and the `.hot()`
* frequency ring. Created lazily, and only while attribution is active
* (`devConnected() || isDevMode()`), so production allocates nothing.
*/
interface DevRenderState {
/** Atom → its value last render (the `atom` attribution diff). */
readonly prevAtoms: Map;
/** Suspending-yield index → the (entry, version) it resolved last render. */
readonly prevQueries: Map;
/** Lifted-hook ordinal → its value last render (the `hook` attribution diff). */
readonly prevHooks: Map;
/** The props object of the previous render (`null` before the first). */
prevProps: object | null;
/** Raw-effect ordinal → resolved-sync memory for the flip detector. */
readonly flipHistory: Map;
/** Ordinals whose flip was already reported — once per instance per index. */
readonly flipReported: Set;
/** Timestamps of this instance's recent renders (`.hot()` frequency ring). */
readonly hotTimes: number[];
/** When the last `hot:frequency` event was emitted (0 = never). */
hotLastEmit: number;
/** Whether this instance committed a render before (the `parent` default). */
renderedBefore: boolean;
/**
* What the *current* render observed, applied to the `prev*` baselines only
* after the body completes: React discards renders freely (a suspension, a
* replay), and a discarded render that advanced the baselines would make the
* committed replay see "no change" and mis-attribute its cause. Reused
* (cleared at render start) so a dev render allocates nothing per pass.
*/
readonly seenAtoms: Array;
readonly seenHooks: Array;
readonly seenQueries: Array;
}
const makeDevRenderState = (): DevRenderState => ({
prevAtoms: new Map(),
prevQueries: new Map(),
prevHooks: new Map(),
prevProps: null,
flipHistory: new Map(),
flipReported: new Set(),
hotTimes: [],
hotLastEmit: 0,
renderedBefore: false,
seenAtoms: [],
seenHooks: [],
seenQueries: [],
});
/** The prop names whose values changed between two props objects, joined. */
const changedKeys = (prev: object, next: object): string => {
const previous = prev as Record;
const current = next as Record;
const changed: string[] = [];
for (const key of new Set([...Object.keys(previous), ...Object.keys(current)])) {
if (!Equal.equals(previous[key], current[key])) {
changed.push(key);
}
}
return changed.join(', ');
};
/** The `query` trigger's label: the stringified key, or `suspend` for a keyless one. */
const queryTriggerLabel = (key: unknown): string => {
if (key === undefined) {
return 'suspend';
}
try {
return JSON.stringify(key);
} catch {
return String(key);
}
};
/** The default attribution: a re-render nothing else explains — the parent rendered. */
const PARENT_TRIGGER: RenderTrigger = { kind: 'parent', label: '' };
/** The sync↔async invariant (AGENTS.md), spelled out on the `invariant:flip` event. */
const FLIP_INVARIANT =
'a yielded effect must not flip sync↔async between renders — whether a `yield* effect` ' +
'calls a React hook depends on whether it resolved synchronously that render, so an effect ' +
'that is synchronous on one render and asynchronous on the next changes the hook count and ' +
'breaks the Rules of Hooks. Wrap it in `suspend()`/`query()` so its loading is a stable ' +
'hook, or make it consistently async.';
/** The `.hot()` frequency window and ring size — renders in the trailing second. */
const HOT_WINDOW_MS = 1_000;
const HOT_RING = 30;
/**
* Report a `.hot()`-acknowledged instance's render frequency (ADR 0007): a
* small timestamp ring counts renders in the trailing second, emitted at most
* once per second per instance — so an acknowledged hot subtree stays visible,
* exactly how `defect:handled` keeps `.catchDefect` honest.
*/
const reportHotRender = (dev: DevRenderState, rec: string, instanceId: string): void => {
const at = Date.now();
dev.hotTimes.push(at);
if (dev.hotTimes.length > HOT_RING) {
dev.hotTimes.shift();
}
if (at - dev.hotLastEmit < HOT_WINDOW_MS) {
return;
}
dev.hotLastEmit = at;
let renders = 0;
for (const time of dev.hotTimes) {
if (at - time <= HOT_WINDOW_MS) {
renders += 1;
}
}
emitDev({ kind: 'hot:frequency', rec, instanceId, renders });
};
/** A human label for a traced yield — resolved on the binding side (#55). */
const traceLabel = (kind: TraceEntry['kind'], instruction: unknown): string => {
switch (kind) {
case 'child':
return (instruction as RecPlacement).rec.displayName;
case 'atom':
return atomNameOf(instruction as object);
case 'query': {
const key = (instruction as Suspensable).key;
try {
return `query ${JSON.stringify(key)}`;
} catch {
return `query ${String(key)}`;
}
}
default:
return kind; // 'hook' | 'suspend' | 'effect'
}
};
/**
* One instance's memory, held in a single ref: its stable id, whether its
* lifecycle effect is currently set up (StrictMode-safe unmount detection),
* the hook count of its last committed render (see the catch re-throw logic),
* and the dev attribution state. One ref instead of four — React hook slots
* are a real per-component cost at scale.
*/
interface InstanceRecord {
readonly id: string;
alive: boolean;
lastHooks: number;
dev: DevRenderState | null;
session: RenderSession | null;
}
const makeInstanceRecord = (): InstanceRecord => ({
id: nextInstanceId(),
alive: true,
lastHooks: -1,
dev: null,
session: null,
});
/**
* One instance's interpreter dependencies, allocated once and *reset* per
* render. The per-render object churn this replaces — a deps object carrying a
* lazy getter, a placer closure, a resolver-builder closure, two template
* strings — was the largest single cost in the render profile at scale. The
* stable pieces live on the object; the per-render slots are mutated in place
* at the top of each render (and `used` is a *fresh* set each render: the
* claim effect closes over its own render's set).
*/
interface RenderSession extends InterpreterDeps {
used: Set;
/** A read suspended on a pending entry this render (see the catch clause). */
suspended: boolean;
/** Placement counter — body-order ordinal within the current render pass. */
ordinal: number;
/** Cache key for the two derived scope strings below. */
structuralScope: string | null;
scopeBase: string;
componentScope: string;
readonly runtimeScope: string;
/** Built on the first suspending yield of a render; reset per render. */
current: SuspensableResolver | undefined;
onResolve: ((index: number, entryId: string, version: number, key: unknown) => void) | undefined;
reader: Reader;
traceYield: InterpreterDeps['traceYield'];
reportFlip: InterpreterDeps['reportFlip'];
}
const makeSession = (executor: Executor, runtimeScope: string): RenderSession => {
// eslint-style forward ref: `onSuspend` and the lazy resolver read the session
// only at render time, after it exists.
let self: RenderSession;
const onSuspend = (): void => {
self.suspended = true;
};
const session: RenderSession = {
executor,
suspender: SUSPENDER,
reader: atomReader,
runtimeScope,
used: new Set(),
suspended: false,
ordinal: 0,
structuralScope: null,
scopeBase: '',
componentScope: '',
current: undefined,
onResolve: undefined,
traceYield: undefined,
reportFlip: undefined,
suspensableResolver: {
resolve: (request, index) =>
(self.current ??= makeSuspensableResolver(
self.runtimeScope,
self.componentScope,
self.executor,
SUSPENDER,
self.used,
onSuspend,
self.onResolve,
)).resolve(request, index),
},
placer: {
place: (placement: RecPlacement) =>
placeChild(placement, self.scopeBase + self.ordinal++),
},
};
self = session;
return session;
};
const bumpTick = (tick: number): number => tick + 1;
const noRetry = (): void => {};
/**
* One React component per descriptor identity, cached so a yield-composed child
* keeps a stable React type across the parent's re-renders (React would
* otherwise remount it every render). The component interprets the descriptor's
* body in-render, resolving services, hooks, queries, and nested placements.
*/
const fcCache = new WeakMap, FunctionComponent