diff --git a/packages/tanstack-react-query/src/hooks/usePowerSyncQueries.ts b/packages/tanstack-react-query/src/hooks/usePowerSyncQueries.ts new file mode 100644 index 000000000..29e7cd51d --- /dev/null +++ b/packages/tanstack-react-query/src/hooks/usePowerSyncQueries.ts @@ -0,0 +1,192 @@ +import { type CompilableQuery, parseQuery } from '@powersync/common'; +import { usePowerSync } from '@powersync/react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; +import * as Tanstack from '@tanstack/react-query'; + +export type UsePowerSyncQueriesInput = { + query?: string | CompilableQuery; + parameters?: unknown[]; + queryKey: Tanstack.QueryKey; +}[]; + +export type UsePowerSyncQueriesOutput = { + sqlStatement: string; + queryParameters: unknown[]; + tables: string[]; + error?: Error; + queryFn: () => Promise; +}[]; + +export function usePowerSyncQueries( + queries: UsePowerSyncQueriesInput, + queryClient: Tanstack.QueryClient +): UsePowerSyncQueriesOutput { + const powerSync = usePowerSync(); + + const [tablesArr, setTablesArr] = useState(() => queries.map(() => [])); + const [errorsArr, setErrorsArr] = useState<(Error | undefined)[]>(() => queries.map(() => undefined)); + + const updateTablesArr = useCallback((tables: string[], idx: number) => { + setTablesArr((prev) => { + if (JSON.stringify(prev[idx]) === JSON.stringify(tables)) return prev; + const next = [...prev]; + next[idx] = tables; + return next; + }); + }, []); + + const updateErrorsArr = useCallback((error: Error | undefined, idx: number) => { + setErrorsArr((prev) => { + if (prev[idx]?.message === error?.message) return prev; + const next = [...prev]; + next[idx] = error; + return next; + }); + }, []); + + const parsedQueries = useMemo( + () => + queries.map((queryInput) => { + const { query, parameters = [], queryKey } = queryInput; + + if (!query) { + return { + query, + parameters, + queryKey, + sqlStatement: '', + queryParameters: [], + parseError: undefined + }; + } + + try { + const parsed = parseQuery(query, parameters); + return { + query, + parameters, + queryKey, + sqlStatement: parsed.sqlStatement, + queryParameters: parsed.parameters, + parseError: undefined + }; + } catch (e) { + return { + query, + parameters, + queryKey, + sqlStatement: '', + queryParameters: [], + parseError: e as Error + }; + } + }), + [queries] + ); + + useEffect(() => { + parsedQueries.forEach((pq, idx) => { + if (pq.parseError) { + updateErrorsArr(pq.parseError, idx); + } + }); + }, [parsedQueries, updateErrorsArr]); + + const stringifiedQueriesDeps = JSON.stringify( + parsedQueries.map((q) => ({ + sql: q.sqlStatement, + params: q.queryParameters + })) + ); + + useEffect(() => { + const listeners = parsedQueries.map((pq, idx) => { + if (pq.parseError || !pq.query) { + return null; + } + + (async () => { + try { + const tables = await powerSync.resolveTables(pq.sqlStatement, pq.queryParameters); + updateTablesArr(tables, idx); + } catch (e) { + updateErrorsArr(e as Error, idx); + } + })(); + + return powerSync.registerListener({ + schemaChanged: async () => { + try { + const tables = await powerSync.resolveTables(pq.sqlStatement, pq.queryParameters); + updateTablesArr(tables, idx); + queryClient.invalidateQueries({ queryKey: pq.queryKey }); + } catch (e) { + updateErrorsArr(e as Error, idx); + } + } + }); + }); + + return () => { + listeners.forEach((l) => l?.()); + }; + }, [powerSync, queryClient, stringifiedQueriesDeps, updateTablesArr, updateErrorsArr]); + + const stringifiedQueryKeys = JSON.stringify(parsedQueries.map((q) => q.queryKey)); + + useEffect(() => { + const aborts = parsedQueries.map((pq, idx) => { + if (pq.parseError || !pq.query) { + return null; + } + + const abort = new AbortController(); + + powerSync.onChangeWithCallback( + { + onChange: () => { + queryClient.invalidateQueries({ queryKey: pq.queryKey }); + }, + onError: (e) => { + updateErrorsArr(e, idx); + } + }, + { + tables: tablesArr[idx], + signal: abort.signal + } + ); + + return abort; + }); + + return () => aborts.forEach((a) => a?.abort()); + }, [powerSync, queryClient, tablesArr, updateErrorsArr, stringifiedQueryKeys]); + + return useMemo(() => { + return parsedQueries.map((pq, idx) => { + const error = errorsArr[idx] || pq.parseError; + + const queryFn = async () => { + if (error) throw error; + if (!pq.query) throw new Error('No query provided'); + + try { + return typeof pq.query === 'string' + ? await powerSync.getAll(pq.sqlStatement, pq.queryParameters) + : await pq.query.execute(); + } catch (e) { + throw e; + } + }; + + return { + sqlStatement: pq.sqlStatement, + queryParameters: pq.queryParameters, + tables: tablesArr[idx], + error, + queryFn + }; + }); + }, [parsedQueries, errorsArr, tablesArr, powerSync]); +} diff --git a/packages/tanstack-react-query/src/hooks/useQueries.ts b/packages/tanstack-react-query/src/hooks/useQueries.ts new file mode 100644 index 000000000..1512e099a --- /dev/null +++ b/packages/tanstack-react-query/src/hooks/useQueries.ts @@ -0,0 +1,127 @@ +import { type CompilableQuery } from '@powersync/common'; +import { usePowerSync } from '@powersync/react'; +import * as Tanstack from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { usePowerSyncQueries } from './usePowerSyncQueries'; + +export type PowerSyncQueryOptions = { + query?: string | CompilableQuery; + parameters?: any[]; +}; + +export type PowerSyncQueryOption = Tanstack.UseQueryOptions & PowerSyncQueryOptions; + +export type InferQueryResults = { + [K in keyof TQueries]: TQueries[K] extends { query: CompilableQuery } + ? Tanstack.UseQueryResult + : Tanstack.UseQueryResult; +}; + +export type ExplicitQueryResults = { + [K in keyof T]: Tanstack.UseQueryResult; +}; + +export type EnhancedInferQueryResults = { + [K in keyof TQueries]: TQueries[K] extends { query: CompilableQuery } + ? Tanstack.UseQueryResult & { queryKey: Tanstack.QueryKey } + : Tanstack.UseQueryResult & { queryKey: Tanstack.QueryKey }; +}; + +export type EnhancedExplicitQueryResults = { + [K in keyof T]: Tanstack.UseQueryResult & { queryKey: Tanstack.QueryKey }; +}; + +// Explicit generic typing with combine +export function useQueries( + options: { + queries: readonly [...{ [K in keyof T]: PowerSyncQueryOption }]; + combine: (results: EnhancedExplicitQueryResults) => TCombined; + }, + queryClient?: Tanstack.QueryClient +): TCombined; + +// Explicit generic typing without combine +export function useQueries( + options: { + queries: readonly [...{ [K in keyof T]: PowerSyncQueryOption }]; + combine?: undefined; + }, + queryClient?: Tanstack.QueryClient +): ExplicitQueryResults; + +// Auto inference with combine +export function useQueries( + options: { + queries: readonly [...TQueries]; + combine: (results: EnhancedInferQueryResults) => TCombined; + }, + queryClient?: Tanstack.QueryClient +): TCombined; + +// Auto inference without combine +export function useQueries( + options: { + queries: readonly [...TQueries]; + combine?: undefined; + }, + queryClient?: Tanstack.QueryClient +): InferQueryResults; + +// Implementation +export function useQueries( + options: { + queries: readonly (Tanstack.UseQueryOptions & PowerSyncQueryOptions)[]; + combine?: (results: (Tanstack.UseQueryResult & { queryKey: Tanstack.QueryKey })[]) => unknown; + }, + queryClient: Tanstack.QueryClient = Tanstack.useQueryClient() +) { + const powerSync = usePowerSync(); + + if (!powerSync) { + throw new Error('PowerSync is not available'); + } + + const queriesInput = options.queries; + + const powerSyncQueriesInput = useMemo( + () => + queriesInput.map((queryOptions) => ({ + query: queryOptions.query, + parameters: queryOptions.parameters, + queryKey: queryOptions.queryKey + })), + [queriesInput] + ); + + const states = usePowerSyncQueries(powerSyncQueriesInput, queryClient); + + const queries = useMemo(() => { + return queriesInput.map((queryOptions, idx) => { + const { query, parameters, ...rest } = queryOptions; + const state = states[idx]; + + return { + ...rest, + queryFn: query ? state.queryFn : rest.queryFn, + queryKey: rest.queryKey + }; + }); + }, [queriesInput, states]); + + return Tanstack.useQueries( + { + queries: queries as Tanstack.QueriesOptions, + combine: options.combine + ? (results) => { + const enhancedResultsWithQueryKey = results.map((result, index) => ({ + ...result, + queryKey: queries[index].queryKey + })); + + return options.combine?.(enhancedResultsWithQueryKey); + } + : undefined + }, + queryClient + ); +} diff --git a/packages/tanstack-react-query/src/hooks/useQuery.ts b/packages/tanstack-react-query/src/hooks/useQuery.ts index aa1034ed4..9ee2338ec 100644 --- a/packages/tanstack-react-query/src/hooks/useQuery.ts +++ b/packages/tanstack-react-query/src/hooks/useQuery.ts @@ -1,8 +1,7 @@ -import { parseQuery, type CompilableQuery } from '@powersync/common'; +import { type CompilableQuery } from '@powersync/common'; import { usePowerSync } from '@powersync/react'; -import React from 'react'; - import * as Tanstack from '@tanstack/react-query'; +import { usePowerSyncQueries } from './usePowerSyncQueries'; export type PowerSyncQueryOptions = { query?: string | CompilableQuery; @@ -65,92 +64,23 @@ function useQueryCore< throw new Error('PowerSync is not available'); } - let error: Error | undefined = undefined; - - const [tables, setTables] = React.useState([]); - const { query, parameters = [], ...resolvedOptions } = options; - - let sqlStatement = ''; - let queryParameters = []; - - if (query) { - try { - const parsedQuery = parseQuery(query, parameters); - - sqlStatement = parsedQuery.sqlStatement; - queryParameters = parsedQuery.parameters; - } catch (e) { - error = e; - } - } - - const stringifiedParams = JSON.stringify(queryParameters); - const stringifiedKey = JSON.stringify(options.queryKey); - - const fetchTables = async () => { - try { - const tables = await powerSync.resolveTables(sqlStatement, queryParameters); - setTables(tables); - } catch (e) { - error = e; - } - }; - - React.useEffect(() => { - if (error || !query) return () => {}; - - (async () => { - await fetchTables(); - })(); + const { query, parameters, queryKey, ...resolvedOptions } = options; - const l = powerSync.registerListener({ - schemaChanged: async () => { - await fetchTables(); - queryClient.invalidateQueries({ queryKey: options.queryKey }); - } - }); - - return () => l?.(); - }, [powerSync, sqlStatement, stringifiedParams]); - - const queryFn = React.useCallback(async () => { - if (error) { - return Promise.reject(error); - } - - try { - return typeof query == 'string' ? powerSync.getAll(sqlStatement, queryParameters) : query.execute(); - } catch (e) { - return Promise.reject(e); - } - }, [powerSync, query, parameters, stringifiedKey]); - - React.useEffect(() => { - if (error || !query) return () => {}; - - const abort = new AbortController(); - powerSync.onChangeWithCallback( - { - onChange: () => { - queryClient.invalidateQueries({ - queryKey: options.queryKey - }); - }, - onError: (e) => { - error = e; - } - }, + const [{ queryFn }] = usePowerSyncQueries( + [ { - tables, - signal: abort.signal + query, + parameters, + queryKey } - ); - return () => abort.abort(); - }, [powerSync, queryClient, stringifiedKey, tables]); + ], + queryClient + ); return useQueryFn( { ...(resolvedOptions as TQueryOptions), + queryKey, queryFn: query ? queryFn : resolvedOptions.queryFn } as TQueryOptions, queryClient diff --git a/packages/tanstack-react-query/src/index.ts b/packages/tanstack-react-query/src/index.ts index feb3e2e36..7696d509e 100644 --- a/packages/tanstack-react-query/src/index.ts +++ b/packages/tanstack-react-query/src/index.ts @@ -1,2 +1,2 @@ -export { useQuery } from './hooks/useQuery'; -export { useSuspenseQuery } from './hooks/useQuery'; +export { useQuery, useSuspenseQuery } from './hooks/useQuery'; +export { useQueries } from './hooks/useQueries'; diff --git a/packages/tanstack-react-query/tests/useQueries.test.tsx b/packages/tanstack-react-query/tests/useQueries.test.tsx new file mode 100644 index 000000000..d33ab0c8c --- /dev/null +++ b/packages/tanstack-react-query/tests/useQueries.test.tsx @@ -0,0 +1,428 @@ +import * as commonSdk from '@powersync/common'; +import { cleanup, renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PowerSyncContext } from '@powersync/react/'; +import { useQueries } from '../src/hooks/useQueries'; +import { QueryClient, QueryClientProvider, QueryKey } from '@tanstack/react-query'; +import { expectTypeOf } from 'vitest'; + +const mockPowerSync = { + currentStatus: { status: 'initial' }, + registerListener: vi.fn(() => {}), + resolveTables: vi.fn(() => ['table1', 'table2']), + onChangeWithCallback: vi.fn(), + getAll: vi.fn((sql) => { + if (sql.includes('users')) { + return Promise.resolve([{ id: 1, name: 'Test User' }]); + } + + if (sql.includes('posts')) { + return Promise.resolve([{ id: 1, title: 'Test Post' }]); + } + + return Promise.resolve([{ id: 1, result: 'mock data' }]); + }) +}; + +describe('useQueries', () => { + let queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false + } + } + }); + + const wrapper = ({ children }) => ( + + {children} + + ); + + beforeEach(() => { + queryClient.clear(); + vi.clearAllMocks(); + cleanup(); + }); + + it('should set loading states on initial load for all queries', async () => { + const { result } = renderHook( + () => + useQueries({ + queries: [ + { queryKey: ['q1'], query: 'SELECT 1' }, + { queryKey: ['q2'], query: 'SELECT 2' } + ] + }), + { wrapper } + ); + + const results = result.current as any[]; + expect(results[0].isLoading).toEqual(true); + expect(results[1].isLoading).toEqual(true); + }); + + it('should execute string queries and return correct data', async () => { + const { result } = renderHook( + () => + useQueries({ + queries: [ + { queryKey: ['users'], query: 'SELECT * FROM users WHERE active = ?', parameters: [true] }, + { queryKey: ['posts'], query: 'SELECT * FROM posts WHERE published = ?', parameters: [true] } + ] + }), + { wrapper } + ); + + await waitFor(() => { + const results = result.current; + + expect(results[0].data).toHaveLength(1); + expect(results[0].data?.[0]).toEqual({ id: 1, name: 'Test User' }); + expect(results[1].data).toHaveLength(1); + expect(results[1].data?.[0]).toEqual({ id: 1, title: 'Test Post' }); + }); + }); + + it('should execute compilable queries', async () => { + const compilableQuery = { + execute: () => [{ test: 'custom' }], + compile: () => ({ sql: 'SELECT * from lists' }) + } as unknown as commonSdk.CompilableQuery; + + const { result } = renderHook( + () => + useQueries({ + queries: [ + { queryKey: ['q1'], query: compilableQuery }, + { queryKey: ['q2'], query: 'SELECT * FROM posts' } + ] + }), + { wrapper } + ); + + await waitFor(() => { + const results = result.current as any[]; + expect(results[0].data[0].test).toEqual('custom'); + expect(results[1].data[0]).toEqual({ id: 1, title: 'Test Post' }); + }); + }); + + it('should set error during query execution', async () => { + const mockPowerSyncError = { + ...mockPowerSync, + getAll: vi.fn(() => { + throw new Error('some error'); + }) + }; + const errorWrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook( + () => + useQueries({ + queries: [ + { queryKey: ['q1'], query: 'SELECT 1' }, + { queryKey: ['q2'], query: 'SELECT 2' } + ] + }), + { wrapper: errorWrapper } + ); + + await waitFor(() => { + const results = result.current as any[]; + expect(results[0].error).toEqual(Error('some error')); + expect(results[1].error).toEqual(Error('some error')); + }); + }); + + it('should support parameters and merge/combine results', async () => { + const { result } = renderHook( + () => + useQueries({ + queries: [ + { queryKey: ['users'], query: 'SELECT * FROM users WHERE active = ?', parameters: [true] }, + { queryKey: ['posts'], query: 'SELECT * FROM posts WHERE published = ?', parameters: [true] } + ], + combine: (results) => { + const [usersResult, postsResult] = results; + return { + totalUsers: usersResult.data?.length ?? 0, + totalPosts: postsResult.data?.length ?? 0, + allData: [...(usersResult.data ?? []), ...(postsResult.data ?? [])] + }; + } + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.totalUsers).toBe(1); + expect(result.current.totalPosts).toBe(1); + expect(result.current.allData).toHaveLength(2); + expect(result.current.allData).toContainEqual({ id: 1, name: 'Test User' }); + expect(result.current.allData).toContainEqual({ id: 1, title: 'Test Post' }); + }); + }); + + it('should show an error if parsing the query results in an error', async () => { + const compilableQuery = { + execute: () => [] as any, + compile: () => ({ sql: 'SELECT * from lists', parameters: ['param'] }) + } as unknown as commonSdk.CompilableQuery; + + const { result } = renderHook( + () => + useQueries({ + queries: [ + { queryKey: ['q1'], query: compilableQuery, parameters: ['redundant param'] }, + { queryKey: ['q2'], query: 'SELECT 2' } + ] + }), + { wrapper } + ); + + await waitFor(() => { + const results = result.current as any[]; + expect(results[0].error).toEqual(Error('You cannot pass parameters to a compiled query.')); + expect(results[0].data).toBeUndefined(); + }); + }); + + describe.skip('Type Tests', () => { + // This is a dummy test that contains all the type tests + // It is not intended to be run, but to be checked by the TypeScript compiler + it('should have correct types', () => { + // === Manual explicit typing without combine === + const manual = useQueries<[{ foo: number }, { baz: string }]>({ + queries: [ + { queryKey: ['q1'], query: 'SELECT foo FROM bar' }, + { queryKey: ['q2'], query: 'SELECT baz FROM qux' } + ] + }); + + // Should infer correct types + expectTypeOf(manual[0].data).toEqualTypeOf<{ foo: number }[] | undefined>(); + expectTypeOf(manual[1].data).toEqualTypeOf<{ baz: string }[] | undefined>(); + expectTypeOf(manual).toHaveProperty('0'); + expectTypeOf(manual).toHaveProperty('1'); + expectTypeOf(manual).not.toHaveProperty('2'); + + // === Manual explicit typing with combine === + const manualCombine = useQueries<[{ foo: number }, { bar: string }], { total: number }>({ + queries: [ + { queryKey: ['q1'], query: 'SELECT foo FROM test' }, + { queryKey: ['q2'], query: 'SELECT bar FROM test' } + ], + combine: (results) => { + // Should have correct input types + expectTypeOf(results[0].data).toEqualTypeOf<{ foo: number }[] | undefined>(); + expectTypeOf(results[1].data).toEqualTypeOf<{ bar: string }[] | undefined>(); + return { total: 42 }; + } + }); + + // Should have combine return type + expectTypeOf(manualCombine).toEqualTypeOf<{ total: number }>(); + expectTypeOf(manualCombine).toHaveProperty('total'); + expectTypeOf(manualCombine).not.toHaveProperty('0'); + + // === Auto inference with CompilableQuery without combine === + type User = { id: number; name: string }; + type Post = { id: number; title: string; userId: number }; + + const userQuery: commonSdk.CompilableQuery = { + execute: () => Promise.resolve([{ id: 1, name: 'John' }]), + compile: () => ({ sql: 'SELECT * FROM users', parameters: [] }) + }; + + const postQuery: commonSdk.CompilableQuery = { + execute: () => Promise.resolve([{ id: 1, title: 'Hello', userId: 1 }]), + compile: () => ({ sql: 'SELECT * FROM posts', parameters: [] }) + }; + + const autoInfer = useQueries({ + queries: [ + { queryKey: ['users'], query: userQuery }, + { queryKey: ['posts'], query: postQuery } + ] + } as const); + + // Should infer correct types from CompilableQuery + expectTypeOf(autoInfer[0].data).toEqualTypeOf(); + expectTypeOf(autoInfer[1].data).toEqualTypeOf(); + + // === Auto inference with CompilableQuery with combine === + const autoInferCombine = useQueries({ + queries: [ + { queryKey: ['users'], query: userQuery }, + { queryKey: ['posts'], query: postQuery } + ], + combine: (results) => { + // Should have correct input types + expectTypeOf(results[0].data).toEqualTypeOf(); + expectTypeOf(results[1].data).toEqualTypeOf(); + + return { + users: results[0].data || [], + posts: results[1].data || [], + combined: true + }; + } + }); + + // Should have combine return type + expectTypeOf(autoInferCombine).toEqualTypeOf<{ + users: User[]; + posts: Post[]; + combined: boolean; + }>(); + expectTypeOf(autoInferCombine).toHaveProperty('users'); + expectTypeOf(autoInferCombine).toHaveProperty('posts'); + expectTypeOf(autoInferCombine).toHaveProperty('combined'); + expectTypeOf(autoInferCombine).not.toHaveProperty('0'); + + // === Mixed queries (CompilableQuery + string) without combine === + const mixed = useQueries({ + queries: [ + { queryKey: ['typed'], query: userQuery }, + { queryKey: ['untyped'], query: 'SELECT * FROM something' } + ] + } as const); + + // First should be typed, second should be unknown + expectTypeOf(mixed[0].data).toEqualTypeOf(); + expectTypeOf(mixed[1].data).toEqualTypeOf(); + + // === Mixed queries with combine === + const mixedCombine = useQueries({ + queries: [ + { queryKey: ['typed'], query: userQuery }, + { queryKey: ['untyped'], query: 'SELECT count(*) as total' } + ], + combine: (results) => { + // First result typed, second unknown + expectTypeOf(results[0].data?.[0].name).toEqualTypeOf(); + expectTypeOf(results[1].data).toEqualTypeOf(); + return results.map((r) => r.data?.length || 0); + } + }); + + // Should be number[] + expectTypeOf(mixedCombine).toEqualTypeOf(); + expectTypeOf(mixedCombine).not.toHaveProperty('users'); + + // === No queries (empty array) === + const empty = useQueries({ + queries: [] + }); + + // Should be an empty array type + expectTypeOf(empty).toEqualTypeOf<[]>(); + expectTypeOf(empty).not.toHaveProperty('0'); + + // === Parameters typing === + const withParams = useQueries<[{ count: number }]>({ + queries: [ + { + queryKey: ['count'], + query: 'SELECT COUNT(*) as count FROM users WHERE active = ?', + parameters: [true] // Should accept any[] + } + ] + }); + + expectTypeOf(withParams[0].data).toEqualTypeOf<{ count: number }[] | undefined>(); + + // === Query options inheritance === + const withOptions = useQueries({ + queries: [ + { + queryKey: ['users'], + query: userQuery, + enabled: true, + staleTime: 5000, + refetchOnWindowFocus: false + } + ] + }); + + // Should still have correct data typing + expectTypeOf(withOptions[0].data).toEqualTypeOf(); + + // === Combine function parameter typing === + const combineParamTest = useQueries({ + queries: [ + { queryKey: ['q1'], query: userQuery }, + { queryKey: ['q2'], query: postQuery } + ], + combine: (results) => { + // Test that results parameter has correct structure + expectTypeOf(results.length).toEqualTypeOf<2>(); + expectTypeOf(results[0].data).toEqualTypeOf(); + expectTypeOf(results[0].isLoading).toEqualTypeOf(); + expectTypeOf(results[1].data).toEqualTypeOf(); + + expectTypeOf(results).not.toHaveProperty('2'); + + expectTypeOf(results[0].queryKey).toEqualTypeOf(); + expectTypeOf(results[1].queryKey).toEqualTypeOf(); + + return 'test'; + } + }); + + // Should be string (return type of combine) + expectTypeOf(combineParamTest).toEqualTypeOf(); + + // === Without combine should return tuple === + const tupleTest = useQueries({ + queries: [ + { queryKey: ['q1'], query: userQuery }, + { queryKey: ['q2'], query: postQuery } + ] + }); + + expectTypeOf(tupleTest[0].data).toEqualTypeOf(); + expectTypeOf(tupleTest[1].data).toEqualTypeOf(); + expectTypeOf(tupleTest).not.toHaveProperty('2'); + + // === Complex combine return types === + const complexCombine = useQueries({ + queries: [ + { queryKey: ['users'], query: userQuery }, + { queryKey: ['posts'], query: postQuery } + ], + combine: (results) => { + if (results[0].isLoading || results[1].isLoading) { + return { loading: true } as const; + } + + return { + loading: false, + data: { + userCount: results[0].data?.length || 0, + postTitles: results[1].data?.map((p) => p.title) || [] + } + } as const; + } + }); + + if (complexCombine.loading === true) { + expectTypeOf(complexCombine).toEqualTypeOf<{ readonly loading: true; readonly data?: undefined }>(); + } else { + expectTypeOf(complexCombine).toEqualTypeOf<{ + readonly loading: false; + readonly data: { + readonly userCount: number; + readonly postTitles: string[]; + }; + }>(); + } + }); + }); +}); diff --git a/packages/tanstack-react-query/tests/useQuery.test.tsx b/packages/tanstack-react-query/tests/useQuery.test.tsx index e6aa2fd60..300f13277 100644 --- a/packages/tanstack-react-query/tests/useQuery.test.tsx +++ b/packages/tanstack-react-query/tests/useQuery.test.tsx @@ -8,7 +8,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const mockPowerSync = { currentStatus: { status: 'initial' }, - registerListener: vi.fn(() => { }), + registerListener: vi.fn(() => {}), resolveTables: vi.fn(() => ['table1', 'table2']), onChangeWithCallback: vi.fn(), getAll: vi.fn(() => Promise.resolve(['list1', 'list2'])) @@ -22,10 +22,10 @@ describe('useQuery', () => { let queryClient = new QueryClient({ defaultOptions: { queries: { - retry: false, - }, + retry: false + } } - }) + }); const wrapper = ({ children }) => ( @@ -40,12 +40,15 @@ describe('useQuery', () => { cleanup(); // Cleanup the DOM after each test }); - it('should set loading states on initial load', async () => { - const { result } = renderHook(() => useQuery({ - queryKey: ['lists'], - query: 'SELECT * from lists' - }), { wrapper }); + const { result } = renderHook( + () => + useQuery({ + queryKey: ['lists'], + query: 'SELECT * from lists' + }), + { wrapper } + ); const currentResult = result.current; expect(currentResult.isLoading).toEqual(true); expect(currentResult.isFetching).toEqual(true); @@ -55,20 +58,23 @@ describe('useQuery', () => { const query = () => useQuery({ queryKey: ['lists'], - query: "SELECT * from lists" + query: 'SELECT * from lists' }); const { result } = renderHook(query, { wrapper }); - await vi.waitFor(() => { - expect(result.current.data![0]).toEqual('list1'); - expect(result.current.data![1]).toEqual('list2'); - }, { timeout: 500 }); + await vi.waitFor( + () => { + expect(result.current.data![0]).toEqual('list1'); + expect(result.current.data![1]).toEqual('list2'); + }, + { timeout: 500 } + ); }); it('should set error during query execution', async () => { const mockPowerSyncError = { currentStatus: { status: 'initial' }, - registerListener: vi.fn(() => { }), + registerListener: vi.fn(() => {}), onChangeWithCallback: vi.fn(), resolveTables: vi.fn(() => ['table1', 'table2']), getAll: vi.fn(() => { @@ -82,10 +88,14 @@ describe('useQuery', () => { ); - const { result } = renderHook(() => useQuery({ - queryKey: ['lists'], - query: 'SELECT * from lists' - }), { wrapper }); + const { result } = renderHook( + () => + useQuery({ + queryKey: ['lists'], + query: 'SELECT * from lists' + }), + { wrapper } + ); await waitFor( async () => { @@ -108,9 +118,12 @@ describe('useQuery', () => { }); const { result } = renderHook(query, { wrapper }); - await vi.waitFor(() => { - expect(result.current.data![0].test).toEqual('custom'); - }, { timeout: 500 }); + await vi.waitFor( + () => { + expect(result.current.data![0].test).toEqual('custom'); + }, + { timeout: 500 } + ); }); it('should show an error if parsing the query results in an error', async () => { @@ -135,10 +148,9 @@ describe('useQuery', () => { expect(currentResult.isLoading).toEqual(false); expect(currentResult.isFetching).toEqual(false); expect(currentResult.error).toEqual(Error('You cannot pass parameters to a compiled query.')); - expect(currentResult.data).toBeUndefined() + expect(currentResult.data).toBeUndefined(); }, { timeout: 100 } ); }); - });