/** * `mutation` — the service-level mutation constructor (ADR 0008). Because the * state is an atom on a plain object a service holds, everything here except the * last test is a plain Effect program: no React, no `act`, no DOM. The rollback * is not a try/catch you remember to write — it fires exactly on the typed `E`. */ import { act, createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as Context from 'effect/Context'; import * as Data from 'effect/Data'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { QueryCache, atom, mount, mutation, rec, type Atom, type Mutation, } from '../index.client.ts'; class Forbidden extends Data.TaggedError('Forbidden')<{ readonly reason: string }> {} /** A QueryCache stub that records every invalidated tag. */ const recordingCache = (tags: unknown[]): Context.Context => Context.make(QueryCache, { invalidate: (tag: unknown) => { tags.push(tag); return 1; }, refetch: () => 0, }); /** Construct a mutation under a stub cache — synchronous, no runtime to keep. */ const construct = (program: Effect.Effect, tags: unknown[] = []): M => Effect.runSync(Effect.provideContext(program, recordingCache(tags))); /** Let the runPromiseExit settlement (a promise `.then`) run. */ const settled = async (): Promise => { await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); }; describe('mutation — service-level, no DOM', () => { it('transitions idle → pending → success, keeps the value, and calls onSuccess', async () => { const successes: Array = []; const m = construct( mutation('todos/add', (text: string) => Effect.promise(() => Promise.resolve(`${text}!`)), { onSuccess: (value, input) => successes.push([value, input] as const), }), ); expect(m.state.value).toEqual({ status: 'idle', error: undefined, data: undefined }); m.run('milk'); expect(m.state.value.status).toBe('pending'); await settled(); expect(m.state.value).toEqual({ status: 'success', error: undefined, data: 'milk!' }); expect(successes).toEqual([['milk!', 'milk']]); }); it('rolls back to the exact pre-optimistic state on a typed failure — error lands typed', async () => { const name = atom('name', 'Ada'); let shouldFail = true; const m = construct( mutation( 'users/rename', (next: string): Effect.Effect => shouldFail ? Effect.fail(new Forbidden({ reason: 'nope' })) : Effect.promise(() => Promise.resolve(next)), { optimistic: (next) => { const previous = name.value; name.set(next); // apply immediately return () => name.set(previous); // rollback restores exactly this }, }, ), ); m.run('Grace'); expect(name.value).toBe('Grace'); // optimistic value shows immediately await settled(); expect(name.value).toBe('Ada'); // rolled back exactly expect(m.state.value.status).toBe('error'); expect(m.state.value.error?._tag).toBe('Forbidden'); expect(m.state.value.data).toBeUndefined(); // Now let it succeed: the optimistic value stands. shouldFail = false; m.run('Grace'); await settled(); expect(name.value).toBe('Grace'); expect(m.state.value).toEqual({ status: 'success', error: undefined, data: 'Grace' }); }); it('invalidates each tag through the QueryCache service on success — and none on failure', async () => { const tags: unknown[] = []; const m = construct( mutation( 'todos/toggle', (ok: boolean): Effect.Effect => ok ? Effect.succeed('done') : Effect.fail(new Forbidden({ reason: 'no' })), { invalidates: ['todos', 'stats'] }, ), tags, ); m.run(false); await settled(); expect(tags).toEqual([]); // a failed mutation reloads nothing m.run(true); await settled(); expect(tags).toEqual(['todos', 'stats']); }); it("captures the constructing context — run's R is the service's own environment", async () => { class Api extends Context.Service string }>()( 'test/Api', ) {} const program = Effect.gen(function* () { // Constructed *inside* a context that provides Api — like a service `make`. return yield* mutation('todos/add', (text: string) => Effect.map(Api, (api) => api.add(text)), ); }); const m = Effect.runSync( Effect.provideContext( program, Context.add(recordingCache([]), Api, { add: (text) => `added:${text}` }), ), ); m.run('milk'); await settled(); expect(m.state.value.data).toBe('added:milk'); }); it('re-throws a defect on a microtask (never lands in error); an interruption is silent', async () => { // Run every scheduled microtask (Effect's own scheduling must keep working) // but capture what it throws — the seam the defect re-throw uses. const thrown: unknown[] = []; const spy = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((cb) => { try { cb(); } catch (error) { thrown.push(error); } }); try { const boom = new Error('boom'); const m = construct(mutation('will/die', () => Effect.die(boom))); m.run(undefined); await settled(); expect(m.state.value.status).toBe('error'); expect(m.state.value.error).toBeUndefined(); // a defect is not a typed failure expect(thrown.some((e) => String(e).includes('boom'))).toBe(true); // An interruption reaches the same error-undefined state, silently. thrown.length = 0; const interrupted = construct(mutation('will/stop', () => Effect.interrupt)); interrupted.run(undefined); await settled(); expect(interrupted.state.value.status).toBe('error'); expect(interrupted.state.value.error).toBeUndefined(); expect(thrown).toEqual([]); } finally { spy.mockRestore(); } }); it('reset() returns the state to idle', async () => { const m = construct(mutation('n/set', (n: number) => Effect.succeed(n))); m.run(7); await settled(); expect(m.state.value.status).toBe('success'); m.reset(); expect(m.state.value).toEqual({ status: 'idle', error: undefined, data: undefined }); }); }); describe('mutation state in a REC', () => { Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => container.remove()); it('a REC reading `yield* m.state` re-renders on every transition', async () => { class Names extends Context.Service< Names, { readonly name: Atom; readonly rename: Mutation } >()('test/Names') {} const NamesLive = Layer.effect(Names)( Effect.gen(function* () { const name = atom('names/name', 'Ada'); const rename = yield* mutation('names/rename', (next: string) => Effect.promise(() => Promise.resolve(next)), ); return { name, rename }; }), ); const View = rec(function* () { const names = yield* Names; const state = yield* names.rename.state; // reactive read — no hook in sight return createElement( 'button', { 'data-x': 'st', onClick: () => names.rename.run('Grace') }, `${state.status}:${state.data ?? ''}`, ); }); const root = createRoot(container); await act(async () => root.render(mount(NamesLive, View))); expect(container.querySelector('[data-x="st"]')?.textContent).toBe('idle:'); await act(async () => { container .querySelector('[data-x="st"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(container.querySelector('[data-x="st"]')?.textContent).toBe('success:Grace'); await act(async () => root.unmount()); }); });