'use client';
/**
* Devtools (dev-only, opt-in). Two consumers of the instrumentation seam:
*
* - `connectReduxDevtools()` — reports every atom write (and a `batch` as one
* action) to the Redux DevTools browser extension, so you get its timeline and
* inspector for free. Zero cost when not called (the core skips building events
* while nothing is connected).
* - `` — an embeddable panel: a live list of atoms (name → value as
* they change) and the query store (keys, status, claims, staleness).
*
* The heavy code lives here and tree-shakes away unless you import it.
*/
import { useEffect, useState, type ReactNode } from 'react';
import { onDevEvent, type DevEvent, type RenderTrigger } from '#infrastructure/devtools-core.ts';
import { inspectStore, type QuerySnapshot } from '#infrastructure/react/suspensable-store.ts';
// --- Redux DevTools bridge (#54) ---
interface ReduxConnection {
init(state: unknown): void;
send(action: { readonly type: string; readonly payload?: unknown }, state: unknown): void;
}
interface ReduxExtension {
connect(options?: { readonly name?: string }): ReduxConnection;
}
interface ReduxGlobal {
readonly __REDUX_DEVTOOLS_EXTENSION__?: ReduxExtension;
}
/**
* Bridge atom writes to the Redux DevTools extension. Returns a disconnect
* function. A no-op (returning a no-op disconnect) when the extension is absent —
* so it is safe to call unconditionally in development.
*/
export const connectReduxDevtools = (options?: { readonly name?: string }): (() => void) => {
const extension = (globalThis as unknown as ReduxGlobal).__REDUX_DEVTOOLS_EXTENSION__;
if (extension === undefined) {
return () => {};
}
const connection = extension.connect({ name: options?.name ?? 'effract' });
const state: Record = {};
connection.init(state);
return onDevEvent((event: DevEvent) => {
if (event.kind === 'atom:write') {
state[event.name] = event.value;
connection.send(
{ type: `${event.name}.${event.action}`, payload: event.value },
{ ...state },
);
}
});
};
// --- the embeddable inspector (#55) ---
export interface InspectorProps {
/** Poll interval (ms) for the query-store view. Default 500. */
readonly interval?: number;
}
/**
* A minimal live inspector: atom writes as they happen, and the current query
* store. Drop it anywhere in a dev build (`{import.meta.env.DEV && }`).
*/
interface RenderRow {
readonly rec: string;
readonly yields: ReadonlyArray;
readonly trigger?: RenderTrigger;
}
/** `atom: count`, `props: name` … or just `parent` (whose label is empty). */
const triggerText = (trigger: RenderTrigger): string =>
trigger.label === '' ? trigger.kind : `${trigger.kind}: ${trigger.label}`;
export const Inspector = ({ interval = 500 }: InspectorProps): ReactNode => {
const [atoms, setAtoms] = useState>([]);
const [queries, setQueries] = useState>([]);
const [edges, setEdges] = useState<
ReadonlyArray<{ target: string; sources: ReadonlyArray }>
>([]);
const [renders, setRenders] = useState>([]);
useEffect(() => {
const byName = new Map();
const edgeMap = new Map>();
let log: ReadonlyArray = [];
let renderFlush = false;
const unsubscribe = onDevEvent((event) => {
if (event.kind === 'atom:write') {
byName.set(event.name, event.value);
setAtoms(Array.from(byName, ([name, value]) => ({ name, value })));
} else if (event.kind === 'atom:derive') {
edgeMap.set(event.target, event.sources);
setEdges(Array.from(edgeMap, ([target, sources]) => ({ target, sources })));
} else if (event.kind === 'render') {
// A render event fires *during* a REC's render, so buffer it and flush on a
// microtask (after that render commits) — never setState mid-render.
log = [
{
rec: event.rec,
yields: event.yields.map((y) => y.label),
...(event.trigger === undefined ? {} : { trigger: event.trigger }),
},
...log,
].slice(0, 25);
if (!renderFlush) {
renderFlush = true;
queueMicrotask(() => {
renderFlush = false;
setRenders(log);
});
}
}
});
const tick = setInterval(() => setQueries(inspectStore()), interval);
setQueries(inspectStore());
return () => {
unsubscribe();
clearInterval(tick);
};
}, [interval]);
const fmt = (value: unknown): string => {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
return (
);
};