/** * Devtools: the Redux DevTools bridge reports atom writes (and is a no-op when the * extension is absent, with zero cost when nothing is connected); the inspector * shows live atom/query state. */ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { onDevEvent, type DevEvent } from '#infrastructure/devtools-core.ts'; import { atom, connectReduxDevtools, derive, Inspector, mount, query, QueryCache, readRenderLedger, rec, } from '../../index.client.ts'; Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); interface ReduxAction { readonly type: string; readonly payload?: unknown; } type ExtensionGlobal = { __REDUX_DEVTOOLS_EXTENSION__?: unknown }; const asGlobal = (): ExtensionGlobal => globalThis as unknown as ExtensionGlobal; let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { container.remove(); delete asGlobal().__REDUX_DEVTOOLS_EXTENSION__; }); describe('connectReduxDevtools (#54)', () => { it('reports atom writes as actions; no cost, and a no-op, when nothing is connected', () => { const actions: ReduxAction[] = []; const counter = atom('counter', 0); // Not connected: a write emits nothing (zero production cost). counter.set(1); expect(actions).toEqual([]); // Connect a fake extension. asGlobal().__REDUX_DEVTOOLS_EXTENSION__ = { connect: () => ({ init: () => {}, send: (action: ReduxAction) => actions.push(action), }), }; const disconnect = connectReduxDevtools({ name: 'test' }); counter.set(2); counter.update((n) => n + 1); expect(actions).toEqual([ { type: 'counter.set', payload: 2 }, { type: 'counter.update', payload: 3 }, ]); // Disconnect: no more actions. disconnect(); counter.set(9); expect(actions).toHaveLength(2); }); it('is a no-op when the extension is absent', () => { const disconnect = connectReduxDevtools(); expect(typeof disconnect).toBe('function'); disconnect(); // does not throw }); }); describe('Inspector (#55)', () => { it('renders live atom writes', async () => { const temperature = atom('temperature', 20); const root = createRoot(container); await act(async () => root.render()); // Write after mount so the inspector's subscription is live. await act(async () => temperature.set(25)); expect(container.textContent).toContain('temperature'); expect(container.textContent).toContain('25'); await act(async () => root.unmount()); }); it('shows the atom dependency graph — a derived atom and its sources (#55)', async () => { const width = atom('width', 4); const height = atom('height', 3); const area = derive([width, height], ([w, h]) => w * h); const Area = rec(function* () { return {yield* area}; // subscribing tracks it → emits its source edges }); const root = createRoot(container); await act(async () => root.render()); // connect the seam first await act(async () => root.render(<>{[, mount(Layer.empty, Area)]})); // The graph lists the derived atom pointing at both sources. expect(container.textContent).toContain('← width, height'); await act(async () => root.unmount()); }); it('shows the render trace — what a REC yielded, and its name (#55)', async () => { const count = atom('count', 0); const Widget = rec(function* Widget() { const n = yield* count; // an atom read shows in the trace return {n}; }); // A separate, stably-mounted inspector root, connected *before* the subject // renders (the render event fires during the subject's render). const panel = document.createElement('div'); document.body.appendChild(panel); const panelRoot = createRoot(panel); await act(async () => panelRoot.render()); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Widget))); const text = panel.textContent ?? ''; expect(text).toContain('Widget'); // the REC name expect(text).toContain('count'); // the atom it depends on await act(async () => { root.unmount(); panelRoot.unmount(); }); panel.remove(); }); it('the render trace names which atom triggered a re-render (#55)', async () => { const count = atom('count', 0); const Widget = rec(function* Widget() { return {yield* count}; }); const panel = document.createElement('div'); document.body.appendChild(panel); const panelRoot = createRoot(panel); await act(async () => panelRoot.render()); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Widget))); // initial render — no trigger await act(async () => count.set(5)); // a write re-renders the Widget expect(panel.textContent).toContain('triggered by atom: count'); await act(async () => { root.unmount(); panelRoot.unmount(); }); panel.remove(); }); it('attributes a re-render to a settled query (trigger kind `query`)', async () => { // Refetching is a *service* concern (ADR 0008): a test service exposes it as // a method over the built-in QueryCache — no hook in sight. class Refresher extends Context.Service< Refresher, { readonly refetch: (key: unknown) => number } >()('test/Refresher') {} const RefresherLive = Layer.effect(Refresher)( Effect.gen(function* () { const cache = yield* QueryCache; return { refetch: (key: unknown) => cache.refetch(key) }; }), ); let refetch: ((key: unknown) => number) | null = null; let fetches = 0; const Stats = rec(function* Stats() { const refresher = yield* Refresher; refetch = refresher.refetch; const value = yield* query( Effect.sync(() => { fetches += 1; return fetches; }), 'stats', ); return {value}; }).suspense(loading); const panel = document.createElement('div'); document.body.appendChild(panel); const panelRoot = createRoot(panel); await act(async () => panelRoot.render()); const root = createRoot(container); await act(async () => root.render(mount(RefresherLive, Stats))); // suspends, settles, commits // A manual refetch settles the same entry with a new value — the version bump // re-renders the component, and the ledger attributes it to the query. await act(async () => void refetch?.('stats')); expect(panel.textContent).toContain('triggered by query: "stats"'); await act(async () => { root.unmount(); panelRoot.unmount(); }); panel.remove(); }); it('attributes a child re-render to a prop change (trigger kind `props`)', async () => { const who = atom('who', 'ada'); const Badge = rec(function* Badge(props: { readonly name: string }) { return {props.name}; }); const Parent = rec(function* Parent() { const name = yield* who; return
{yield* Badge.with({ name })}
; }); const panel = document.createElement('div'); document.body.appendChild(panel); const panelRoot = createRoot(panel); await act(async () => panelRoot.render()); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Parent))); await act(async () => who.set('grace')); // parent: atom; child: props const text = panel.textContent ?? ''; expect(text).toContain('triggered by atom: who'); // the parent's cause expect(text).toContain('triggered by props: name'); // the child's cause await act(async () => { root.unmount(); panelRoot.unmount(); }); panel.remove(); }); it('reports a `.hot()` subtree render frequency (hot:frequency, ADR 0007)', async () => { const tick = atom('tick', 0); const Ticker = rec(function* Ticker() { return {yield* tick}; }).hot(); const events: DevEvent[] = []; const disconnect = onDevEvent((event) => events.push(event)); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Ticker))); await act(async () => tick.set(1)); const hot = events.filter( (event): event is Extract => event.kind === 'hot:frequency', ); expect(hot.length).toBeGreaterThanOrEqual(1); // once per second per instance expect(hot[0]?.rec).toBe('Ticker'); expect(hot[0]?.renders).toBeGreaterThanOrEqual(1); disconnect(); await act(async () => root.unmount()); }); }); describe('the dev render ledger (readRenderLedger, ADR 0007)', () => { it('records attributed renders in dev with no inspector connected', async () => { const mood = atom('mood', 'calm'); const LedgerWidget = rec(function* LedgerWidget() { return {yield* mood}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, LedgerWidget))); await act(async () => mood.set('busy')); const rows = readRenderLedger().filter((row) => row.rec === 'LedgerWidget'); expect(rows.length).toBeGreaterThanOrEqual(2); // mount + the atom-write re-render expect(rows[0]?.trigger).toBeUndefined(); // a first render has no cause to name expect(rows.at(-1)?.trigger).toEqual({ kind: 'atom', label: 'mood' }); await act(async () => root.unmount()); }); });