'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 ( ); };