// @vitest-environment jsdom // // real nested-provider tests for combineZeroClients: zero-react's useQuery // resolves its zero instance from the NEAREST ZeroProvider context, so under // nested providers an outer instance's queries must NOT ride the context // path — they must materialize directly on the owning instance. import { createSchema, string, table } from '@rocicorp/zero' import { useZero } from '@rocicorp/zero/react' import { act, StrictMode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, test } from 'vitest' import { createZeroClient } from './createZeroClient' import { combineZeroClients, createZeroClientWithDirectQueries } from './multi' import { zql } from './zql' import type { MutatorContext } from './types' import type { ReactNode } from 'react' declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined } globalThis.IS_REACT_ACT_ENVIRONMENT = true const userTable = table('user').columns({ id: string(), name: string() }).primaryKey('id') const taskTable = table('task') .columns({ id: string(), title: string() }) .primaryKey('id') const schema = createSchema({ tables: [userTable, taskTable] }) type UserRow = { id: string; name: string } // control = OUTER/primary instance — owns the 'user' model namespace and the // 'ctlUser' query namespace const controlModels = { user: { mutate: { seed: async (ctx: MutatorContext, row?: UserRow) => { await (ctx.tx.mutate as any).user.upsert(row) }, }, }, } const ctlUserById = (args: { id: string }) => (zql as any).user.where('id', args.id).one() const control = createZeroClientWithDirectQueries({ schema, models: controlModels, groupedQueries: { ctlUser: { byId: ctlUserById } }, instanceName: 'nested-control', }) // project = INNER instance — its own store. its seed mutator writes the same // user TABLE (tables are physical, only namespaces are claims) so the two // stores can hold conflicting rows for the same id. const projectModels = { prjSeed: { mutate: { seedUser: async (ctx: MutatorContext, row?: UserRow) => { await (ctx.tx.mutate as any).user.upsert(row) }, }, }, } const prjTaskById = (args: { id: string }) => (zql as any).task.where('id', args.id).one() const project = createZeroClient({ schema, models: projectModels, groupedQueries: { prjTask: { byId: prjTaskById } }, instanceName: 'nested-project', }) // default contract: last argument = inner provider const combined = combineZeroClients(control, project) let root: Root let container: HTMLElement beforeEach(() => { resetProbe() container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) }) afterEach(async () => { await act(async () => root.unmount()) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) }) container.remove() }) const render = (ui: ReactNode) => act(async () => root.render(ui)) async function waitFor(condition: () => boolean, what: string) { for (let i = 0; i < 200; i++) { if (condition()) return await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)) }) } throw new Error(`timed out waiting for ${what}`) } const probe: { data: UserRow | undefined; renders: number } = { data: undefined, renders: 0, } const materializeProbe: { materializeTtls: unknown[] updateTtls: unknown[] destroys: number } = { materializeTtls: [], updateTtls: [], destroys: 0, } // reset via helper so the assignments don't narrow probe.data to undefined // in test scope function resetProbe() { probe.data = undefined probe.renders = 0 materializeProbe.materializeTtls = [] materializeProbe.updateTtls = [] materializeProbe.destroys = 0 } function ControlUserProbe({ id, ttl }: { id: string; ttl?: number }) { const [data] = ttl === undefined ? (combined.useQuery as any)(ctlUserById, { id }) : (combined.useQuery as any)(ctlUserById, { id }, { ttl }) probe.data = data ?? undefined probe.renders++ return null } function MaterializeProbe() { const zero = useZero() as any // ProvideZero hands children a stub Zero (no .materialize) when disable=true. // skip on the stub render. if (typeof zero.materialize !== 'function') return null if (!zero.__onZeroMaterializeProbe) { zero.__onZeroMaterializeProbe = true const materialize = zero.materialize.bind(zero) zero.materialize = (...args: any[]) => { materializeProbe.materializeTtls.push(args[1]?.ttl) const view = materialize(...args) const updateTTL = view.updateTTL.bind(view) view.updateTTL = (ttl: unknown) => { materializeProbe.updateTtls.push(ttl) return updateTTL(ttl) } const destroy = view.destroy.bind(view) view.destroy = () => { materializeProbe.destroys++ return destroy() } return view } } return null } const seedControl = async (row: UserRow) => { await act(async () => { await (control.zero.mutate as any).user.seed(row).client }) } const seedProject = async (row: UserRow) => { await act(async () => { await (project.zero.mutate as any).prjSeed.seedUser(row).client }) } test('outer-instance query reads the OUTER store under nested providers', async () => { await render( ) await waitFor(() => probe.renders > 0, 'probe mount') // same row id, conflicting values per store — whichever store the view // materialized against decides the rendered name await seedControl({ id: 'u1', name: 'from-control' }) await seedProject({ id: 'u1', name: 'from-project' }) await waitFor(() => probe.data !== undefined, 'control row visible') expect(probe.data?.name).toBe('from-control') }) test('inner provider unmount/remount does not break outer-instance subscriptions', async () => { const App = ({ showInner }: { showInner: boolean }) => ( {showInner ? ( ) : ( )} ) await render() await waitFor(() => probe.renders > 0, 'probe mount') await seedControl({ id: 'u2', name: 'v1' }) await waitFor(() => probe.data?.name === 'v1', 'v1 visible') // unmount the inner provider (probe remounts outside it) await render() await seedControl({ id: 'u2', name: 'v2' }) await waitFor(() => probe.data?.name === 'v2', 'v2 visible after inner unmount') // remount the inner provider await render() await seedControl({ id: 'u2', name: 'v3' }) await waitFor(() => probe.data?.name === 'v3', 'v3 visible after inner remount') }) test('StrictMode: direct views survive the effect double-invoke', async () => { // StrictMode (and any suspense hide/reveal) runs effect cleanup + re-setup // with unchanged deps: the cleanup destroys the materialized view while the // memoized store identity survives, so without re-materialization on // resubscribe the snapshot freezes at its mount-time value forever — the // "control queries strand at result type unknown" bug (soot 3e6ecd10a) await render( ) await waitFor(() => probe.renders > 0, 'probe mount') await seedControl({ id: 'u4', name: 'strict-v1' }) await waitFor(() => probe.data?.name === 'strict-v1', 'update visible under StrictMode') await seedControl({ id: 'u4', name: 'strict-v2' }) await waitFor(() => probe.data?.name === 'strict-v2', 'second update still live') }) test('direct-path subscribers share one materialized view', async () => { const App = ({ showSubscribers }: { showSubscribers: boolean }) => ( {showSubscribers ? ( <> ) : null} ) await render() await waitFor(() => probe.renders >= 2, 'both probes mount') expect(materializeProbe.materializeTtls).toEqual([1000]) expect(materializeProbe.updateTtls).toEqual([2000]) await seedControl({ id: 'u5', name: 'shared-v1' }) await waitFor(() => probe.data?.name === 'shared-v1', 'shared view update visible') expect(materializeProbe.materializeTtls).toEqual([1000]) await render() await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) }) expect(materializeProbe.destroys).toBe(1) }) test('direct-path views re-materialize when the owning instance rotates', async () => { const App = ({ userID }: { userID: string }) => ( ) await render() await waitFor(() => probe.renders > 0, 'probe mount') await seedControl({ id: 'u3', name: 'before-rotation' }) await waitFor(() => probe.data?.name === 'before-rotation', 'pre-rotation row') // identity change rotates the mounted zero instance (new client group, // fresh store) via the instanceKey change await render() await waitFor(() => probe.data === undefined, 'view reset to the new empty store') await seedControl({ id: 'u3', name: 'after-rotation' }) await waitFor(() => probe.data?.name === 'after-rotation', 'post-rotation row') })