/** * The canonical model: RECs compose with `yield*`, requirements bubble to the * root, and `mount` is the typed boundary. RECs are not JSX element types — * `` is a compile error (asserted at the bottom) — so this is the only * way to use one, and it stays 100% real React underneath. */ import { Suspense, act, useState, type ReactNode } from 'react'; import { createRoot } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Equal from 'effect/Equal'; import * as Hash from 'effect/Hash'; import * as Layer from 'effect/Layer'; import type { ReadableAtom, Volatile } from '#domain/protocol.ts'; import type { RendersOn } from '#infrastructure/rec-core.tsx'; import { atom, rec, hook, mount } from '../../index.client.ts'; Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); class Stats extends Context.Service()('test/Stats') {} const statsLayer = (total: number): Layer.Layer => Layer.succeed(Stats)({ total }); let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => container.remove()); describe('REC composition + mount', () => { it('mounts a tree, resolving services and yield-composed children', () => { const Badge = rec(function* () { const stats = yield* Stats; return {stats.total}; }); const Page = rec(function* () { return
online: {yield* Badge}
; }); const html = renderToStaticMarkup(mount(statsLayer(42), Page)); expect(html).toContain('42'); }); it('passes props with .with(), type-checked', () => { const Greet = rec(function* (props: { name: string }) { const stats = yield* Stats; return (

hi {props.name} ({stats.total})

); }); const Page = rec(function* () { return
{yield* Greet.with({ name: 'Ada' })}
; }); expect(renderToStaticMarkup(mount(statsLayer(3), Page))).toContain('hi Ada'); }); it('keeps a yield-composed child a real React child (hook state survives re-render)', async () => { const Counter = rec(function* () { const [n, setN] = yield* hook(useState(0)); return ( ); }); const Parent = rec(function* () { const [p, setP] = yield* hook(useState(0)); return (
{yield* Counter}
); }); const root = createRoot(container); const click = async (x: string) => act(async () => { container .querySelector(`[data-x="${x}"]`) ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await act(async () => root.render(mount(Layer.empty, Parent))); expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('0'); await click('counter'); expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('1'); await click('parent'); // Parent re-renders → re-runs yield* Counter expect(container.querySelector('[data-x="parent"]')?.textContent).toBe('1'); expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('1'); // preserved await act(async () => root.unmount()); }); it('suspends an async effect through Suspense, then resolves', async () => { let release: (v: number) => void = () => {}; const gate = new Promise((r) => { release = r; }); const Async = rec(function* () { const v = yield* Effect.promise(() => gate); return val:{v}; }); const Page = rec(function* () { return loading}>{yield* Async}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Page))); expect(container.textContent).toContain('loading'); await act(async () => { release(7); await gate; }); expect(container.textContent).toContain('val:7'); await act(async () => root.unmount()); }); it('runs the SAME tree under two runtimes — server vs client is the mount', () => { const Total = rec(function* () { const stats = yield* Stats; return {stats.total}; }); const Page = rec(function* () { return
{yield* Total}
; }); expect(renderToStaticMarkup(mount(statsLayer(100), Page))).toContain('100'); expect(renderToStaticMarkup(mount(statsLayer(1), Page))).toContain('1'); }); }); describe('explicit .memo() placements (ADR 0007)', () => { it('WITHOUT .memo(), a parent re-render with identical-value props re-runs the child (plain React)', async () => { const tick = atom('tick', 0); let childRuns = 0; const Child = rec(function* (props: { n: number }) { childRuns += 1; return {props.n}; }); const Parent = rec(function* () { const t = yield* tick; // the parent re-renders on every write return (
{t} {yield* Child.with({ n: 1 })} {/* a fresh `{ n: 1 }` literal each render */}
); }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Parent))); const runsAfterMount = childRuns; await act(async () => tick.update((n) => n + 1)); expect(container.querySelector('output')?.textContent).toBe('1'); // the parent re-rendered expect(childRuns).toBeGreaterThan(runsAfterMount); // no memo — the child re-ran with it await act(async () => root.unmount()); }); it('skips the child body when a .memo() child receives value-equal primitive props', async () => { const tick = atom('tick', 0); let childRuns = 0; const Child = rec(function* (props: { n: number }) { childRuns += 1; return {props.n}; }); const MemoChild = Child.memo(); // once, at declaration — a stable component type const Parent = rec(function* () { const t = yield* tick; return (
{t} {yield* MemoChild.with({ n: 1 })} {/* a fresh `{ n: 1 }` literal each render */}
); }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Parent))); const runsAfterMount = childRuns; await act(async () => tick.update((n) => n + 1)); expect(container.querySelector('output')?.textContent).toBe('1'); // the parent DID re-render expect(childRuns).toBe(runsAfterMount); // …but value-equal props skipped the child await act(async () => root.unmount()); }); it('re-runs a .memo() child when a prop changes value', async () => { const tick = atom('tick', 0); let childRuns = 0; const Child = rec(function* (props: { n: number }) { childRuns += 1; return {props.n}; }); const MemoChild = Child.memo(); const Parent = rec(function* () { const t = yield* tick; return
{yield* MemoChild.with({ n: t })}
; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Parent))); const runsAfterMount = childRuns; await act(async () => tick.update((n) => n + 1)); expect(childRuns).toBeGreaterThan(runsAfterMount); expect(container.querySelector('i')?.textContent).toBe('1'); await act(async () => root.unmount()); }); it('still re-renders a .memo() child from its OWN atom subscription', async () => { const tick = atom('tick', 0); const inner = atom('inner', 0); let childRuns = 0; const Child = rec(function* (props: { n: number }) { childRuns += 1; const v = yield* inner; // the child's own subscription return ( {props.n}:{v} ); }); const MemoChild = Child.memo(); const Parent = rec(function* () { const t = yield* tick; return (
{t} {yield* MemoChild.with({ n: 1 })}
); }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Parent))); const runsAfterMount = childRuns; // Parent-only write, value-equal props → the memo skips the child… await act(async () => tick.update((n) => n + 1)); expect(childRuns).toBe(runsAfterMount); // …but the child's own atom still re-renders it straight through the memo. await act(async () => inner.update((n) => n + 1)); expect(childRuns).toBeGreaterThan(runsAfterMount); expect(container.querySelector('em')?.textContent).toBe('1:1'); await act(async () => root.unmount()); }); it('skips the child body for structurally-equal fresh Equal-implementing prop values', async () => { // A value class carrying its own equality — the shape `.memo()`'s gate // admits for structured data (a plain object or array would not compile). class Vec implements Equal.Equal { constructor( readonly x: number, readonly y: number, ) {} [Equal.symbol](that: Equal.Equal): boolean { return that instanceof Vec && this.x === that.x && this.y === that.y; } [Hash.symbol](): number { return Hash.string(`${this.x},${this.y}`); } } const tick = atom('tick', 0); let childRuns = 0; const Child = rec(function* (props: { v: Vec }) { childRuns += 1; return ( {props.v.x},{props.v.y} ); }); const MemoChild = Child.memo(); const Parent = rec(function* () { const t = yield* tick; return (
{t} {yield* MemoChild.with({ v: new Vec(1, 2) })} {/* a fresh instance each render */}
); }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Parent))); const runsAfterMount = childRuns; await act(async () => tick.update((n) => n + 1)); expect(container.querySelector('output')?.textContent).toBe('1'); // the parent DID re-render expect(childRuns).toBe(runsAfterMount); // …but the Equal trait skipped the child await act(async () => root.unmount()); }); }); // --- type-level guarantees (checked by tsgo, not run) --- { const Needs = rec(function* () { const s = yield* Stats; return {s.total}; }); const Root = rec(function* () { return
{yield* Needs}
; }); // ✓ AppLive provides Stats: void mount(statsLayer(1), Root); // ✗ empty layer is missing Stats — mount returns a non-ReactNode error type: // @ts-expect-error effract: runtime is missing Stats const _bad: ReactNode = mount(Layer.empty, Root); void _bad; // ✗ a REC is not a JSX element type: // @ts-expect-error RECs cannot be used as JSX const _jsx = ; void _jsx; // A volatile atom read is a mount-checked obligation (the render channel). // The cast stands in for a born-volatile source (`atom.fromStream`, which // returns `Atom`); the atom is never dereferenced — the // generator below never runs. const price = null as unknown as ReadableAtom; const Ticker = rec(function* () { const p = yield* price; return {p}; }); // ✗ an undischarged volatile subscription refuses to mount: // @ts-expect-error effract: volatile subscription not handled const _volatile: ReactNode = mount(Layer.empty, Ticker); void _volatile; // ✓ .hot() is the deliberate acknowledgment — V discharged: void mount(Layer.empty, Ticker.hot()); // ✓ RendersOn extracts the exact label union a REC can re-render on: const _lock: [RendersOn] extends ['price'] ? 'price' extends RendersOn ? true : never : never = true; void _lock; }