/** * `.catch` renders a typed fallback for a REC's declared failures. Effect's * error channel `E` becomes an exhaustive, compile-checked handler map: a * failure — synchronous *or* async — renders its mapped node in place, while * defects and a child's own failures are left alone. These tests pin the runtime * behaviour; the block at the bottom pins the types tsgo enforces. */ import { Component, Suspense, act, 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 Data from 'effect/Data'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { onDevEvent, type DevEvent } from '../devtools-core.ts'; import { rec, mount } from '../../index.client.ts'; Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); class NotFound extends Data.TaggedError('NotFound')<{ readonly id: number }> {} class Unauthorized extends Data.TaggedError('Unauthorized')<{ readonly reason: string }> {} interface User { readonly name: string; } /** A service call with a real union error channel — the shape `.catch` shines on. */ const fetchUser = ( outcome: 'ok' | 'missing' | 'denied', ): Effect.Effect => { if (outcome === 'missing') return Effect.fail(new NotFound({ id: 7 })); if (outcome === 'denied') return Effect.fail(new Unauthorized({ reason: 'expired' })); return Effect.succeed({ name: 'Ada' }); }; /** The same call, but async — it suspends first, then settles into its failure. */ const fetchUserAsync = (gate: Promise): Effect.Effect => Effect.tryPromise({ try: () => gate, catch: () => new NotFound({ id: 1 }) }); let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => container.remove()); /** Minimal boundary so an *un*caught failure is observable instead of fatal. */ class Boundary extends Component<{ children: ReactNode }, { readonly caught: string | null }> { override state = { caught: null as string | null }; static getDerivedStateFromError(error: unknown): { caught: string } { return { caught: error instanceof Error ? error.message : String(error) }; } override render(): ReactNode { return this.state.caught === null ? this.props.children : boundary:{this.state.caught}; } } describe('.catch — typed error rendering', () => { it('renders a typed fallback for a synchronous failure', () => { const Profile = rec(function* () { const user = yield* fetchUser('missing'); return hi {user.name}; }).catch({ NotFound: () => empty, Unauthorized: () => login, }); expect(renderToStaticMarkup(mount(Layer.empty, Profile))).toContain('empty'); }); it('passes the tagged error to its handler', () => { const Profile = rec(function* () { const user = yield* fetchUser('missing'); return hi {user.name}; }).catch({ NotFound: (e) => missing {e.id}, Unauthorized: (e) => {e.reason}, }); expect(renderToStaticMarkup(mount(Layer.empty, Profile))).toContain('missing 7'); }); it('dispatches on the failing tag among several', () => { const Profile = rec(function* () { const user = yield* fetchUser('denied'); return hi {user.name}; }).catch({ NotFound: () => empty, Unauthorized: (e) => login:{e.reason}, }); expect(renderToStaticMarkup(mount(Layer.empty, Profile))).toContain('login:expired'); }); it('renders a typed fallback for an async failure, after Suspense', async () => { let reject: (e: unknown) => void = () => {}; const gate = new Promise((_, r) => { reject = r; }); const Profile = rec(function* () { const user = yield* fetchUserAsync(gate); return hi {user.name}; }).catch({ NotFound: () => empty, Unauthorized: () => login, }); const Page = rec(function* () { return loading}>{yield* Profile}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Page))); expect(container.textContent).toContain('loading'); await act(async () => { reject(new Error('boom')); await gate.catch(() => {}); }); expect(container.textContent).toContain('empty'); await act(async () => root.unmount()); }); it('does not swallow a defect — it reaches the boundary, formatted with context in dev (#57)', async () => { const Boom = rec(function* Boom() { yield* Effect.die(new Error('kaboom')); return never; }).catch({}); // no tagged errors in E, so `{}` is exhaustive const root = createRoot(container); await act(async () => root.render({mount(Layer.empty, Boom)})); // The defect is not swallowed (it reaches the boundary), and in dev it arrives // enriched: the failing REC's name plus the pretty-printed cause (#57). const text = container.textContent ?? ''; expect(text).toContain('boundary:'); // reached the boundary — never swallowed expect(text).toContain('Boom'); // the failing component's name expect(text).toContain('kaboom'); // the original defect, via the pretty cause await act(async () => root.unmount()); }); it("composes as a child that owns its failure — the parent doesn't swallow it", () => { // The child catches its own failure; the parent has no handler at all, yet // the child's fallback renders in place (and the parent is unaffected). const Child = rec(function* () { const user = yield* fetchUser('missing'); return hi {user.name}; }).catch({ NotFound: () => child-empty, Unauthorized: () => child-login, }); const Parent = rec(function* () { return
ok:{yield* Child}
; }); const html = renderToStaticMarkup(mount(Layer.empty, Parent)); expect(html).toContain('ok:'); expect(html).toContain('child-empty'); }); it('gives the .catch fallback a retry that re-attempts the body (#57)', async () => { let healed = false; // A failure the retry can clear: it fails until `healed` flips true. const load = (): Effect.Effect => Effect.suspend(() => healed ? Effect.succeed('recovered') : Effect.fail(new NotFound({ id: 1 })), ); const View = rec(function* () { const value = yield* load(); return {value}; }).catch({ NotFound: (_error, { retry }) => ( ), }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, View))); expect(container.querySelector('[data-x="retry"]')).not.toBeNull(); // failed → fallback // Click retry → the body re-attempts, now succeeds. await act(async () => { container .querySelector('[data-x="retry"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.querySelector('[data-x="ok"]')?.textContent).toBe('recovered'); await act(async () => root.unmount()); }); }); describe('.catchDefect — the opt-in last resort (#57)', () => { it('renders the fallback for a defect the tag dispatch cannot name', () => { const Boom = rec(function* () { yield* Effect.die(new Error('kaboom')); return never; }).catchDefect(() => defect-fallback); expect(renderToStaticMarkup(mount(Layer.empty, Boom))).toContain('defect-fallback'); }); it('renders the fallback for an untagged failure, and hands it the original value', () => { const Boom = rec(function* () { yield* Effect.fail('plain-string-failure'); return never; }).catchDefect((defect) => got:{String(defect)}); expect(renderToStaticMarkup(mount(Layer.empty, Boom))).toContain('got:plain-string-failure'); }); it('never wins over the tag dispatch — .catch handles its tags first', () => { const Profile = rec(function* () { const user = yield* fetchUser('missing'); return hi {user.name}; }) .catch({ NotFound: () => typed-empty, Unauthorized: () => login, }) .catchDefect(() => defect-fallback); const html = renderToStaticMarkup(mount(Layer.empty, Profile)); expect(html).toContain('typed-empty'); expect(html).not.toContain('defect-fallback'); }); it('handles an async defect too — through the boundary, after Suspense', async () => { let reject: (e: unknown) => void = () => {}; const gate = new Promise((_, r) => { reject = r; }); // `Effect.promise` treats a rejection as a defect — exactly the unnameable case. const Boom = rec(function* () { const user = yield* Effect.promise(() => gate); return hi {user.name}; }).catchDefect(() => defect-fallback); const Page = rec(function* () { return loading}>{yield* Boom}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Page))); expect(container.textContent).toContain('loading'); await act(async () => { reject(new Error('async-kaboom')); await gate.catch(() => {}); }); expect(container.textContent).toContain('defect-fallback'); await act(async () => root.unmount()); }); it('is dev-visible: an absorbed defect is reported through the devtools seam', () => { const events: DevEvent[] = []; const disconnect = onDevEvent((event) => events.push(event)); const Boom = rec(function* namedBoom() { yield* Effect.die(new Error('seen-in-dev')); return never; }).catchDefect(() => defect-fallback); renderToStaticMarkup(mount(Layer.empty, Boom)); disconnect(); const handled = events.find((event) => event.kind === 'defect:handled'); expect(handled).toBeDefined(); expect(handled?.kind === 'defect:handled' && handled.rec).toBe('namedBoom'); expect(handled?.kind === 'defect:handled' && handled.detail).toContain('seen-in-dev'); }); }); // --- type-level guarantees (checked by tsgo, not run) --- { const base = rec(function* () { const user = yield* fetchUser('ok'); // E = NotFound | Unauthorized return {user.name}; }); // ✓ exhaustive over the error channel: void base.catch({ NotFound: (e) => {e.id}, Unauthorized: (e) => {e.reason}, }); // ✗ a forgotten tag is a compile error: // @ts-expect-error effract: handler for 'Unauthorized' is missing void base.catch({ NotFound: () => only }); // ✗ an unknown tag is rejected as an excess property: void base.catch({ NotFound: () => , Unauthorized: () => , // @ts-expect-error effract: 'Nope' is not an error this REC can raise Nope: () => , }); // ✓ a caught REC is a plain REC (error channel discharged) — mounts anywhere: const Safe = base.catch({ NotFound: () => , Unauthorized: () => }); const _node: ReactNode = mount(Layer.empty, Safe); void _node; // ✓ .catchDefect fully discharges the error channel (E = never), alone or // after .catch, and the result still mounts anywhere: const SafeAll = base.catchDefect(() => defect); const SafeBoth = base .catch({ NotFound: () => , Unauthorized: () => }) .catchDefect((_defect, { retry }) => ); const _nodes: ReactNode = ( <> {mount(Layer.empty, SafeAll)} {mount(Layer.empty, SafeBoth)} ); void _nodes; }