/** * The suspensable store's lifecycle guarantees, exercised through the real React * binding: * * - raw `yield* asyncEffect` now rides the *same* store as suspend/query, so it * runs its effect exactly once across pre-commit retries and StrictMode (#34) * and its fiber is interrupted on unmount (#29) — one cancellation standard; * - query keys are compared by Effect `Hash`/`Equal`, so a reordered-but-equal * object key reuses the entry and a distinct key never collides (#30); * - a superseded, un-claimed entry is interrupted promptly on key churn rather * than lingering until the sweep (#35). */ import { StrictMode, Suspense, act } 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 { atom, mount, query, rec } 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('raw async rides the suspensable store (#29, #34)', () => { it('runs the async work exactly once under StrictMode + pre-commit retries', async () => { // The counter sits in the effect's *continuation* (after the async boundary), // which is where the dedup guarantee lives: the interpreter's sync/async probe // starts one fiber per render attempt, but the store joins the *first* and // interrupts every redundant one before its continuation runs — so the work // past the suspension executes exactly once, no matter how many attempts React // makes (StrictMode's double-render + the pre-commit Suspense retries). let runs = 0; let resolve: (v: number) => void = () => {}; const gate = new Promise((r) => { resolve = r; }); const Async = rec(function* () { const v = yield* Effect.promise(() => gate).pipe( Effect.tap(() => Effect.sync(() => void (runs += 1))), ); 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 () => { resolve(7); await gate; }); await flush(); expect(container.textContent).toContain('val:7'); expect(runs).toBe(1); await act(async () => root.unmount()); }); it('interrupts the in-flight raw async fiber on unmount (finalizers run)', async () => { let interrupted = false; const never = new Promise(() => {}); const Async = rec(function* () { const v = yield* Effect.promise(() => never).pipe( Effect.onInterrupt(() => Effect.sync(() => void (interrupted = true))), ); return {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 () => 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); }); }); describe('query keys compared by Hash/Equal (#30)', () => { it('a reordered-but-equal object key reuses the entry; a distinct key refetches', async () => { const calls: Array> = []; const load = (key: Record): Effect.Effect => Effect.promise(() => { calls.push({ ...key }); return Promise.resolve('ok'); }); // phase 0 → { a:1, b:2 }; phase 1 → { b:2, a:1 } (Equal, property order flipped); // phase 2 → { a:1, b:3 } (distinct). The key object is rebuilt every render, so // a fresh reference each time — only structural equality can dedupe it. const phase = atom('phase', 0); const Q = rec(function* () { const p = yield* phase; const key = p === 0 ? { a: 1, b: 2 } : p === 1 ? { b: 2, a: 1 } : { a: 1, b: 3 }; const v = yield* query(load(key), key); return ( {v}:{p} ); }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Q))); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('ok:0'); expect(calls).toHaveLength(1); // Reordered-but-equal key → same entry, no refetch. await act(async () => phase.set(1)); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('ok:1'); expect(calls).toHaveLength(1); // Distinct key → exactly one more fetch (never a collision onto the old entry). await act(async () => phase.set(2)); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('ok:2'); expect(calls).toHaveLength(2); await act(async () => root.unmount()); }); }); describe('prompt supersede-interrupt on key churn (#35)', () => { it('interrupts superseded, un-claimed fibers immediately — not after the sweep', async () => { const interrupted = new Set(); // Each id's fetch never settles; interruption is the only way it ends. const load = (id: number): Effect.Effect => Effect.promise(() => new Promise(() => {})).pipe( Effect.onInterrupt(() => Effect.sync(() => void interrupted.add(id))), ); // The parent owns the key and commits (its child suspends into its own // boundary), so each key change re-renders the never-committing child with a // new key at the same (scope, index) slot — the churn #35 targets. const id = atom('id', 0); const Child = rec(function* (props: { readonly id: number }) { const v = yield* query(load(props.id), props.id); return {v}; }).suspense(loading); const Parent = rec(function* () { const i = yield* id; return
{yield* Child.with({ id: i })}
; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, Parent))); await act(async () => id.set(1)); await act(async () => id.set(2)); await act(async () => id.set(3)); // Let the abort microtasks + finalizers run. await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); // The three superseded keys were interrupted as soon as the next key rendered; // the current key (3) is still in flight, not interrupted. expect(interrupted.has(0)).toBe(true); expect(interrupted.has(1)).toBe(true); expect(interrupted.has(2)).toBe(true); expect(interrupted.has(3)).toBe(false); await act(async () => root.unmount()); }); });