/** * The interpreter, exercised with no React at all. This is the payoff of * keeping it in the application layer: the entire fiber-bridging logic — hook * unwrapping, synchronous service resolution, async suspension, error * propagation — is testable against plain fakes. */ import { describe, expect, it } from 'vitest'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import type * as Exit from 'effect/Exit'; import * as Fiber from 'effect/Fiber'; import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import { AtomTypeId, hook, placement, suspend, type ReadableAtom, type RecGenerator, type RecHandle, } from '#domain/protocol.ts'; import { driveRec, driveState } from '#application/interpreter.ts'; import type { Executor, InterpreterDeps, Placer, Reader, SuspensableResolver, Suspender, } from '#application/ports.ts'; class Stats extends Context.Service()('test/Stats') {} const executorWith = (layer: Layer.Layer): Executor => { const runtime = ManagedRuntime.make(layer) as unknown as ManagedRuntime.ManagedRuntime< unknown, unknown >; return { runSyncExit: (effect) => runtime.runSyncExit(effect), serviceContext: () => runtime.cachedContext ?? null, runPromise: (effect) => runtime.runPromise(effect), runPromiseExit: (effect: Effect.Effect): Promise> => runtime.runPromiseExit(effect as Effect.Effect) as Promise>, join: (fiber) => runtime.runPromise(Fiber.join(fiber)), interrupt: (fiber) => void runtime.runFork(Fiber.interrupt(fiber)), withScope: (effect) => ({ promise: runtime.runPromise(effect), close: () => {} }), }; }; const neverSuspend: Suspender = { use: () => { throw new Error('did not expect to suspend'); }, }; const neverPlace: Placer = { place: () => { throw new Error('did not expect to place a child'); }, }; const neverResolveSuspensable: SuspensableResolver = { resolve: () => { throw new Error('did not expect a query'); }, }; /** A trivial reader: the current value, no subscription (enough for these tests). */ const staticReader: Reader = { read: (atomRead) => atomRead.value }; const makeDeps = ( layer: Layer.Layer, suspender: Suspender = neverSuspend, suspensableResolver: SuspensableResolver = neverResolveSuspensable, ): InterpreterDeps => ({ traceYield: undefined, reportFlip: undefined, executor: executorWith(layer), suspender, suspensableResolver, reader: staticReader, placer: neverPlace, }); describe('driveRec', () => { it('unwraps lifted hooks without touching the runtime', () => { const result = driveRec( (function* () { const a = yield* hook(10); const b = yield* hook(20); return a + b; })(), makeDeps(Layer.empty), ); expect(result).toBe(30); }); it('resolves a service Tag synchronously from the runtime', () => { const result = driveRec( (function* () { const stats = yield* Stats; return stats.total; })(), makeDeps(Layer.succeed(Stats)({ total: 42 })), ); expect(result).toBe(42); }); it('interleaves services and hooks in one render pass — the fiber bridge', () => { const result = driveRec( (function* () { const stats = yield* Stats; const label = yield* hook('overview'); const suffix = yield* hook('!'); return `${label}:${stats.total}${suffix}`; })(), makeDeps(Layer.succeed(Stats)({ total: 7 })), ); expect(result).toBe('overview:7!'); }); it('routes an async effect through the resolver by handing it the running fiber', () => { // A raw `yield* asyncEffect` no longer has its own per-render cache: the // interpreter detects async (an AsyncFiberError from runSyncExit, which leaves // the effect running on a fiber) and hands *that fiber* to the same resolver as // suspend/query — one cancellation standard, and the effect runs once (joined, // never re-run). Here we assert the routing; the store's dedup and interruption // are exercised in the React-level tests. const calls: Array<{ hasFiber: boolean; index: number; key: unknown }> = []; const resolver: SuspensableResolver = { resolve: (request, index) => { calls.push({ hasFiber: 'fiber' in request, index, key: request.key }); return 99; // stands in for the settled value the resolver returns on retry }, }; const result = driveRec( (function* () { const value = yield* Effect.promise(() => Promise.resolve(99)); return value; })(), makeDeps(Layer.empty, neverSuspend, resolver), ); expect(result).toBe(99); expect(calls).toHaveLength(1); expect(calls[0]?.hasFiber).toBe(true); // handed the already-running fiber, not the effect expect(calls[0]?.index).toBe(0); expect(calls[0]?.key).toBeUndefined(); // raw async is load-once — no refetch key }); it('shares one encounter counter across suspensables and raw async yields', () => { // The store slot is keyed by (scope, index); a per-kind counter would let a // raw-async and a suspensable collide at index 0. One counter gives each a // distinct position in body order. const indices: number[] = []; const resolver: SuspensableResolver = { resolve: (_suspendable, index) => { indices.push(index); return `v${index}`; }, }; driveRec( (function* () { yield* suspend(Effect.promise(() => Promise.resolve('a'))); // index 0 yield* Effect.promise(() => Promise.resolve('b')); // raw async → index 1 return null; })(), makeDeps(Layer.empty, neverSuspend, resolver), ); expect(indices).toEqual([0, 1]); }); it('throws a genuine Effect failure to the error boundary', () => { expect.assertions(1); try { driveRec( (function* () { const value = yield* Effect.fail('boom'); return value; })(), makeDeps(Layer.empty), ); } catch (thrown) { expect(thrown).toBe('boom'); } }); it('increments DriveState.hooks exactly once per hook-bearing yield kind (#37 invariant)', () => { // The `.catch` fast-path in react/rec.tsx trusts this counter to reflect every // hook-bearing yield. Walk each kind and assert its contribution, so a future // kind that forgets its increment fails here, not with an intermittent // hook-order crash in production. // hook, atom read → +1 (one React hook each) // suspensable, async effect → +2 (the resolver subscribes to the entry AND // `use`s its promise) // sync effect, child placement → +0 const fakeAtom: ReadableAtom = { [AtomTypeId]: true, value: 7, subscribe: () => () => {}, derive: () => fakeAtom as unknown as ReadableAtom, [Symbol.iterator]() { let done = false; return { next(sent?: unknown): IteratorResult, number> { if (done) { return { done: true, value: sent as number }; } done = true; return { done: false, value: fakeAtom }; }, }; }, }; // The child's body is never driven here (the placer stands in), so a stub // suffices — cast past the generator signature to avoid an empty generator. const childHandle: RecHandle = { body: (() => undefined) as unknown as RecHandle['body'], displayName: 'Child', }; const deps: InterpreterDeps = { traceYield: undefined, reportFlip: undefined, executor: executorWith(Layer.succeed(Stats)({ total: 1 })), suspender: neverSuspend, suspensableResolver: { resolve: () => 0 }, reader: { read: (atomRead) => atomRead.value }, placer: { place: () => 'child-node' }, }; const state = driveState(); driveRec( (function* () { yield* hook('h'); // +1 yield* fakeAtom; // +1 yield* suspend(Effect.promise(() => Promise.resolve(1))); // +2 yield* Effect.promise(() => Promise.resolve(2)); // +2 yield* Stats; // +0 — a synchronous effect is not a hook yield* placement(childHandle, {}); // +0 — a placement is not a hook return null; })() as RecGenerator, deps, state, ); expect(state.hooks).toBe(6); }); it('rejects a yield that is neither an Effect nor a hook', () => { const body = function* () { yield 5 as never; // a bare yield of a non-instruction: the interpreter must reject it return null; }; expect(() => driveRec(body(), makeDeps(Layer.empty))).toThrow(TypeError); }); }); describe('the sync↔async flip detector (dev-only, ADR 0007)', () => { /** A resolver that settles any suspending yield inline, so drives complete. */ const settleInline: SuspensableResolver = { resolve: () => 42 }; const depsWithFlips = (flips: number[]): InterpreterDeps => ({ ...makeDeps(Layer.empty, neverSuspend, settleInline), reportFlip: (index) => flips.push(index), }); /** A body with one raw effect yield, choosable sync or async per render. */ const body = (effect: Effect.Effect): RecGenerator => (function* () { const value = yield* effect; return value; })() as RecGenerator; const syncEffect = Effect.succeed(1); const asyncEffect = Effect.promise(() => Promise.resolve(2)); it('reports a raw effect yield that flips sync↔async across renders, at its ordinal', () => { const flips: number[] = []; const deps = depsWithFlips(flips); // The binding threads ONE history map across an instance's renders. const history = new Map(); driveRec(body(syncEffect), deps, driveState(history)); // render 1: sync expect(flips).toEqual([]); // a first resolution is not a flip driveRec(body(asyncEffect), deps, driveState(history)); // render 2: async — flip expect(flips).toEqual([0]); driveRec(body(syncEffect), deps, driveState(history)); // render 3: back — flips again expect(flips).toEqual([0, 0]); // once-per-instance dedup is the binding's job }); it('identifies the flipping yield among stable ones — ordinals are per raw effect yield', () => { const flips: number[] = []; const deps = depsWithFlips(flips); const history = new Map(); const twoEffects = (second: Effect.Effect): RecGenerator => (function* () { yield* Effect.succeed(0); // ordinal 0 — consistently sync const value = yield* second; // ordinal 1 — the one under test return value; })() as RecGenerator; driveRec(twoEffects(syncEffect), deps, driveState(history)); driveRec(twoEffects(asyncEffect), deps, driveState(history)); expect(flips).toEqual([1]); }); it('is silent for a suspend()-wrapped effect — the stable-hook fix routes past it', () => { const flips: number[] = []; const deps = depsWithFlips(flips); const history = new Map(); const wrapped = (): RecGenerator => (function* () { // `suspend` resolves through the suspensable resolver, never // `resolveEffect` — its loading is a stable hook, so no flip is possible. const value = yield* suspend(Effect.promise(() => Promise.resolve(7))); return value; })() as RecGenerator; driveRec(wrapped(), deps, driveState(history)); driveRec(wrapped(), deps, driveState(history)); expect(flips).toEqual([]); }); it('does no work without a threaded history (production: no map, no report)', () => { const flips: number[] = []; const deps = depsWithFlips(flips); driveRec(body(syncEffect), deps, driveState()); // no syncHistory — detector off driveRec(body(asyncEffect), deps, driveState()); expect(flips).toEqual([]); }); });