/** * @vitest-environment custom-vitest-environment.ts */ import { expect, vi, test, describe, beforeEach } from "vitest"; import { act, renderHook } from "@testing-library/react"; import React from "react"; import { anyApi, FunctionArgs, FunctionReference, FunctionReturnType, getFunctionName, makeFunctionReference, PaginationOptions, PaginationResult, } from "../server/index.js"; import { assert, Equals } from "../test/type_testing.js"; import { compareValues, convexToJson, Value } from "../values/index.js"; import { ConvexProvider, ConvexReactClient } from "./client.js"; import type { PaginatedQueryArgs, PaginatedQueryItem, PaginatedQueryReference, UsePaginatedQueryReturnType, } from "./use_paginated_query.js"; import type { UsePaginatedQueryObjectReturnType } from "./use_paginated_query2.js"; import { resetPaginationId, usePaginatedQuery, insertAtTop, insertAtPosition, } from "./use_paginated_query.js"; import { resetPaginationId as resetPaginatedId2, usePaginatedQuery_experimental, } from "./use_paginated_query2.js"; import { OptimisticLocalStore } from "../browser/index.js"; // address is expected not to exist const address = "https://127.0.0.1:30001"; type Props = { onError: (e: Error) => void; children: any }; class ErrorBoundary extends React.Component { state: { error: Error | undefined } = { error: undefined }; onError: (e: Error) => void; constructor(props: Props) { super(props); this.onError = props.onError; } componentDidCatch(error: Error) { this.onError(error); return { error }; } render() { if (this.state.error) { return this.state.error.toString(); } return this.props.children; } } describe.each([ { usePaginatedQuery: usePaginatedQuery, resetPaginationId, version: "hook-based logic" as const, }, { usePaginatedQuery: usePaginatedQuery_experimental, resetPaginationId: resetPaginatedId2, version: "client-based logic" as const, }, ] as { usePaginatedQuery: | typeof usePaginatedQuery | typeof usePaginatedQuery_experimental; resetPaginationId: typeof resetPaginationId; version: "hook-based logic" | "client-based logic"; }[])( "usePaginatedQuery $version", ({ usePaginatedQuery, version, resetPaginationId }) => { test.each([ { options: undefined, expectedError: "Error: `options.initialNumItems` must be a positive number. Received `undefined`.", }, { options: {}, expectedError: "Error: `options.initialNumItems` must be a positive number. Received `undefined`.", }, { options: { initialNumItems: -1 }, expectedError: "Error: `options.initialNumItems` must be a positive number. Received `-1`.", }, { options: { initialNumItems: "wrongType" }, expectedError: "Error: `options.initialNumItems` must be a positive number. Received `wrongType`.", }, ])( "Throws an error when options is $options", ({ options, expectedError }) => { const convexClient = new ConvexReactClient(address); let lastError: Error | undefined = undefined; function updateError(e: Error) { lastError = e; } const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); renderHook( () => usePaginatedQuery( makeFunctionReference<"query">("myQuery"), {}, // @ts-expect-error We're testing user programming errors options, ), { wrapper, }, ); expect(lastError).not.toBeUndefined(); expect(lastError!.toString()).toEqual(expectedError); }, ); function pushFirstPageError(client: ConvexReactClient, message: string) { act(() => { void client.mutation( anyApi.myMutation.default, {}, { optimisticUpdate: (localStore) => { localStore.setQuery( anyApi.myQuery.default, { paginationOpts: { numItems: 10, cursor: null, id: 1, }, }, new Error( message, ) as unknown as FunctionReturnType, ); }, }, ); }); } test("Positional form continues throwing on errors", () => { if (version === "client-based logic") { return; } const convexClient = new ConvexReactClient(address); resetPaginationId(); let lastError: Error | undefined; const wrapper = ({ children }: { children: React.ReactNode }) => ( (lastError = e)}> {children} ); renderHook( () => usePaginatedQuery( makeFunctionReference<"query">("myQuery"), {}, { initialNumItems: 10, }, ), { wrapper }, ); pushFirstPageError(convexClient, "boom-positional"); expect(lastError).toBeDefined(); expect(lastError?.message).toBe("boom-positional"); }); test("Returns nothing when args are 'skip'", () => { const convexClient = new ConvexReactClient(address); const watchQuerySpy = version === "hook-based logic" ? vi.spyOn(convexClient, "watchQuery") : vi.spyOn(convexClient, "watchPaginatedQuery"); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook( () => usePaginatedQuery(makeFunctionReference<"query">("myQuery"), "skip", { initialNumItems: 10, }), { wrapper }, ); expect(watchQuerySpy.mock.calls).toEqual([]); expect(result.current).toMatchObject({ isLoading: true, results: [], status: "LoadingFirstPage", }); }); test("Initially returns LoadingFirstPage", () => { const convexClient = new ConvexReactClient(address); const watchQuerySpy = vi.spyOn(convexClient, "watchQuery"); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook( () => usePaginatedQuery( makeFunctionReference<"query">("myQuery"), {}, { initialNumItems: 10 }, ), { wrapper }, ); if (version === "hook-based logic") { expect(watchQuerySpy.mock.calls[1]).toEqual([ makeFunctionReference("myQuery"), { paginationOpts: { cursor: null, id: expect.anything(), numItems: 10, }, }, { journal: undefined }, ]); } expect(result.current).toMatchObject({ isLoading: true, results: [], status: "LoadingFirstPage", }); }); test("Updates to a new query if query name or args change", () => { const convexClient = new ConvexReactClient(address); const watchQuerySpy = vi.spyOn(convexClient, "watchQuery"); let args: [ query: FunctionReference<"query">, args: Record, options: { initialNumItems: number }, ] = [makeFunctionReference("myQuery"), {}, { initialNumItems: 10 }]; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { rerender } = renderHook(() => usePaginatedQuery(...args), { wrapper, }); if (version === "hook-based logic") { // Starts with just the initial query. expect(watchQuerySpy.mock.calls.length).toBe(3); expect(watchQuerySpy.mock.calls[1]).toEqual([ makeFunctionReference("myQuery"), { paginationOpts: { cursor: null, id: expect.anything(), numItems: 10, }, }, { journal: undefined }, ]); } // If we change the query name, we get a new call. args = [ makeFunctionReference<"query">("myQuery2"), {}, { initialNumItems: 10 }, ]; rerender(); if (version === "hook-based logic") { expect(watchQuerySpy.mock.calls.length).toBe(6); expect(watchQuerySpy.mock.calls[4]).toEqual([ makeFunctionReference("myQuery2"), { paginationOpts: { cursor: null, id: expect.anything(), numItems: 10, }, }, { journal: undefined }, ]); } // If we add an arg, it also updates. args = [ makeFunctionReference("myQuery2"), { someArg: 123 }, { initialNumItems: 10 }, ]; rerender(); if (version === "hook-based logic") { expect(watchQuerySpy.mock.calls.length).toBe(9); expect(watchQuerySpy.mock.calls[7]).toEqual([ makeFunctionReference("myQuery2"), { paginationOpts: { cursor: null, id: expect.anything(), numItems: 10, }, someArg: 123, }, { journal: undefined }, ]); } // Updating to a new arg object that serializes the same thing doesn't increase // the all count. args = [ makeFunctionReference("myQuery2"), { someArg: 123 }, { initialNumItems: 10 }, ]; rerender(); if (version === "hook-based logic") { expect(watchQuerySpy.mock.calls.length).toBe(9); } }); describe("usePaginatedQuery pages", () => { let client: ConvexReactClient; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const query: FunctionReference<"query"> = makeFunctionReference("myQuery"); /** * How do you reach in and do these underlying queries? * And how in the old system did a paginated query know to use * these pages? * */ const mockPage = ( opts: PaginationOptions, retval: PaginationResult, ) => { act(() => { // Set a query result with an optimistic update. // The mutation doesn't go through because the client's websocket isn't // connected, so the optimistic update persists. void client.mutation( anyApi.myMutation.default, // TODO TOMHERE let's handle optimistic updates the old way: // you need to update the underlying one. // How should you be able to associate this query // with your paginated query? {}, { optimisticUpdate: (localStore) => { localStore.setQuery( anyApi.myQuery.default, { paginationOpts: { ...opts, id: 1 }, }, retval, ); }, }, ); }); }; beforeEach(() => { client = new ConvexReactClient(address); resetPaginationId(); }); test("loadMore", () => { const { result } = renderHook( () => usePaginatedQuery(query, {}, { initialNumItems: 1 }), { wrapper }, ); expect(result.current.status).toStrictEqual("LoadingFirstPage"); // What's happening here is that we're anticipating the queries our code will make. mockPage( { numItems: 1, cursor: null, }, { page: ["item1"], continueCursor: "abc", isDone: false, }, ); expect(result.current.status).toStrictEqual("CanLoadMore"); expect(result.current.results).toStrictEqual(["item1"]); mockPage( { numItems: 2, cursor: "abc", }, { page: ["item2"], continueCursor: "def", isDone: true, }, ); act(() => { result.current.loadMore(2); }); expect(result.current.status).toStrictEqual("Exhausted"); expect(result.current.results).toStrictEqual(["item1", "item2"]); }); test("single page updating", () => { const { result } = renderHook( () => usePaginatedQuery(query, {}, { initialNumItems: 1 }), { wrapper }, ); mockPage( { numItems: 1, cursor: null, }, { page: ["item1"], continueCursor: "abc", isDone: true, }, ); expect(result.current.status).toStrictEqual("Exhausted"); expect(result.current.results).toStrictEqual(["item1"]); mockPage( { numItems: 1, cursor: null, }, { page: ["item2", "item3"], continueCursor: "def", isDone: true, }, ); expect(result.current.status).toStrictEqual("Exhausted"); expect(result.current.results).toStrictEqual(["item2", "item3"]); }); test("page split", () => { const { result } = renderHook( () => usePaginatedQuery(query, {}, { initialNumItems: 1 }), { wrapper }, ); mockPage( { numItems: 1, cursor: null, }, { page: ["item1", "item2", "item3", "item4"], continueCursor: "abc", splitCursor: "mid", isDone: true, }, ); expect(result.current.status).toStrictEqual("Exhausted"); expect(result.current.results).toStrictEqual([ "item1", "item2", "item3", "item4", ]); mockPage( { numItems: 1, cursor: null, endCursor: "mid", }, { page: ["item1S", "item2S"], continueCursor: "mid", isDone: false, }, ); mockPage( { numItems: 1, cursor: "mid", endCursor: "abc", }, { page: ["item3S", "item4S"], continueCursor: "abc", isDone: true, }, ); expect(result.current.status).toStrictEqual("Exhausted"); expect(result.current.results).toStrictEqual([ "item1S", "item2S", "item3S", "item4S", ]); }); }); describe("PaginatedQueryArgs", () => { test("basic", () => { type MyQueryFunction = FunctionReference< "query", "public", { paginationOpts: PaginationOptions; property: string }, PaginationResult >; type Args = PaginatedQueryArgs; type ExpectedArgs = { property: string }; assert>(); }); }); describe("PaginatedQueryItem", () => { test("interface return type", () => { interface ReturnType { property: string; } type MyQueryFunction = FunctionReference< "query", "public", { paginationOpts: PaginationOptions; property: string }, PaginationResult >; type ActualReturnType = PaginatedQueryItem; assert>(); }); }); describe("UsePaginatedQueryObjectReturnType", () => { type MyQuery = FunctionReference< "query", "public", { paginationOpts: PaginationOptions }, PaginationResult >; test("object-form return type includes error variant", () => { type ObjResult = UsePaginatedQueryObjectReturnType; type ErrorVariant = Extract; assert< Equals< ErrorVariant, { data: string[]; status: "error"; canLoadMore: false; isLoading: false; error: Error; loadMore: (numItems: number) => void; } > >(); }); test("positional-form return type does NOT include error variant", () => { type PosResult = UsePaginatedQueryReturnType; type ErrorVariant = Extract; assert>(); }); test("object-form pending variant allows data during load-more", () => { type ObjResult = UsePaginatedQueryObjectReturnType; type PendingVariants = Extract; type PendingData = PendingVariants["data"]; assert>(); }); test("narrowing on status === 'error' gives access to error field", () => { type ObjResult = UsePaginatedQueryObjectReturnType; type NarrowedError = Extract["error"]; assert>(); }); test("success variant has canLoadMore as boolean", () => { type ObjResult = UsePaginatedQueryObjectReturnType; type SuccessVariant = Extract; assert< Equals< SuccessVariant, { data: string[]; status: "success"; canLoadMore: boolean; isLoading: false; error: undefined; loadMore: (numItems: number) => void; } > >(); }); }); class LocalQueryStoreFake implements OptimisticLocalStore { queries: Record< string, Record< string, { args: Record; value: undefined | Value } > > = {}; constructor() { this.queries = {}; } setQuery(query: FunctionReference<"query">, args: any, value: any) { const queriesByName = this.queries[getFunctionName(query)] ?? {}; this.queries[getFunctionName(query)] = queriesByName; const rawArgs = args ?? {}; const serializedArgs = JSON.stringify(convexToJson(rawArgs)); queriesByName[serializedArgs] = { args: rawArgs, value }; } getAllQueries>( query: Query, ): Array<{ args: FunctionArgs; value: undefined | FunctionReturnType; }> { return Object.values(this.queries[getFunctionName(query)] ?? {}).map( (q) => ({ args: q.args, value: q.value, }), ); } getQuery(query: FunctionReference<"query">, args: any) { const serializedArgs = JSON.stringify(convexToJson(args)); return this.queries[getFunctionName(query)]?.[serializedArgs]; } } function getPaginatedQueryResults< Query extends PaginatedQueryReference, >(options: { localQueryStore: LocalQueryStoreFake; query: Query; argsToMatch?: Partial>; }) { const { localQueryStore, query, argsToMatch } = options; const allQueries = localQueryStore.getAllQueries(query); const relevantQueries = allQueries.filter((q) => argsMatch({ args: q.args, ...(argsToMatch ? { argsToMatch } : {}) }), ); const loadedQueries: Array<{ args: FunctionArgs; value: FunctionReturnType; }> = []; for (const query of relevantQueries) { expect(query.value).toBeDefined(); loadedQueries.push({ args: query.args, value: query.value! }); } const firstPage = loadedQueries.find( (q) => q.args.paginationOpts.cursor === null, ); if (!firstPage) { return []; } const sortedResults = [...firstPage.value.page]; let currentCursor = firstPage.value.continueCursor; while (currentCursor !== null) { const nextPage = loadedQueries.find( (r) => r.args.paginationOpts.cursor === currentCursor, ); if (nextPage === undefined) { break; } sortedResults.push(...nextPage.value.page); if (nextPage.value.isDone) { break; } currentCursor = nextPage.value.continueCursor; } return sortedResults; } function argsMatch(options: { args: FunctionArgs; argsToMatch?: Partial>; }) { if (options.argsToMatch === undefined) { return true; } return Object.keys(options.argsToMatch).every((key) => { // @ts-expect-error xcxc return compareValues(options.args[key], options.argsToMatch[key]) === 0; }); } function setupPages(options: { localQueryStore: LocalQueryStoreFake; paginatedQuery: Query; args: PaginatedQueryArgs; pages: Array>>; isDone: boolean; }) { let currentCursor = null; for (let i = 0; i < options.pages.length; i++) { const page = options.pages[i]; const nextCursor = `cursor${i}`; options.localQueryStore.setQuery( options.paginatedQuery, { ...options.args, paginationOpts: { cursor: currentCursor, id: JSON.stringify(options.args), numItems: 10, }, }, { page, continueCursor: nextCursor, isDone: i === options.pages.length - 1 ? options.isDone : false, }, ); currentCursor = nextCursor; } } describe("insertAtTop", () => { test("does not insert if the query is not loaded", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; insertAtTop({ paginatedQuery, localQueryStore, item: { author: "Sarah", content: "Hello, world!" }, }); expect(localQueryStore.getAllQueries(paginatedQuery).length).toBe(0); }); test("inserts at top", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery: FunctionReference< "query", "public", { paginationOpts: PaginationOptions }, PaginationResult<{ author: string; content: string }> > = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: [ [ { author: "Alice", content: "Hello, world!" }, { author: "Bob", content: "Hello, world!" }, ], ], isDone: false, }); insertAtTop({ paginatedQuery, localQueryStore, item: { author: "Sarah", content: "Hello, world!" }, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Sarah", content: "Hello, world!" }, { author: "Alice", content: "Hello, world!" }, { author: "Bob", content: "Hello, world!" }, ]); }); test("inserts at top multiple pages", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: [ [ { author: "Alice", content: "Hello, world!" }, { author: "Bob", content: "Hello, world!" }, ], [ { author: "Charlie", content: "Hello, world!" }, { author: "Dave", content: "Hello, world!" }, ], ], isDone: false, }); insertAtTop({ paginatedQuery, localQueryStore, item: { author: "Sarah", content: "Hello, world!" }, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Sarah", content: "Hello, world!" }, { author: "Alice", content: "Hello, world!" }, { author: "Bob", content: "Hello, world!" }, { author: "Charlie", content: "Hello, world!" }, { author: "Dave", content: "Hello, world!" }, ]); }); test("respects filters", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: { channel: "general" }, pages: [ [ { author: "Alice", content: "Hello, world!" }, { author: "Bob", content: "Hello, world!" }, ], ], isDone: false, }); setupPages({ localQueryStore, paginatedQuery, args: { channel: "marketing" }, pages: [ [ { author: "Charlie", content: "Hello, world!" }, { author: "Dave", content: "Hello, world!" }, ], ], isDone: false, }); insertAtTop({ paginatedQuery, localQueryStore, argsToMatch: { channel: "general" }, item: { author: "Sarah", content: "Hello, world!" }, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, argsToMatch: { channel: "general" }, }); expect(sortedResults).toEqual([ { author: "Sarah", content: "Hello, world!" }, { author: "Alice", content: "Hello, world!" }, { author: "Bob", content: "Hello, world!" }, ]); }); }); describe("insertAtPosition", () => { const defaultPages = [ [ { author: "Dave", rank: 40 }, { author: "Charlie", rank: 30 }, ], [ { author: "Bob", rank: 20 }, { author: "Alice", rank: 10 }, ], ]; describe("descending", () => { test("inserts in middle", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: false, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 15 }, sortOrder: "desc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Dave", rank: 40 }, { author: "Charlie", rank: 30 }, { author: "Bob", rank: 20 }, { author: "Sarah", rank: 15 }, { author: "Alice", rank: 10 }, ]); }); test("inserts at top", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: false, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 55 }, sortOrder: "desc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Sarah", rank: 55 }, { author: "Dave", rank: 40 }, { author: "Charlie", rank: 30 }, { author: "Bob", rank: 20 }, { author: "Alice", rank: 10 }, ]); }); test("inserts at bottom if list is done", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: true, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 5 }, sortOrder: "desc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Dave", rank: 40 }, { author: "Charlie", rank: 30 }, { author: "Bob", rank: 20 }, { author: "Alice", rank: 10 }, { author: "Sarah", rank: 5 }, ]); }); test("does not insert at bottom if list is still loading", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: false, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 5 }, sortOrder: "desc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Dave", rank: 40 }, { author: "Charlie", rank: 30 }, { author: "Bob", rank: 20 }, { author: "Alice", rank: 10 }, ]); }); test("inserts on page boundary", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: false, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 29 }, sortOrder: "desc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Dave", rank: 40 }, { author: "Charlie", rank: 30 }, { author: "Sarah", rank: 29 }, { author: "Bob", rank: 20 }, { author: "Alice", rank: 10 }, ]); }); }); describe("ascending", () => { const defaultPages = [ [ { author: "Alice", rank: 10 }, { author: "Bob", rank: 20 }, ], [ { author: "Charlie", rank: 30 }, { author: "Dave", rank: 40 }, ], ]; test("inserts in middle", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: false, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 15 }, sortOrder: "asc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Alice", rank: 10 }, { author: "Sarah", rank: 15 }, { author: "Bob", rank: 20 }, { author: "Charlie", rank: 30 }, { author: "Dave", rank: 40 }, ]); }); test("inserts at top", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: false, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 5 }, sortOrder: "asc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Sarah", rank: 5 }, { author: "Alice", rank: 10 }, { author: "Bob", rank: 20 }, { author: "Charlie", rank: 30 }, { author: "Dave", rank: 40 }, ]); }); test("inserts at bottom if list is done", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: true, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 50 }, sortOrder: "asc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Alice", rank: 10 }, { author: "Bob", rank: 20 }, { author: "Charlie", rank: 30 }, { author: "Dave", rank: 40 }, { author: "Sarah", rank: 50 }, ]); }); test("does not insert at bottom if list is still loading", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: false, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 50 }, sortOrder: "asc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Alice", rank: 10 }, { author: "Bob", rank: 20 }, { author: "Charlie", rank: 30 }, { author: "Dave", rank: 40 }, ]); }); test("inserts on page boundary", () => { const localQueryStore = new LocalQueryStoreFake(); const paginatedQuery = anyApi.messages.list; setupPages({ localQueryStore, paginatedQuery, args: {}, pages: defaultPages, isDone: false, }); insertAtPosition({ paginatedQuery, localQueryStore, item: { author: "Sarah", rank: 21 }, sortOrder: "asc", sortKeyFromItem: (item) => item.rank, }); const sortedResults = getPaginatedQueryResults({ localQueryStore, query: paginatedQuery, }); expect(sortedResults).toEqual([ { author: "Alice", rank: 10 }, { author: "Bob", rank: 20 }, { author: "Sarah", rank: 21 }, { author: "Charlie", rank: 30 }, { author: "Dave", rank: 40 }, ]); }); }); }); }, ); describe("usePaginatedQuery_experimental object form", () => { test("Returns nothing when object-form args are 'skip'", () => { const convexClient = new ConvexReactClient(address); const subscriptionSpy = vi.spyOn(convexClient, "watchPaginatedQuery"); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook( () => usePaginatedQuery_experimental({ query: makeFunctionReference<"query">("myQuery"), args: "skip", initialNumItems: 10, }), { wrapper }, ); expect(subscriptionSpy.mock.calls).toEqual([]); expect(result.current).toMatchObject({ isLoading: true, data: undefined, status: "pending", canLoadMore: false, }); }); test("Initially returns pending for object options", () => { const convexClient = new ConvexReactClient(address); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook( () => usePaginatedQuery_experimental({ query: makeFunctionReference<"query">("myQuery"), args: {}, initialNumItems: 10, }), { wrapper }, ); expect(result.current).toMatchObject({ isLoading: true, data: undefined, status: "pending", canLoadMore: false, }); }); describe("object form with data", () => { let client: ConvexReactClient; const query: FunctionReference<"query"> = makeFunctionReference("myQuery"); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const mockPage = ( opts: PaginationOptions, retval: PaginationResult, ) => { act(() => { void client.mutation( anyApi.myMutation.default, {}, { optimisticUpdate: (localStore) => { localStore.setQuery( anyApi.myQuery.default, { paginationOpts: { ...opts, id: 1 }, }, retval, ); }, }, ); }); }; beforeEach(() => { client = new ConvexReactClient(address); resetPaginatedId2(); }); test("returns success with data for a single exhausted page", () => { const { result } = renderHook( () => usePaginatedQuery_experimental({ query, args: {}, initialNumItems: 5, }), { wrapper }, ); expect(result.current.status).toBe("pending"); mockPage( { numItems: 5, cursor: null }, { page: ["a", "b", "c"], continueCursor: "end", isDone: true }, ); expect(result.current).toMatchObject({ status: "success", data: ["a", "b", "c"], canLoadMore: false, isLoading: false, error: undefined, }); }); test("returns success with canLoadMore true when more pages exist", () => { const { result } = renderHook( () => usePaginatedQuery_experimental({ query, args: {}, initialNumItems: 2, }), { wrapper }, ); mockPage( { numItems: 2, cursor: null }, { page: ["item1", "item2"], continueCursor: "cur1", isDone: false }, ); expect(result.current).toMatchObject({ status: "success", data: ["item1", "item2"], canLoadMore: true, isLoading: false, error: undefined, }); }); test("loadMore appends data and transitions to exhausted", () => { const { result } = renderHook( () => usePaginatedQuery_experimental({ query, args: {}, initialNumItems: 1, }), { wrapper }, ); mockPage( { numItems: 1, cursor: null }, { page: ["first"], continueCursor: "abc", isDone: false }, ); expect(result.current).toMatchObject({ status: "success", data: ["first"], canLoadMore: true, }); mockPage( { numItems: 2, cursor: "abc" }, { page: ["second"], continueCursor: "def", isDone: true }, ); act(() => { result.current.loadMore(2); }); expect(result.current).toMatchObject({ status: "success", data: ["first", "second"], canLoadMore: false, isLoading: false, }); }); test("page updates reflect in data", () => { const { result } = renderHook( () => usePaginatedQuery_experimental({ query, args: {}, initialNumItems: 5, }), { wrapper }, ); mockPage( { numItems: 5, cursor: null }, { page: ["old"], continueCursor: "c1", isDone: true }, ); expect(result.current).toMatchObject({ status: "success", data: ["old"], }); mockPage( { numItems: 5, cursor: null }, { page: ["new1", "new2"], continueCursor: "c2", isDone: true }, ); expect(result.current).toMatchObject({ status: "success", data: ["new1", "new2"], canLoadMore: false, }); }); }); });