/** * The list shape: N instances of the *same* REC, each with its own query key — * a list of cards, one query per row. Every entry must fetch exactly once, * stay live in the cache while its reader is mounted, and be reachable by * `QueryCache.invalidate`/`refetch`. Pins the slot bookkeeping for sibling * instances (one REC descriptor, many fibers), which the single-instance and * two-distinct-RECs tests cannot see. */ import { act, type ReactNode } 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 { mount, query, rec } from '../../index.client.ts'; import { QueryCache } from '#infrastructure/query-cache.ts'; import { inspectStore } from '#infrastructure/react/suspensable-store.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('query — a list of instances of one REC, one key per row', () => { it('fetches once per key, keeps every row live, and invalidates all of them', async () => { const calls: number[] = []; let generation = 0; const load = (id: number): Effect.Effect => Effect.promise(() => { calls.push(id); return Promise.resolve(`row-${id}@${generation}`); }); class Reloader extends Context.Service()('test/Reloader', { make: Effect.gen(function* () { const cache = yield* QueryCache; return { reloadAll: () => cache.invalidate('rows'), refetchOne: (k: number) => cache.refetch(k), }; }), }) { static readonly layer = Layer.effect(Reloader, Reloader.make); } let reloadAll: (() => number) | undefined; let refetchOne: ((k: number) => number) | undefined; const Row = rec(function* (props: { k: number }) { const value = yield* query(load(props.k), props.k, { tags: ['rows'] }); return {value}; }); const Page = rec(function* () { const reloader = yield* Reloader; reloadAll = reloader.reloadAll; refetchOne = reloader.refetchOne; const rows: ReactNode[] = []; for (let k = 0; k < 5; k += 1) { rows.push(yield* Row.with({ k })); } return
{rows}
; }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Reloader.layer, Page))); await flush(); // Every row rendered its own value, from exactly one fetch per key. expect(container.textContent).toBe('row-0@0row-1@0row-2@0row-3@0row-4@0'); expect(calls.toSorted()).toEqual([0, 1, 2, 3, 4]); // All five entries are live in the cache, claimed by their mounted readers. expect(inspectStore().filter((entry) => entry.tags.includes('rows'))).toHaveLength(5); // Invalidating the shared tag reloads all five. generation = 1; let matched = 0; await act(async () => { matched = reloadAll?.() ?? 0; }); await flush(); expect(matched).toBe(5); expect(container.textContent).toBe('row-0@1row-1@1row-2@1row-3@1row-4@1'); // Refetching one key reloads exactly that row. generation = 2; let refetched = 0; await act(async () => { refetched = refetchOne?.(3) ?? 0; }); await flush(); expect(refetched).toBe(1); expect(container.textContent).toBe('row-0@1row-1@1row-2@1row-3@2row-4@1'); await act(async () => root.unmount()); }); });