'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>(); /** * The bounded props comparator behind `.memo()` (ADR 0007, decision 4): same * own-key set, then per key `Object.is` — or `Equal.equals` when **both** * sides implement the `Equal` trait, which delegates to the values' own * hash-gated implementations. Nothing here walks a plain object, array, or * JSX tree: `.memo()`'s compile gate guarantees every prop is a primitive or * an `Equal` implementor, so the comparator's cost is bounded by the number * of props, never by the size of a value. */ const memoPropsEqual = (prev: object, next: object): boolean => { const previous = prev as Record; const current = next as Record; const keys = Object.keys(previous); if (keys.length !== Object.keys(current).length) { return false; } for (const key of keys) { if (!Object.hasOwn(current, key)) { return false; } const a = previous[key]; const b = current[key]; if (!(Object.is(a, b) || (Equal.isEqual(a) && Equal.isEqual(b) && Equal.equals(a, b)))) { return false; } } return true; }; /** * The private prop carrying a placement's structural scope from the parent's * render into the child instance (`createElement` copies only string-keyed * props, so this cannot be a symbol). Stripped before the body sees its props; * stable per position, so `.memo()` comparison and props attribution are * unaffected. */ const SLOT_PROP = '__effractScope'; /** The props the body sees — the placement's structural scope removed. */ const stripSlotProp = (props: object): object => { if (!Object.hasOwn(props, SLOT_PROP)) { return props; } const { [SLOT_PROP]: _slot, ...rest } = props as Record; return rest; }; const clientFcFor = (handle: RecHandle): FunctionComponent => { const cached = fcCache.get(handle); if (cached !== undefined) { return cached; } const scope = scopeOf(handle); // Whether an inline `.catch`/`.catchDefect` fallback can ever render — fixed // per handle, so the retry hook below is stable per component type. const catchable = handle.catchHandlers !== undefined || handle.defectHandler !== undefined; const fc: FunctionComponent = (props) => { const { executor, runtime } = useRuntimeHandles(); const instRef = useRef(null); const inst = (instRef.current ??= makeInstanceRecord()); const instanceId = inst.id; // Structural identity: where this instance sits in the REC tree — the // parent's structural scope plus this placement's ordinal among the // parent's placement yields (the root falls back to the per-handle scope). // Unlike any fiber-bound identity (a ref, `useId`), it survives the render // attempts React discards before commit — a suspended sibling throws the // whole subtree away — so keyless dedup and the supersede-interrupt (#35) // stay keyed to the *position*, while sibling instances of one REC never // share a query slot (a list of rows, one keyed query each: each row's // entry must stay live and invalidatable, not be evicted as "superseded" // by the next row's). const placedScope = (props as Record)[SLOT_PROP]; const structuralScope = typeof placedScope === 'string' ? placedScope : scope; // 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); }, }; // Re-render trigger for an inline `.catch` fallback's `retry`. The hook // exists only for handles that can render an inline fallback — `catchable` // is fixed per handle and each handle has its own cached fc, so the hook // order of any one component type never changes. let retry: () => void = noRetry; if (catchable) { // oxlint-disable-next-line react-hooks/rules-of-hooks -- constant branch per component type (see `catchable`) retry = useReducer(bumpTick, 0)[1] as unknown as () => void; } // Attribution gate: always on in dev builds (the ledger), and whenever an // inspector is connected (which may include a production build with the // Redux DevTools bridge attached — same behaviour as before). const dev = devConnected() || isDevMode() ? (inst.dev ??= makeDevRenderState()) : null; // Candidate causes for this render, collected as the body is driven. When // several kinds match, a fixed priority picks the winner — atom > query > // props > hook > parent — and within a kind the first changed yield (body // order) wins, so attribution is deterministic (see `RenderTrigger`). let atomTrigger: RenderTrigger | undefined; let queryTrigger: RenderTrigger | undefined; let propsTrigger: RenderTrigger | undefined; let hookTrigger: RenderTrigger | undefined; if (dev !== null) { // Fresh observation buffers for this pass (see `DevRenderState.seenAtoms`). dev.seenAtoms.length = 0; dev.seenHooks.length = 0; dev.seenQueries.length = 0; if (dev.prevProps !== null && !Equal.equals(dev.prevProps, props)) { propsTrigger = { kind: 'props', label: changedKeys(dev.prevProps, props) }; } } // The per-instance session: interpreter deps allocated once, reset per // render. Rebuilt only if the instance somehow moves under a different // runtime (a swapped `Runtime` provider — same fiber, new executor). const runtimeScope = runtimeScopeOf(runtime); let session = inst.session; if ( session === null || session.executor !== executor || session.runtimeScope !== runtimeScope ) { session = inst.session = makeSession(executor, runtimeScope); } if (session.structuralScope !== structuralScope) { // The slot moved (or first render): refresh the derived scope strings. session.structuralScope = structuralScope; session.componentScope = `${scope}@${structuralScope}`; session.scopeBase = `${structuralScope}.`; } // Per-render slots. `used` is a fresh set (the claim effect below closes // over this render's set); the resolver is rebuilt on demand so it captures // it; `suspended` is the flag that keeps a `.catchDefect` from absorbing a // suspension (React's `use` throws a non-thenable sentinel). const used = new Set(); session.used = used; session.suspended = false; session.current = undefined; session.ordinal = 0; session.reader = reader; // Attribution seam for `query`/`suspend`: the same entry at the same yield // index with a bumped version means it settled, refetched or was // invalidated since last render — that is what re-rendered this component. session.onResolve = dev === null ? undefined : (index, entryId, version, key) => { const previous = dev.prevQueries.get(index); if ( previous !== undefined && previous.id === entryId && previous.version !== version && queryTrigger === undefined ) { queryTrigger = { kind: 'query', label: queryTriggerLabel(key) }; } dev.seenQueries.push([index, { id: entryId, version }]); }; // Render trace (#55): while attribution is active, record each yield in // order so the ledger/inspector can show what this render depends on and why // it re-rendered. A no-op otherwise — `traceYield` stays undefined, so the // interpreter skips it and production does zero work. const trace: TraceEntry[] = dev === null ? EMPTY_TRACE : []; let hookOrdinal = 0; const traceYield = dev === null ? undefined : (kind: TraceEntry['kind'], instruction: unknown): void => { const label = traceLabel(kind, instruction); if (kind === 'atom') { // Which atom changed since last render is what triggered this one. const value = (instruction as ReadableAtom).value; if ( dev.prevAtoms.has(instruction) && !Object.is(dev.prevAtoms.get(instruction), value) && atomTrigger === undefined ) { atomTrigger = { kind: 'atom', label }; } dev.seenAtoms.push([instruction, value]); } else if (kind === 'hook') { // A lifted hook whose value changed at the same ordinal (structural, // so a stable setter in a fresh `[value, set]` pair compares equal). const ordinal = hookOrdinal++; const value = (instruction as Hook).value; if ( dev.prevHooks.has(ordinal) && !Equal.equals(dev.prevHooks.get(ordinal), value) && hookTrigger === undefined ) { hookTrigger = { kind: 'hook', label: `#${ordinal}` }; } dev.seenHooks.push([ordinal, value]); } trace.push({ kind, label }); }; // The sync↔async flip detector is dev-build-only (never in production, even // with an inspector connected): thread the instance's resolution history to // the interpreter, and report each flipping yield once through the seam. let flipHistory: Map | undefined; let reportFlip: ((index: number) => void) | undefined; if (dev !== null && isDevMode()) { flipHistory = dev.flipHistory; reportFlip = (index: number): void => { if (dev.flipReported.has(index)) { return; } dev.flipReported.add(index); emitDev({ kind: 'invariant:flip', rec: handle.displayName, index, detail: FLIP_INVARIANT, }); }; } session.traceYield = traceYield; session.reportFlip = reportFlip; const state = driveState(flipHistory); let node: ReactNode; try { node = driveRec(handle.body(stripSlotProp(props)), session, state); // Committing render (no suspension) — remember its hook count. inst.lastHooks = state.hooks; // Report the render trace (#55, ADR 0007). During render, so it is just a // gate check in production; the inspector buffers it (it never setStates // mid-render), and in dev builds the ledger records it even with no // inspector connected. if (dev !== null) { // The body completed — only now advance the diff baselines (a discarded // render must not eat the committed replay's attribution). for (const [instruction, value] of dev.seenAtoms) { dev.prevAtoms.set(instruction, value); } for (const [ordinal, value] of dev.seenHooks) { dev.prevHooks.set(ordinal, value); } for (const [index, record] of dev.seenQueries) { dev.prevQueries.set(index, record); } dev.prevProps = props; // Attribution priority: atom > query > props > hook > parent. The // `parent` default only applies to a re-render — a first render has no // cause worth naming. const trigger = atomTrigger ?? queryTrigger ?? propsTrigger ?? hookTrigger ?? (dev.renderedBefore ? PARENT_TRIGGER : undefined); const event: RenderEvent = { kind: 'render', rec: handle.displayName, instanceId, yields: trace, ...(trigger === undefined ? {} : { trigger }), }; if (isDevMode()) { recordRender(event); } if (devConnected()) { emitDev(event); } dev.renderedBefore = true; // A `.hot()`-acknowledged subtree reports its render frequency (at most // once per second per instance) — acknowledged, never silent. if (handle.hotAcknowledged === true && isDevMode() && devConnected()) { reportHotRender(dev, handle.displayName, instanceId); } } } catch (thrown) { const tag = errorTag(thrown); const handler = handle.catchHandlers !== undefined && tag !== undefined ? handle.catchHandlers[tag] : undefined; // `.catch`'s tag dispatch wins; the opt-in `.catchDefect` takes the rest (#57). const fallback = handler ?? handle.defectHandler; // Not ours to catch inline — let it propagate: a suspension (to ``), // a REC without a matching `.catch`/`.catchDefect`, 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 ( fallback === undefined || isThenable(thrown) || session.suspended || state.hooks < inst.lastHooks ) { throw thrown; } if (handler === undefined) { reportHandledDefect(thrown, handle.displayName); } node = fallback(thrown, { retry }); inst.lastHooks = state.hooks; // the fallback committed too } // Reached only when the body did not suspend, i.e. this render will commit. // The claim effects are trailing hooks: a suspended attempt throws past them // and is discarded by React, so hook order per *committed* render is stable // (the body's own hook count is stable by the Rules of Hooks / flip // invariant). Keying the reconcile effect on the used set means the common // case — a re-render that read the same queries (usually none) — runs no // effect at all, instead of reconciling on every commit. const usedKey = used.size === 0 ? '' : Array.from(used).sort().join('\u001f'); // One effect for the whole claim lifecycle. Setup (mount, or the used set // changed): mark alive and reconcile. Cleanup (unmount, or the set is about // to change): schedule a release check one microtask out — a remount or a // same-commit re-setup re-arms `alive` first, so only a *true* unmount // releases (StrictMode/offscreen safe; a key change is handled by the // reconcile that follows). // oxlint-disable-next-line react-hooks/rules-of-hooks -- trailing hook: every completing render reaches it; a throw discards the attempt useEffect(() => { inst.alive = true; reconcileClaims(instanceId, used); return () => { inst.alive = false; queueMicrotask(() => { if (!inst.alive) { releaseClaims(instanceId); } }); }; // oxlint-disable-next-line react-hooks/exhaustive-deps -- usedKey is the set's identity; inst is the per-instance record }, [instanceId, usedKey]); return node; }; Object.defineProperty(fc, 'name', { value: handle.displayName }); // Explicit `.memo()` (ADR 0007, decision 4): only a `.memo()`-marked handle // gets a `React.memo` wrapper — memoization is never automatic. The wrapper // lives *inside* the per-handle cache, so the memoized component type is // stable across the parent's re-renders and React's bailout actually applies. const component = handle.memoized === true ? memo(fc, memoPropsEqual) : fc; fcCache.set(handle, component); return component; }; const EMPTY_TRACE: TraceEntry[] = []; /** * Places a child REC as a real React child element under the same runtime. The * `place` method's parameter is bivariant (see `Placer` in ports), so it names the * ReactNode-bodied child it renders here — the interpreter still hands it the * erased placement, and no cast is needed on either side. A child that declared * a loading fallback with `.suspense(...)` is wrapped in a real ``, so * its queries' pending state renders that fallback. */ const placeChild = ( placement: RecPlacement, childScope: string, ): ReactElement => { const element = createElement(clientFcFor(placement.rec), { ...placement.props, [SLOT_PROP]: childScope, } as object); const handlers = placement.rec.catchHandlers as CatchDispatch | undefined; const defectHandler = placement.rec.defectHandler; const caught = handlers === undefined && defectHandler === undefined ? element : withCatch(handlers, defectHandler, element, placement.rec.displayName); const fallback = placement.rec.suspenseFallback; // 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); }; /** Options for the client `mount`. `loading` discharges the tree's loading obligation. */ export interface MountOptions { readonly loading?: ReactNode; } /** * Mount a root REC under an Effect runtime — the **client** implementation of * `mount`, selected everywhere except a React Server Component graph (where the * sibling `../server/mount.ts` is chosen by the `react-server` condition). It is * the boundary between effract and React: returns an ordinary React node and, at * compile time, verifies that `layer` provides every service the REC's tree * requires *and* that the tree's loading obligation is discharged — either by a * `.suspense(...)` somewhere in it, or by passing `{ loading }` here — *and* * that its volatility obligation is discharged (`defer(atom)` at the read, or * `.hot()` on the component; there is deliberately no mount-level discharge for * it). All checks live on the arguments, so there is no cast on the result. * * ```tsx * createRoot(el).render(mount(AppLive, Dashboard.suspense())); * // or * createRoot(el).render(mount(AppLive, Dashboard, { loading: })); * ``` * * You import `mount` from `@tmonier/effract` in every file; where the module * runs decides whether it renders interactively (here) or on the server. */ export function mount( source: Layer.Layer | ManagedRuntime.ManagedRuntime, rec: REC, RE, R, S, V, L> & // `QueryCache` is built in: `mount` itself provides it, so a tree (or a // service `make`) that reads it never counts it as missing. ([Effective] extends [ROut | QueryCache] ? unknown : MissingServices, ROut | QueryCache>>) & ([S] extends [never] ? unknown : LoadingNotHandled) & ([V] extends [never] ? unknown : VolatileNotHandled), options?: MountOptions, ): ReactNode; export function mount( source: Layer.Layer | ManagedRuntime.ManagedRuntime, rec: REC, RE, R, S, V, L> & ([Effective] extends [ROut | QueryCache] ? unknown : MissingServices, ROut | QueryCache>>) & // `{ loading }` discharges only the loading obligation — a volatile // subscription is a scheduling decision, not a boundary fallback, so `V` // must still be discharged in the tree (`defer(atom)` or `.hot()`). ([V] extends [never] ? unknown : VolatileNotHandled), options: MountOptions & { readonly loading: ReactNode }, ): ReactNode; export function mount( source: Layer.Layer | ManagedRuntime.ManagedRuntime, rec: REC, RE, R, S, V, L>, options?: MountOptions, ): ReactNode { const handle = rec as unknown as RecHandle; const fallback = handle.suspenseFallback ?? options?.loading; const inner = createElement(clientFcFor(handle), {}); const handlers = handle.catchHandlers as CatchDispatch | undefined; const defectHandler = handle.defectHandler; const caught = handlers === undefined && defectHandler === undefined ? inner : withCatch(handlers, defectHandler, inner, handle.displayName); const root = fallback === undefined ? caught : createElement(Suspense, { fallback }, caught); // Hydration is automatic: if the server embedded settled state (see the `/ssr` // `renderToStream`), seed it before the tree renders — inside `Runtime`, so the // query cache seeds into this mount's scope. No embedded state → a no-op. const seeded = createElement(AutoSeed, {}, root); // A pre-built runtime is injected (and not disposed by effract); a layer is built. // The guard narrows to `ManagedRuntime`, but this branch is the // typed runtime the signature already accepted — so recover its `ROut`/`LE`. return ManagedRuntime.isManagedRuntime(source) ? createElement( Runtime, { runtime: source as ManagedRuntime.ManagedRuntime }, seeded, ) : createElement( Runtime, { layer: source as Layer.Layer }, seeded, ); }