/** * `QueryCache` as a built-in service (ADR 0008): invalidation and refetch are * driven from a *service method* through the Tag, against the real runtime-level * store — no hook in sight. Also pins the one documented limitation: an * *injected* `ManagedRuntime` is built outside effract, so it does not get the * cache merged in. */ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import { QueryCache, 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()); const click = (x: string): Promise => act(async () => { container .querySelector(`[data-x="${x}"]`) ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); /** A service whose *methods* drive the cache — where post-mutation reloads live. */ class Refresher extends Context.Service< Refresher, { readonly invalidate: (tag: unknown) => number; readonly refetch: (key: unknown) => number; } >()('test/Refresher') {} const RefresherLive = Layer.effect(Refresher)( Effect.gen(function* () { const cache = yield* QueryCache; // the built-in service — provided by mount return { invalidate: (tag: unknown) => cache.invalidate(tag), refetch: (key: unknown) => cache.refetch(key), }; }), ); describe('QueryCache service — invalidate/refetch from a service method (#41, #43)', () => { it('cache.invalidate(tag) background-refetches an active tagged query', async () => { const calls: string[] = []; let n = 0; const load = Effect.promise(() => { n += 1; calls.push(`v${n}`); return Promise.resolve(`v${n}`); }); const returned: number[] = []; const View = rec(function* () { const refresher = yield* Refresher; const v = yield* query(load, 'data', { tags: ['data'] }); return ( ); }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(RefresherLive, View))); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v1'); expect(calls).toEqual(['v1']); // Invalidate through the service → the active query revalidates (in the // background) → v2 — and the method reports how many entries matched. await click('v'); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v2'); expect(calls).toEqual(['v1', 'v2']); expect(returned).toEqual([1]); await act(async () => root.unmount()); }); it('cache.refetch(key) re-runs a completed query without a remount', async () => { let n = 0; const load = Effect.promise(() => { n += 1; return Promise.resolve(`v${n}`); }); const View = rec(function* () { const refresher = yield* Refresher; const v = yield* query(load, 'k'); return ( ); }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(RefresherLive, View))); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v1'); await click('v'); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v2'); await act(async () => root.unmount()); }); it('overlapping refetches: a late out-of-order response never clobbers the newer value', async () => { // Each run captures its own resolver, so the test can settle fetches out of // order. Labels are issue-order: v1 (initial), v2 (first refetch), v3 (second). const resolvers: Array<() => void> = []; let issued = 0; const load = Effect.promise( () => new Promise((res) => { const label = `v${(issued += 1)}`; resolvers.push(() => res(label)); }), ); const View = rec(function* () { const refresher = yield* Refresher; const v = yield* query(load, 'k'); return ( ); }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(RefresherLive, View))); await flush(); // let the initial fetch register its resolver await act(async () => resolvers[0]!()); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v1'); // Two refetches back to back — the second supersedes the first mid-flight. await click('v'); await flush(); await click('v'); await flush(); expect(resolvers).toHaveLength(3); // The newer fetch (v3) lands first; the older (v2) straggles in afterward. // Without a generation guard the stale straggler would win — assert it can't. await act(async () => resolvers[2]!()); await act(async () => resolvers[1]!()); await flush(); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('v3'); await act(async () => root.unmount()); }); it('a component may yield* QueryCache directly — the mount gate accepts it', async () => { const View = rec(function* () { const cache = yield* QueryCache; // Nothing cached under a fresh runtime — an honest zero, not an error. return {cache.invalidate('anything')}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, View))); expect(container.querySelector('[data-x="n"]')?.textContent).toBe('0'); await act(async () => root.unmount()); }); it('documented limitation: an injected runtime does not carry the cache', async () => { // A ManagedRuntime built *outside* effract cannot be retrofitted — the // service is merged only on the Layer path. Reading it then fails at // runtime: build with the Layer form if your services use the cache. const runtime = ManagedRuntime.make(Layer.empty); const View = rec(function* () { const cache = yield* QueryCache; return {cache.invalidate('x')}; }); const errors: unknown[] = []; const root = createRoot(container, { onUncaughtError: (error) => errors.push(error), }); try { await act(async () => root.render(mount(runtime, View))); } catch (error) { errors.push(error); } expect(errors.length).toBeGreaterThan(0); expect(errors.map(String).join('\n')).toContain('QueryCache'); await act(async () => root.unmount()); await runtime.dispose(); }); });