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