/** * `query` is effract's async-data primitive: it suspends the render for its * value, refetches when its key changes, interrupts the in-flight fiber on * unmount, and — at the type level — contributes a loading obligation `S` that * `.suspense(fallback)` (or `mount(…, { loading })`) must discharge. These tests * pin the runtime behaviour; the block at the bottom pins the obligation tsgo * enforces. Typed-error handling rides on the same `E` channel as `.catch`. */ import { StrictMode, act, useState, type ReactNode } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as Data from 'effect/Data'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Option from 'effect/Option'; import { atom, hook, mount, query, rec, suspend } from '../../index.client.ts'; Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); class NotFound extends Data.TaggedError('NotFound')<{ readonly id: number }> {} interface User { readonly name: string; } 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('query — async data + loading obligation', () => { it('suspends for a query, then resolves inline', async () => { let resolve: (u: User) => void = () => {}; const gate = new Promise((r) => { resolve = r; }); const Profile = rec(function* () { const user = yield* query(Effect.promise(() => gate)); return hi {user.name}; }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Profile))); expect(container.textContent).toContain('loading'); await act(async () => { resolve({ name: 'Ada' }); await gate; }); await flush(); expect(container.textContent).toContain('hi Ada'); await act(async () => root.unmount()); }); it('refetches when the key changes, and not when it is unchanged', async () => { const calls: number[] = []; const load = (id: number): Effect.Effect => Effect.promise(() => { calls.push(id); return Promise.resolve(`user-${id}`); }); const Profile = rec(function* (props: { readonly id: number }) { const name = yield* query(load(props.id), props.id); return {name}; }).suspense(loading); const App = rec(function* () { const [id, setId] = yield* hook(useState(1)); const [, bump] = yield* hook(useState(0)); return (
{yield* Profile.with({ id })}
); }); const root = createRoot(container); const click = (x: string): Promise => act(async () => { container .querySelector(`[data-x="${x}"]`) ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await act(async () => root.render(mount(Layer.empty, App))); await flush(); expect(container.querySelector('[data-x="name"]')?.textContent).toBe('user-1'); // Exactly one run despite suspending before commit — the cross-render store // dedupes every render attempt of the same query. expect(calls).toEqual([1]); // Re-render with the same key → the entry is reused, no refetch. await click('rerender'); await flush(); expect(calls).toEqual([1]); expect(container.querySelector('[data-x="name"]')?.textContent).toBe('user-1'); // Change the key → exactly one refetch, for the new id. await click('next'); await flush(); expect(container.querySelector('[data-x="name"]')?.textContent).toBe('user-2'); expect(calls).toEqual([1, 2]); await act(async () => root.unmount()); }); it('two different RECs with the same keyed query share one fetch (#40 runtime cache)', async () => { const calls: number[] = []; const load = (id: number): Effect.Effect => Effect.promise(() => { calls.push(id); return Promise.resolve(`user-${id}`); }); // Two *distinct* component types issuing the identical keyed query. Under one // runtime they must dedupe to a single fetch — the cache identity is the key, // not the component or the yield position. const CardA = rec(function* () { const name = yield* query(load(1), 1); return {name}; }); const CardB = rec(function* () { // a different yield position (an extra effect first) — identity is still the key yield* Effect.succeed(0); const name = yield* query(load(1), 1); return {name}; }); const Page = rec(function* () { return (
{yield* CardA} {yield* CardB}
); }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Page))); await flush(); expect(container.querySelector('[data-x="a"]')?.textContent).toBe('user-1'); expect(container.querySelector('[data-x="b"]')?.textContent).toBe('user-1'); // One fetch, shared across the two components and yield positions. expect(calls).toEqual([1]); await act(async () => root.unmount()); }); it('renders a typed fallback when a query fails (via .catch)', async () => { const Profile = rec(function* () { const user = yield* query( Effect.fail(new NotFound({ id: 9 })) as Effect.Effect, ); return hi {user.name}; }) .catch({ NotFound: (e) => missing {e.id} }) .suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Profile))); await flush(); expect(container.textContent).toContain('missing 9'); await act(async () => root.unmount()); }); it('interrupts the in-flight query fiber on unmount', async () => { let interrupted = false; const never = new Promise(() => {}); const Profile = rec(function* () { const n = yield* query( Effect.promise(() => never).pipe( Effect.onInterrupt(() => Effect.sync(() => void (interrupted = true))), ), ); return {n}; }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Profile))); expect(container.textContent).toContain('loading'); await act(async () => root.unmount()); // The abort is deferred a microtask (StrictMode-safe) and interruption is // async; give the fiber a moment to run its finalizer. await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); expect(interrupted).toBe(true); }); it('suspend — the primitive — suspends for a value and carries the obligation', async () => { let resolve: (u: User) => void = () => {}; const gate = new Promise((r) => { resolve = r; }); // `suspend` (no key) is the load-once primitive `query` is built on. const Profile = rec(function* () { const user = yield* suspend(Effect.promise(() => gate)); return hi {user.name}; }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Profile))); expect(container.textContent).toContain('loading'); await act(async () => { resolve({ name: 'Ada' }); await gate; }); await flush(); expect(container.textContent).toContain('hi Ada'); await act(async () => root.unmount()); }); it('runs once and survives under StrictMode (no double-fetch on remount)', async () => { let runs = 0; let resolve: (u: User) => void = () => {}; const gate = new Promise((r) => { resolve = r; }); const Profile = rec(function* () { const user = yield* query( Effect.promise(() => { runs++; return gate; }), ); return hi {user.name}; }).suspense(loading); const root = createRoot(container); await act(async () => root.render({mount(Layer.empty, Profile)})); await act(async () => { resolve({ name: 'Ada' }); await gate; }); await flush(); expect(container.textContent).toContain('hi Ada'); expect(runs).toBe(1); await act(async () => root.unmount()); }); it('discharges the loading obligation at the mount boundary with { loading }', async () => { let resolve: (u: User) => void = () => {}; const gate = new Promise((r) => { resolve = r; }); const Profile = rec(function* () { const user = yield* query(Effect.promise(() => gate)); return hi {user.name}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Profile, { loading: booting }))); expect(container.textContent).toContain('booting'); await act(async () => { resolve({ name: 'Ada' }); await gate; }); await flush(); expect(container.textContent).toContain('hi Ada'); await act(async () => root.unmount()); }); it('a refetch that fails renders the .catch fallback in place (siblings survive)', async () => { // Mirrors the playground: a query keyed by a service atom, initially succeeds, // then the key changes to one whose fetch fails with a typed error. Gated // promises make the async settlement deterministic. const gates = new Map; settle: () => void }>(); const gateFor = (id: number): { promise: Promise; settle: () => void } => { const existing = gates.get(id); if (existing) { return existing; } let settle: () => void = () => {}; const promise = new Promise((resolve, reject) => { settle = () => (id === 9 ? reject(new NotFound({ id })) : resolve(`product-${id}`)); }); const gate = { promise, settle }; gates.set(id, gate); return gate; }; const load = (id: number): Effect.Effect => Effect.tryPromise({ try: () => gateFor(id).promise, catch: (e) => e as NotFound }); const sku = atom('sku', 1); const tag = atom('tag', '•'); // A reactive read comes AFTER the catchable query on purpose: a failure skips // it, so the render calls fewer hooks than the prior success. `.catch` is a // React error boundary, so the throwing render is discarded (no "rendered // fewer hooks" teardown) and the fallback shows regardless of yield order. const Product = rec(function* () { const id = yield* sku; const name = yield* query(load(id), id); // refetch when the id changes const mark = yield* tag; // ← skipped when the query throws return ( {name} {mark} ); }) .catch({ NotFound: (e) => missing {e.id} }) .suspense(loading); const Page = rec(function* () { return (
nav {yield* Product}
); }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Page))); await act(async () => { gateFor(1).settle(); await gateFor(1).promise; }); await flush(); expect(container.querySelector('[data-x="name"]')?.textContent).toContain('product-1'); // Refetch to a failing id — the .catch fallback renders in place, and the // sibling survives (the tree must NOT unmount). await act(async () => sku.set(9)); await act(async () => { gateFor(9).settle(); await gateFor(9).promise.catch(() => {}); }); await flush(); expect(container.querySelector('[data-x="sibling"]')).not.toBeNull(); expect(container.querySelector('[data-x="err"]')?.textContent).toBe('missing 9'); // Recovery: because the REC rendered its fallback *inline* (it stayed mounted // and subscribed), navigating back to a valid id re-renders and refetches. await act(async () => sku.set(1)); await flush(); expect(container.querySelector('[data-x="err"]')).toBeNull(); expect(container.querySelector('[data-x="name"]')?.textContent).toContain('product-1'); await act(async () => root.unmount()); }); }); // Invalidation and manual refetch (#41, #43) are driven through the `QueryCache` // service — pinned in `infrastructure/query-cache.test.tsx` (ADR 0008). describe('live query cache — dependent queries, staleTime', () => { it('query.enabled never fires the effect while disabled, reads an Option (#44)', async () => { const calls: number[] = []; const load = (id: number): Effect.Effect => Effect.promise(() => { calls.push(id); return Promise.resolve(`user-${id}`); }); // The dependent-query shape: fetch a profile only once a userId exists. const userId = atom('userId', undefined as number | undefined); const Profile = rec(function* () { const id = yield* userId; const profile = yield* query.enabled(load(id ?? 0), id, id !== undefined); return ( {Option.match(profile, { onNone: () => 'no-user', onSome: (u) => u })} ); }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Profile))); await flush(); // Disabled → the effect never fired; the read is Option.none. expect(container.querySelector('[data-x="p"]')?.textContent).toBe('no-user'); expect(calls).toEqual([]); // Enable it → it fires once and reads Option.some. await act(async () => userId.set(1)); await flush(); expect(container.querySelector('[data-x="p"]')?.textContent).toBe('user-1'); expect(calls).toEqual([1]); await act(async () => root.unmount()); }); it('staleTime serves the cached value then revalidates in the background (#42)', async () => { vi.useFakeTimers(); vi.setSystemTime(0); try { let n = 0; const load = Effect.promise(() => { n += 1; return Promise.resolve(`v${n}`); }); const bump = atom('bump', 0); const Data = rec(function* () { const b = yield* bump; // an unrelated re-render trigger const v = yield* query(load, 'k', { staleTime: 1000 }); return ( {v}:{b} ); }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Data))); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v1:0'); expect(n).toBe(1); // Still fresh (< staleTime): a re-render does not refetch. vi.setSystemTime(500); await act(async () => bump.set(1)); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v1:1'); expect(n).toBe(1); // Past staleTime: the re-render serves v1 instantly, then background-refetches. vi.setSystemTime(2000); await act(async () => bump.set(2)); await flush(); expect(n).toBe(2); // a background revalidation ran expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v2:2'); await act(async () => root.unmount()); } finally { vi.useRealTimers(); } }); }); // --- type-level guarantees (checked by tsgo, not run) --- { const Async = rec(function* () { const user = yield* query(Effect.succeed({ name: 'x' } as User)); return {user.name}; }); // ✗ a query left the loading obligation unhandled — mount refuses it: // @ts-expect-error effract: loading not handled void mount(Layer.empty, Async); // ✓ discharged with .suspense: void mount(Layer.empty, Async.suspense(loading)); // ✓ discharged at the boundary with { loading }: void mount(Layer.empty, Async, { loading: loading }); // The obligation bubbles: a parent that places `Async` inherits it. const Parent = rec(function* () { return
{yield* Async}
; }); // @ts-expect-error effract: loading not handled (bubbled up from the child) void mount(Layer.empty, Parent); void mount(Layer.empty, Parent.suspense(loading)); // `suspend` (the primitive) carries the obligation too — not just `query`: const Susp = rec(function* () { const v = yield* suspend(Effect.succeed(1)); return {v}; }); // @ts-expect-error effract: loading not handled void mount(Layer.empty, Susp); void mount(Layer.empty, Susp.suspense(loading)); // A REC with a plain (non-suspensable) effect has no obligation — mounts with neither: const Plain = rec(function* () { const n = yield* Effect.succeed(1); return {n}; }); const _node: ReactNode = mount(Layer.empty, Plain); void _node; // Opt-in: a *raw async* effect still suspends at runtime, but carries no loading // obligation — `query` is what opts a REC into type-tracked loading. So this // mounts without `.suspense` (you bring your own , as in recipe 03). const RawAsync = rec(function* () { const v = yield* Effect.promise(() => Promise.resolve(1)); return {v}; }); const _raw: ReactNode = mount(Layer.empty, RawAsync); void _raw; }