From 682ca9c3e89b7d807038816f9f0224ac484b044b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:19:29 +0000 Subject: [PATCH] test: consolidate duplicated live-query test harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reactivity and unmount test suites each rebuilt the same live-query scaffold (BehaviorSubject pair + filter/switchMap/map queryFn + data span component + render) in every test — 10 copies across the two files. Extract the source into createLiveQuerySource in tests/liveQuery and collapse the per-test scaffolding into one setup helper per file. --- src/lib/queries/useQuery$.reactivity.test.tsx | 195 +++++------------- src/lib/queries/useQuery$.unmount.test.tsx | 194 +++++------------ src/tests/liveQuery.tsx | 21 ++ 3 files changed, 124 insertions(+), 286 deletions(-) diff --git a/src/lib/queries/useQuery$.reactivity.test.tsx b/src/lib/queries/useQuery$.reactivity.test.tsx index 20af7b1..ad7b5f5 100644 --- a/src/lib/queries/useQuery$.reactivity.test.tsx +++ b/src/lib/queries/useQuery$.reactivity.test.tsx @@ -1,111 +1,89 @@ +import type { NetworkMode } from "@tanstack/react-query" import { act, render, screen } from "@testing-library/react" -import { BehaviorSubject, filter, map, switchMap } from "rxjs" import { describe, expect, it } from "vitest" import { + createLiveQuerySource, createQueryClient, createWrapper, liveQueryOptions, } from "../../tests/liveQuery" import { waitForTimeout } from "../../tests/utils" -import { isDefined } from "../utils/isDefined" import { useQuery$ } from "./useQuery$" -describe("useQuery$ live-query reactivity", () => { - it("re-renders when a new item is added", async () => { - const liveQuery$ = new BehaviorSubject(["a", "b"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() +function setup( + queryKey: string[], + initialItems: string[], + queryOptions: { + networkMode?: NetworkMode + gcTime?: number + staleTime?: number + } = liveQueryOptions, +) { + const { liveQuery$, queryFn } = createLiveQuerySource(initialItems) + const queryClient = createQueryClient() + + function Comp() { + const { data } = useQuery$({ + ...queryOptions, + queryKey, + queryFn, + }) - function Comp() { - const { data } = useQuery$({ - ...liveQueryOptions, - queryKey: ["live", "add"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) + return {JSON.stringify(data)} + } - return {JSON.stringify(data)} - } + render(, { wrapper: createWrapper(queryClient) }) - render(, { wrapper: createWrapper(queryClient) }) + return { liveQuery$, queryClient } +} + +const expectData = (items: string[]) => + expect(screen.getByTestId("data").textContent).toBe(JSON.stringify(items)) + +describe("useQuery$ live-query reactivity", () => { + it("re-renders when a new item is added", async () => { + const { liveQuery$ } = setup(["live", "add"], ["a", "b"]) await act(async () => { await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b"]), - ) + expectData(["a", "b"]) await act(async () => { liveQuery$.next(["a", "b", "c"]) await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c"]), - ) + expectData(["a", "b", "c"]) }) it("re-renders when an item is removed", async () => { - const liveQuery$ = new BehaviorSubject(["a", "b", "c"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() - - function Comp() { - const { data } = useQuery$({ - ...liveQueryOptions, - queryKey: ["live", "remove"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) - - return {JSON.stringify(data)} - } - - render(, { wrapper: createWrapper(queryClient) }) + const { liveQuery$ } = setup(["live", "remove"], ["a", "b", "c"]) await act(async () => { await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c"]), - ) + expectData(["a", "b", "c"]) await act(async () => { liveQuery$.next(["a", "c"]) await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "c"]), - ) + expectData(["a", "c"]) }) it("re-renders with two observers on the same key", async () => { - const liveQuery$ = new BehaviorSubject(["a", "b"]) - const db$ = new BehaviorSubject({}) + const { liveQuery$, queryFn } = createLiveQuerySource(["a", "b"]) const queryClient = createQueryClient() function useLive() { return useQuery$({ ...liveQueryOptions, queryKey: ["live", "double"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), + queryFn, }) } @@ -122,47 +100,24 @@ describe("useQuery$ live-query reactivity", () => { await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b"]), - ) + expectData(["a", "b"]) await act(async () => { liveQuery$.next(["a", "b", "c"]) await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c"]), - ) + expectData(["a", "b", "c"]) }) it("settles on the latest value after rapid emissions", async () => { - const liveQuery$ = new BehaviorSubject(["a"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() - - function Comp() { - const { data } = useQuery$({ - ...liveQueryOptions, - queryKey: ["live", "rapid"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) - - return {JSON.stringify(data)} - } - - render(, { wrapper: createWrapper(queryClient) }) + const { liveQuery$ } = setup(["live", "rapid"], ["a"]) await act(async () => { await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe(JSON.stringify(["a"])) + expectData(["a"]) await act(async () => { liveQuery$.next(["a", "b"]) @@ -171,33 +126,15 @@ describe("useQuery$ live-query reactivity", () => { await waitForTimeout(300) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c", "d"]), - ) + expectData(["a", "b", "c", "d"]) }) it("survives an external refetch racing with the internal loop", async () => { - const liveQuery$ = new BehaviorSubject(["a", "b"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() - - function Comp() { - const { data } = useQuery$({ - networkMode: "always", - gcTime: 0, - queryKey: ["live", "external-refetch"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) - - return {JSON.stringify(data)} - } - - render(, { wrapper: createWrapper(queryClient) }) + const { liveQuery$, queryClient } = setup( + ["live", "external-refetch"], + ["a", "b"], + { networkMode: "always", gcTime: 0 }, + ) await act(async () => { await waitForTimeout(100) @@ -216,33 +153,15 @@ describe("useQuery$ live-query reactivity", () => { await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c"]), - ) + expectData(["a", "b", "c"]) }) it("survives invalidateQueries followed by a data change", async () => { - const liveQuery$ = new BehaviorSubject(["a", "b"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() - - function Comp() { - const { data } = useQuery$({ - networkMode: "always", - gcTime: 0, - queryKey: ["live", "invalidate"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) - - return {JSON.stringify(data)} - } - - render(, { wrapper: createWrapper(queryClient) }) + const { liveQuery$, queryClient } = setup( + ["live", "invalidate"], + ["a", "b"], + { networkMode: "always", gcTime: 0 }, + ) await act(async () => { await waitForTimeout(100) @@ -261,8 +180,6 @@ describe("useQuery$ live-query reactivity", () => { await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c"]), - ) + expectData(["a", "b", "c"]) }) }) diff --git a/src/lib/queries/useQuery$.unmount.test.tsx b/src/lib/queries/useQuery$.unmount.test.tsx index d07d9ac..66baf24 100644 --- a/src/lib/queries/useQuery$.unmount.test.tsx +++ b/src/lib/queries/useQuery$.unmount.test.tsx @@ -1,57 +1,57 @@ import { act, render, screen } from "@testing-library/react" import { useState } from "react" -import { BehaviorSubject, filter, map, switchMap } from "rxjs" import { describe, expect, it } from "vitest" import { + createLiveQuerySource, createQueryClient, createWrapper, liveQueryOptions, } from "../../tests/liveQuery" import { waitForTimeout } from "../../tests/utils" -import { isDefined } from "../utils/isDefined" import { useQuery$ } from "./useQuery$" -describe("useQuery$ unmount / remount", () => { - it("stays reactive after hide/show then adding an item", { - timeout: 3000, - }, async () => { - const liveQuery$ = new BehaviorSubject(["a", "b"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() - - function List() { - const { data } = useQuery$({ - ...liveQueryOptions, - queryKey: ["unmount", "add"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) +function setup(queryKey: string[], initialItems: string[]) { + const { liveQuery$, queryFn } = createLiveQuerySource(initialItems) + const queryClient = createQueryClient() - return {JSON.stringify(data)} - } + function List() { + const { data } = useQuery$({ + ...liveQueryOptions, + queryKey, + queryFn, + }) - let toggle: () => void + return {JSON.stringify(data)} + } - function Host() { - const [visible, setVisible] = useState(true) - toggle = () => setVisible((v) => !v) + let toggle = () => {} - return visible ? : - } + function Host() { + const [visible, setVisible] = useState(true) + toggle = () => setVisible((v) => !v) + + return visible ? : + } + + render(, { wrapper: createWrapper(queryClient) }) - render(, { wrapper: createWrapper(queryClient) }) + return { liveQuery$, toggle: () => toggle() } +} + +const expectData = (items: string[]) => + expect(screen.getByTestId("data").textContent).toBe(JSON.stringify(items)) + +describe("useQuery$ unmount / remount", () => { + it("stays reactive after hide/show then adding an item", { + timeout: 3000, + }, async () => { + const { liveQuery$, toggle } = setup(["unmount", "add"], ["a", "b"]) await act(async () => { await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b"]), - ) + expectData(["a", "b"]) await act(async () => { toggle() @@ -59,60 +59,26 @@ describe("useQuery$ unmount / remount", () => { await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b"]), - ) + expectData(["a", "b"]) await act(async () => { liveQuery$.next(["a", "b", "c"]) await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c"]), - ) + expectData(["a", "b", "c"]) }) it("stays reactive after hide/show then removing an item", { timeout: 3000, }, async () => { - const liveQuery$ = new BehaviorSubject(["a", "b", "c"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() - - function List() { - const { data } = useQuery$({ - ...liveQueryOptions, - queryKey: ["unmount", "remove"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) - - return {JSON.stringify(data)} - } - - let toggle: () => void - - function Host() { - const [visible, setVisible] = useState(true) - toggle = () => setVisible((v) => !v) - - return visible ? : - } - - render(, { wrapper: createWrapper(queryClient) }) + const { liveQuery$, toggle } = setup(["unmount", "remove"], ["a", "b", "c"]) await act(async () => { await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c"]), - ) + expectData(["a", "b", "c"]) await act(async () => { toggle() @@ -125,49 +91,19 @@ describe("useQuery$ unmount / remount", () => { await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "c"]), - ) + expectData(["a", "c"]) }) it("stays reactive after multiple hide/show cycles", { timeout: 5000, }, async () => { - const liveQuery$ = new BehaviorSubject(["a"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() - - function List() { - const { data } = useQuery$({ - ...liveQueryOptions, - queryKey: ["unmount", "multi"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) - - return {JSON.stringify(data)} - } - - let toggle: () => void - - function Host() { - const [visible, setVisible] = useState(true) - toggle = () => setVisible((v) => !v) - - return visible ? : - } - - render(, { wrapper: createWrapper(queryClient) }) + const { liveQuery$, toggle } = setup(["unmount", "multi"], ["a"]) await act(async () => { await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe(JSON.stringify(["a"])) + expectData(["a"]) for (let i = 0; i < 5; i++) { await act(async () => { @@ -177,16 +113,14 @@ describe("useQuery$ unmount / remount", () => { }) } - expect(screen.getByTestId("data").textContent).toBe(JSON.stringify(["a"])) + expectData(["a"]) await act(async () => { liveQuery$.next(["a", "b"]) await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b"]), - ) + expectData(["a", "b"]) }) /** @@ -201,50 +135,20 @@ describe("useQuery$ unmount / remount", () => { it("does not create orphaned subscriptions when refetch races with hide/show", { timeout: 3000, }, async () => { - const liveQuery$ = new BehaviorSubject(["a"]) - const db$ = new BehaviorSubject({}) - const queryClient = createQueryClient() - - function List() { - const { data } = useQuery$({ - ...liveQueryOptions, - queryKey: ["unmount", "stale-guard"], - queryFn: () => - db$.pipe( - filter(isDefined), - switchMap(() => liveQuery$), - map((items) => [...items]), - ), - }) - - return {JSON.stringify(data)} - } - - let toggle: () => void - - function Host() { - const [visible, setVisible] = useState(true) - toggle = () => setVisible((v) => !v) - - return visible ? : - } - - render(, { wrapper: createWrapper(queryClient) }) + const { liveQuery$, toggle } = setup(["unmount", "stale-guard"], ["a"]) await act(async () => { await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe(JSON.stringify(["a"])) + expectData(["a"]) await act(async () => { liveQuery$.next(["a", "b"]) await waitForTimeout(100) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b"]), - ) + expectData(["a", "b"]) liveQuery$.next(["a", "b", "c"]) @@ -254,17 +158,13 @@ describe("useQuery$ unmount / remount", () => { await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c"]), - ) + expectData(["a", "b", "c"]) await act(async () => { liveQuery$.next(["a", "b", "c", "d"]) await waitForTimeout(200) }) - expect(screen.getByTestId("data").textContent).toBe( - JSON.stringify(["a", "b", "c", "d"]), - ) + expectData(["a", "b", "c", "d"]) }) }) diff --git a/src/tests/liveQuery.tsx b/src/tests/liveQuery.tsx index 5f1b27a..9d98cb1 100644 --- a/src/tests/liveQuery.tsx +++ b/src/tests/liveQuery.tsx @@ -1,6 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import type React from "react" +import { BehaviorSubject, filter, map, switchMap } from "rxjs" import { QueryClientProvider$ } from "../lib/queries/QueryClientProvider$" +import { isDefined } from "../lib/utils/isDefined" export function createQueryClient() { return new QueryClient({ @@ -18,6 +20,25 @@ export const liveQueryOptions = { staleTime: Number.POSITIVE_INFINITY, } +/** + * Emulates a live query backed by a database: `queryFn` waits for the db to + * be ready then emits a fresh copy of the current items whenever `liveQuery$` + * pushes a new list. + */ +export function createLiveQuerySource(initialItems: string[]) { + const liveQuery$ = new BehaviorSubject(initialItems) + const db$ = new BehaviorSubject({}) + + const queryFn = () => + db$.pipe( + filter(isDefined), + switchMap(() => liveQuery$), + map((items) => [...items]), + ) + + return { liveQuery$, queryFn } +} + export function createWrapper(queryClient: QueryClient) { return function Wrapper({ children }: { children: React.ReactNode }) { return (