/** * The dev-instrumentation seam — server-safe, and a no-op in production. * * The atom/query core emits events through a single optional hook that is `null` * unless something (the Redux DevTools bridge, the inspector) connects. So the * only cost in a production build is one null check per atom write; the heavy * consumers live in separate modules that tree-shake away when unused. */ interface AtomWriteEvent { readonly kind: 'atom:write'; /** The atom's stable id (or its explicit name). */ readonly id: string; /** A human label — the canonical label passed to `atom(label, initial)`. */ readonly name: string; readonly value: unknown; readonly action: 'set' | 'update'; } /** A derived atom's current source edges — the dependency graph (#55). */ interface DeriveEvent { readonly kind: 'atom:derive'; /** The derived atom's name. */ readonly target: string; /** The names of the atoms it currently reads (its sources). */ readonly sources: ReadonlyArray; } /** One yield a REC render processed, in order — the unit of the render trace (#55). */ export interface TraceEntry { readonly kind: 'effect' | 'atom' | 'query' | 'suspend' | 'hook' | 'child'; readonly label: string; } /** * Why a REC rendered — the render ledger's attribution (ADR 0007, decision 5). * One cause per render; when several changed at once 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. * * - `atom` — an atom the body reads changed value (label: the atom's name) * - `query` — a `query`/`suspend` entry settled, refetched or was * invalidated (label: the stringified key; `suspend` if keyless) * - `props` — the placement's props changed (label: the changed key names) * - `hook` — a lifted hook's value changed (label: the hook ordinal, `#n`) * - `parent` — a re-render none of the above explains: the parent rendered * (label: empty) */ export interface RenderTrigger { readonly kind: 'atom' | 'query' | 'props' | 'hook' | 'parent'; readonly label: string; } /** * A REC render: what it yielded, and (if known) what triggered it — the render * trace that makes "why did this re-render, and what does it depend on" * visible, so React render behaviour is debuggable (#55). */ export interface RenderEvent { readonly kind: 'render'; /** The REC's display name. */ readonly rec: string; /** A stable per-instance id, so repeated renders of one instance group together. */ readonly instanceId: string; readonly yields: ReadonlyArray; /** The attributed cause of this render, if it could be determined. */ readonly trigger?: RenderTrigger; } /** * A defect (or untagged failure) a `.catchDefect` fallback absorbed — emitted in * dev so the explicit catch-all is never a *silent* one (#57): what would have * hit an error boundary stays visible while you build. */ interface DefectHandledEvent { readonly kind: 'defect:handled'; /** The REC whose `.catchDefect` rendered the fallback. */ readonly rec: string; /** The pretty-printed `Cause` (or the error's stack) — what went wrong. */ readonly detail: string; } /** * A raw `yield*`ed effect resolved synchronously on one render and * asynchronously on another — the sync↔async flip the type system cannot see * (it cannot tell a sync `Effect` from an async one), detected at runtime in * dev builds. A flip changes the component's hook count and breaks the Rules * of Hooks; `detail` spells out the invariant and the fix. Emitted at most * once per component instance per yield. */ interface FlipEvent { readonly kind: 'invariant:flip'; /** The REC whose yield flipped. */ readonly rec: string; /** The flipping yield's ordinal among the body's raw effect yields. */ readonly index: number; /** The invariant, spelled out — what broke and how to fix it. */ readonly detail: string; } /** * A `.hot()`-acknowledged REC's render frequency — emitted in dev at most once * per second per instance, so an acknowledged hot subtree stays visible while * you build, never silent (the same principle as `defect:handled` for * `.catchDefect`). */ interface HotFrequencyEvent { readonly kind: 'hot:frequency'; /** The `.hot()` REC's display name. */ readonly rec: string; /** A stable per-instance id, so instances of one REC report separately. */ readonly instanceId: string; /** How many times this instance rendered in the trailing second. */ readonly renders: number; } export type DevEvent = | AtomWriteEvent | DeriveEvent | RenderEvent | DefectHandledEvent | FlipEvent | HotFrequencyEvent; const subscribers = new Set<(event: DevEvent) => void>(); /** Subscribe to dev events; returns an unsubscribe. Multiple consumers may listen. */ export const onDevEvent = (listener: (event: DevEvent) => void): (() => void) => { subscribers.add(listener); return () => { subscribers.delete(listener); }; }; /** True when something is listening — lets the core skip building events otherwise. */ export const devConnected = (): boolean => subscribers.size > 0; /** Emit an event to every connected consumer (no-op if none). */ export const emitDev = (event: DevEvent): void => { for (const listener of subscribers) { listener(event); } }; // --- the dev render ledger (ADR 0007, decision 5) --- /** How many renders the dev ledger retains (a small fixed ring). */ const LEDGER_CAPACITY = 200; const ledger: RenderEvent[] = []; /** * Record a render into the dev ledger. Called by the React binding on every * REC render in development builds only — production never calls this, so the * ring costs nothing there. */ export const recordRender = (event: RenderEvent): void => { if (ledger.length >= LEDGER_CAPACITY) { ledger.shift(); } ledger.push(event); }; /** * Read the dev render ledger: the last 200 renders (oldest first), each with * its yields and — when attributable — the {@link RenderTrigger} that caused * it. Populated in development builds whether or not an inspector is * connected, so a test or a REPL can ask "why did this render?" without * mounting ``. Always empty in production. */ export const readRenderLedger = (): ReadonlyArray => ledger.slice();