/** * The signals bridge: a change to an atom re-renders precisely the components * that read it — and leaves the rest untouched. */ import { act, type ReactNode } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { Observe, atom, atomFamily, batch, derive, mount, observe, rec, useAtom, } from '../../index.client.ts'; Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); const flush = (): Promise => act(async () => { await Promise.resolve(); await Promise.resolve(); }); let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { container.remove(); }); describe('reactivity', () => { it('observe re-renders when a read atom changes', async () => { const count = atom('count', 1); const Doubled = (): ReactNode => { const doubled = observe(($) => $(count) * 2); return {doubled}; }; const root = createRoot(container); await act(async () => { root.render(); }); expect(container.textContent).toBe('2'); await act(async () => { count.set(5); }); expect(container.textContent).toBe('10'); await act(async () => { root.unmount(); }); }); it('useAtom reads and writes like useState, backed by Effect', async () => { const name = atom('name', 'ada'); const Field = (): ReactNode => { const [value, setValue] = useAtom(name); return ( ); }; const root = createRoot(container); await act(async () => { root.render(); }); expect(container.textContent).toBe('ada'); await act(async () => { container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.textContent).toBe('ada!'); await act(async () => { root.unmount(); }); }); it(' tracks only the atoms it actually reads', async () => { const a = atom('a', 1); const b = atom('b', 100); let renders = 0; const View = (): ReactNode => { renders += 1; return {($) => {$(a)}}; }; const root = createRoot(container); await act(async () => { root.render(); }); const baseline = renders; expect(container.textContent).toBe('1'); // b is never read by the selector, so changing it must not re-render. await act(async () => { b.set(200); }); expect(renders).toBe(baseline); await act(async () => { a.set(2); }); expect(container.textContent).toBe('2'); await act(async () => { root.unmount(); }); }); it('yield* atom reads reactive state in a REC and re-renders on change', async () => { const count = atom('count', 1); const Counter = rec(function* () { const n = yield* count; // read + subscribe — no hook(useAtomValue(...)) return {n}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Counter))); expect(container.textContent).toBe('1'); await act(async () => count.set(5)); expect(container.textContent).toBe('5'); await act(async () => root.unmount()); }); it('derive computes from atoms, updates on change, and composes', async () => { const price = atom('price', 10); const qty = atom('qty', 2); const subtotal = derive([price, qty], ([p, q]) => p * q); const withTax = subtotal.derive((s) => Math.round(s * 1.1)); // derived-of-derived const Total = rec(function* () { const total = yield* withTax; return {total}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Total))); expect(container.textContent).toBe('22'); // 10*2=20 → *1.1=22 await act(async () => qty.set(3)); expect(container.textContent).toBe('33'); // 30 → 33 await act(async () => price.set(20)); expect(container.textContent).toBe('66'); // 60 → 66 await act(async () => root.unmount()); }); it('derive.value reads imperatively (no component, no subscription)', () => { const n = atom('n', 3); const doubled = n.derive((value) => value * 2); expect(doubled.value).toBe(6); n.set(5); expect(doubled.value).toBe(10); }); it('atom.derive derives from one atom without $, recomputes, and chains', () => { const items = atom('items', [{ price: 3 }, { price: 4 }] as ReadonlyArray<{ price: number }>); const count = items.derive((list) => list.length); // the value, handed over directly const total = items.derive((list) => list.reduce((n, item) => n + item.price, 0)); const withTax = total.derive((t) => Math.round(t * 1.1)); // chains off a derived atom expect(count.value).toBe(2); expect(total.value).toBe(7); expect(withTax.value).toBe(8); // 7 * 1.1 = 7.7 → 8 items.update((list) => [...list, { price: 3 }]); expect(count.value).toBe(3); expect(total.value).toBe(10); expect(withTax.value).toBe(11); // 10 * 1.1 = 11 }); it('an unchanged write (by Equal) notifies no one', () => { const n = atom('n', 1); let notifications = 0; const unsub = n.subscribe(() => { notifications += 1; }); n.set(1); // same value — no-op expect(notifications).toBe(0); n.set(2); expect(notifications).toBe(1); unsub(); }); it('batch coalesces writes into a single notification wave', () => { const a = atom('a', 1); const b = atom('b', 2); const sum = derive([a, b], ([x, y]) => x + y); let notifications = 0; const unsub = sum.subscribe(() => { notifications += 1; }); // Without a batch: two writes → two recomputations, two notifications. a.set(10); b.set(20); expect(notifications).toBe(2); expect(sum.value).toBe(30); // With a batch: two writes → one recomputation, one notification. notifications = 0; batch(() => { a.set(100); b.set(200); }); expect(notifications).toBe(1); expect(sum.value).toBe(300); unsub(); }); it('batch throws in dev when its callback returns a Promise — sync-only guard (#36)', () => { const a = atom('a', 0); // A sync callback is fine and returns its value. expect(batch(() => a.set(1))).toBeUndefined(); expect(batch(() => 42)).toBe(42); // An async callback returns a Promise synchronously — the batch would stay open // across the await, so it is rejected in development. expect(() => batch(async () => { a.set(2); }), ).toThrow(/synchronous-only/); // The batch depth is still restored (the throw is after the finally), so later // batches behave normally. expect(batch(() => 7)).toBe(7); }); it('atomFamily memoises one independent atom per key', () => { const quantities = atomFamily('quantities', (id: string) => atom(id, 1)); const first = quantities('sku-1'); expect(quantities('sku-1')).toBe(first); // same key → same atom expect(quantities('sku-2')).not.toBe(first); // different key → its own atom first.update((n) => n + 4); expect(quantities('sku-1').value).toBe(5); expect(quantities('sku-2').value).toBe(1); // independent quantities.forget('sku-1'); expect(quantities('sku-1')).not.toBe(first); // forgotten → a fresh atom expect(quantities('sku-1').value).toBe(1); }); it('atomFamily default keeps entries strongly — no GC (#32)', () => { let makes = 0; const fam = atomFamily('fam', (id: string) => { makes += 1; return atom(id, 0); }); const a1 = fam('x'); const unsubscribe = a1.subscribe(() => {}); unsubscribe(); expect(fam('x')).toBe(a1); // still cached after the last unsubscribe — strong expect(makes).toBe(1); }); it('atomFamily gc: "unreferenced" drops an entry once its atom has no subscribers (#32)', async () => { let makes = 0; const fam = atomFamily( 'fam', (id: string) => { makes += 1; return atom(id, 0); }, { gc: 'unreferenced' }, ); const a1 = fam('x'); expect(makes).toBe(1); const unsubscribe = a1.subscribe(() => {}); expect(fam('x')).toBe(a1); // still cached while subscribed unsubscribe(); await Promise.resolve(); // let the deferred eviction run const a2 = fam('x'); expect(makes).toBe(2); // re-created — the unreferenced entry was dropped expect(a2).not.toBe(a1); }); it('atomFamily gc: "unreferenced" keeps an entry a StrictMode unsub→resub touches (#32)', async () => { let makes = 0; const fam = atomFamily( 'fam', (id: string) => { makes += 1; return atom(id, 0); }, { gc: 'unreferenced' }, ); const a1 = fam('x'); const unsubscribe = a1.subscribe(() => {}); unsubscribe(); // count → 0, eviction deferred a microtask... a1.subscribe(() => {}); // ...re-subscribed in the same tick → eviction cancelled await Promise.resolve(); expect(fam('x')).toBe(a1); // survived expect(makes).toBe(1); }); it('atomFamily gc: { maxSize } evicts least-recently-used (#32)', () => { let makes = 0; const fam = atomFamily( 'fam', (id: number) => { makes += 1; return atom(String(id), id); }, { gc: { maxSize: 2 } }, ); const a = fam(1); fam(2); fam(1); // touch 1 → most recent; order [2, 1] const c = fam(3); // exceeds cap → evict the oldest (2) expect(makes).toBe(3); expect(fam(1)).toBe(a); // still cached (recently used) expect(fam(3)).toBe(c); fam(2); // 2 was evicted → re-created expect(makes).toBe(4); }); it('derive.writable reads a derived value and writes back to its sources', () => { const celsius = atom('celsius', 0); const fahrenheit = derive.writable( [celsius], ([c]) => c * 1.8 + 32, (f) => celsius.set((f - 32) / 1.8), ); expect(fahrenheit.value).toBe(32); celsius.set(100); expect(fahrenheit.value).toBe(212); fahrenheit.set(32); // write flows back to the source expect(celsius.value).toBe(0); fahrenheit.update((f) => f + 18); // 32 → 50 → celsius 10 expect(celsius.value).toBe(10); }); it('derive.writable is a reactive atom a component can yield and drive', async () => { const celsius = atom('celsius', 0); const fahrenheit = derive.writable( [celsius], ([c]) => c * 1.8 + 32, (f) => celsius.set((f - 32) / 1.8), ); const Thermostat = rec(function* () { const f = yield* fahrenheit; return ( ); }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Thermostat))); expect(container.textContent).toBe('32'); await act(async () => { container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.textContent).toBe('50'); // +18°F → celsius 10 → 50°F expect(celsius.value).toBe(10); await act(async () => root.unmount()); }); it('derive.effect suspends, resolves, and refetches when a source atom changes', async () => { const calls: number[] = []; // A per-id gate so we can observe the loading frame deterministically. const gates = new Map; settle: (n: number) => void }>(); const gateFor = (id: number): { promise: Promise; settle: (n: number) => void } => { const existing = gates.get(id); if (existing) { return existing; } let settle: (n: number) => void = () => {}; const promise = new Promise((resolve) => { settle = resolve; }); const gate = { promise, settle }; gates.set(id, gate); return gate; }; const sku = atom('sku', 1); // The source tuple is explicit — the effect receives the sources' values. const price = derive.effect([sku], ([id]) => Effect.promise(() => { calls.push(id); return gateFor(id).promise; }), ); const Price = rec(function* () { const p = yield* price; return {p}; }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Price))); expect(container.textContent).toContain('loading'); // suspended on the effect expect(calls).toEqual([1]); await act(async () => { gateFor(1).settle(10); await gateFor(1).promise; }); await flush(); expect(container.querySelector('[data-x="price"]')?.textContent).toBe('10'); // A source change re-renders (it subscribed) and refetches, keyed by the new value. await act(async () => sku.set(3)); await flush(); expect(calls).toEqual([1, 3]); await act(async () => { gateFor(3).settle(30); await gateFor(3).promise; }); await flush(); expect(container.querySelector('[data-x="price"]')?.textContent).toBe('30'); await act(async () => root.unmount()); }); it('derive.effect does not refetch when a source is written its current value', async () => { const calls: number[] = []; const sku = atom('sku', 2); const price = derive.effect([sku], ([id]) => Effect.promise(() => { calls.push(id); return Promise.resolve(id * 10); }), ); const Price = rec(function* () { const p = yield* price; return {p}; }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Price))); await flush(); expect(container.textContent).toBe('20'); expect(calls).toEqual([2]); // Same value → no notification, no re-render, no refetch. await act(async () => sku.set(2)); await flush(); expect(calls).toEqual([2]); await act(async () => root.unmount()); }); it('derive.effect runs its effect once across unrelated re-renders (#38 refetch-key stability)', async () => { // The refetch key is the snapshot array the tracked key-computation returns. // Its reference is stable across renders that don't change a source (see the // invariant comment at `computation.get()`), so N unrelated re-renders must not // refetch. A regression that returned a fresh array per call would refetch every // render — this pins that it does not. const calls: number[] = []; const sku = atom('sku', 1); const bump = atom('bump', 0); // unrelated: not in the source tuple const price = derive.effect([sku], ([id]) => Effect.promise(() => { calls.push(id); return Promise.resolve(id * 10); }), ); const Price = rec(function* () { const b = yield* bump; // subscribe to the unrelated atom → re-render on bump const p = yield* price; return ( {p}:{b} ); }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Price))); await flush(); expect(container.querySelector('[data-x="p"]')?.textContent).toContain('10'); expect(calls).toEqual([1]); // Five unrelated re-renders — none changes `sku`, so exactly one execution. for (let i = 1; i <= 5; i += 1) { await act(async () => bump.set(i)); await flush(); } expect(calls).toEqual([1]); await act(async () => root.unmount()); }); }); // --- type-level: derive.effect carries the loading obligation (checked by tsgo) --- { const sku = atom('sku', 1); const AsyncPrice = rec(function* () { const p = yield* derive.effect([sku], ([s]) => Effect.succeed(s * 10)); return {p}; }); // @ts-expect-error effract: loading not handled — derive.effect suspends void mount(Layer.empty, AsyncPrice); // ✓ discharged with .suspense: void mount(Layer.empty, AsyncPrice.suspense(loading)); }