diff --git a/.changeset/tall-dodos-watch.md b/.changeset/tall-dodos-watch.md
new file mode 100644
index 000000000..0e8abb55d
--- /dev/null
+++ b/.changeset/tall-dodos-watch.md
@@ -0,0 +1,5 @@
+---
+'@powersync/tanstack-react-query': patch
+---
+
+Added Tanstack useQueries support. See https://tanstack.com/query/latest/docs/framework/react/reference/useQueries for more information.
diff --git a/packages/tanstack-react-query/README.md b/packages/tanstack-react-query/README.md
index 919cc9e40..60d9639d7 100644
--- a/packages/tanstack-react-query/README.md
+++ b/packages/tanstack-react-query/README.md
@@ -106,6 +106,38 @@ export const TodoListDisplaySuspense = () => {
};
```
+### useQueries
+
+The `useQueries` hook allows you to run multiple queries in parallel and combine the results into a single result.
+
+```JSX
+// TodoListDisplay.jsx
+import { useQueries } from '@powersync/tanstack-react-query';
+
+export const TodoListDisplay = () => {
+ const { data: todoLists } = useQueries({
+ queries: [
+ { queryKey: ['todoLists'], query: 'SELECT * from lists' },
+ { queryKey: ['todoLists2'], query: 'SELECT * from lists2' },
+ ],
+ combine: (results) => {
+ return {
+ data: results.map((result) => result.data),
+ pending: results.some((result) => result.isPending),
+ }
+ },
+ });
+
+ return (
+
+ {todoLists.map((list) => (
+
{list.name}
+ ))}
+
+ );
+};
+```
+
### TypeScript Support
A type can be specified for each row returned by `useQuery` and `useSuspenseQuery`.
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..294bbc2e6
--- /dev/null
+++ b/packages/tanstack-react-query/src/hooks/useQueries.ts
@@ -0,0 +1,165 @@
+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.js';
+
+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 };
+};
+
+export type UseQueriesExplicitWithCombineOptions = {
+ queries: readonly [...{ [K in keyof T]: PowerSyncQueryOption }];
+ combine: (results: EnhancedExplicitQueryResults) => TCombined;
+};
+
+export type UseQueriesExplicitWithoutCombineOptions = {
+ queries: readonly [...{ [K in keyof T]: PowerSyncQueryOption }];
+ combine?: undefined;
+};
+
+export type UseQueriesAutoInferenceWithCombineOptions = {
+ queries: readonly [...TQueries];
+ combine: (results: EnhancedInferQueryResults) => TCombined;
+};
+
+export type UseQueriesAutoInferenceWithoutCombineOptions = {
+ queries: readonly [...TQueries];
+ combine?: undefined;
+};
+
+export type UseQueriesBaseOptions = {
+ queries: readonly (Tanstack.UseQueryOptions & PowerSyncQueryOptions)[];
+ combine?: (results: (Tanstack.UseQueryResult & { queryKey: Tanstack.QueryKey })[]) => unknown;
+};
+
+// Explicit generic typing with combine
+export function useQueries(
+ options: UseQueriesExplicitWithCombineOptions,
+ queryClient?: Tanstack.QueryClient
+): TCombined;
+
+// Explicit generic typing without combine
+export function useQueries(
+ options: UseQueriesExplicitWithoutCombineOptions,
+ queryClient?: Tanstack.QueryClient
+): ExplicitQueryResults;
+
+// Auto inference with combine
+export function useQueries(
+ options: UseQueriesAutoInferenceWithCombineOptions,
+ queryClient?: Tanstack.QueryClient
+): TCombined;
+
+// Auto inference without combine
+export function useQueries(
+ options: UseQueriesAutoInferenceWithoutCombineOptions,
+ queryClient?: Tanstack.QueryClient
+): InferQueryResults;
+
+/**
+ * @example
+ * ```
+ * const { data, error, isLoading } = useQueries({
+ * queries: [
+ * { queryKey: ['lists'], query: 'SELECT * from lists' },
+ * { queryKey: ['todos'], query: 'SELECT * from todos' }
+ * ],
+ * })
+ * ```
+ *
+ * @example
+ * ```
+ * const ids = [1, 2, 3];
+ * const combinedQueries = useQueries({
+ * queries: ids.map((id) => ({
+ * queryKey: ['post', id],
+ * query: 'SELECT * from lists where id = ?',
+ * parameters: [id],
+ * })),
+ * combine: (results) => {
+ * return {
+ * data: results.map((result) => result.data),
+ * pending: results.some((result) => result.isPending),
+ * }
+ * },
+ * });
+ * ```
+ */
+export function useQueries(
+ options: UseQueriesBaseOptions,
+ 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..69f2f74d4 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.js';
export type PowerSyncQueryOptions = {
query?: string | CompilableQuery;
@@ -11,12 +10,44 @@ export type PowerSyncQueryOptions = {
export type UseBaseQueryOptions = TQueryOptions & PowerSyncQueryOptions;
+/**
+ *
+ * Uses the `queryFn` to execute the query. No different from the base `useQuery` hook.
+ *
+ * @example
+ * ```
+ * const { data, error, isLoading } = useQuery({
+ * queryKey: ['lists'],
+ * queryFn: getTodos,
+ * });
+ * ```
+ */
export function useQuery(
options: UseBaseQueryOptions> & { query?: undefined },
queryClient?: Tanstack.QueryClient
): Tanstack.UseQueryResult;
-// Overload when 'query' is present
+/**
+ *
+ * Uses the `query` to execute the PowerSync query.
+ *
+ * @example
+ * ```
+ * const { data, error, isLoading } = useQuery({
+ * queryKey: ['lists'],
+ * query: 'SELECT * from lists where id = ?',
+ * parameters: ['id-1']
+ * });
+ * ```
+ *
+ * @example
+ * ```
+ * const { data, error, isLoading } = useQuery({
+ * queryKey: ['lists'],
+ * query: compilableQuery,
+ * });
+ * ```
+ */
export function useQuery(
options: UseBaseQueryOptions> & { query: string | CompilableQuery },
queryClient?: Tanstack.QueryClient
@@ -29,12 +60,36 @@ export function useQuery(
return useQueryCore(options, queryClient, Tanstack.useQuery);
}
+/**
+ *
+ * Uses the `queryFn` to execute the query. No different from the base `useSuspenseQuery` hook.
+ *
+ * @example
+ * ```
+ * const { data } = useSuspenseQuery({
+ * queryKey: ['lists'],
+ * queryFn: getTodos,
+ * });
+ * ```
+ */
export function useSuspenseQuery(
options: UseBaseQueryOptions> & { query?: undefined },
queryClient?: Tanstack.QueryClient
): Tanstack.UseSuspenseQueryResult;
-// Overload when 'query' is present
+/***
+ *
+ * Uses the `query` to execute the PowerSync query.
+ *
+ * @example
+ * ```
+ * const { data } = useSuspenseQuery({
+ * queryKey: ['lists'],
+ * query: 'SELECT * from lists where id = ?',
+ * parameters: ['id-1']
+ * });
+ * ```
+ */
export function useSuspenseQuery(
options: UseBaseQueryOptions> & {
query: string | CompilableQuery;
@@ -65,92 +120,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 b072f317d..47754e1b7 100644
--- a/packages/tanstack-react-query/src/index.ts
+++ b/packages/tanstack-react-query/src/index.ts
@@ -1 +1,3 @@
export { useQuery, useSuspenseQuery } from './hooks/useQuery.js';
+export { useQueries } from './hooks/useQueries.js';
+
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 924b079a7..3ef8c94b1 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 }
);
});
-
});