Skip to content

Commit 82fa744

Browse files
committed
Added test suite for useSuspenseQuery.
1 parent 0a09ee3 commit 82fa744

File tree

5 files changed

+258
-11
lines changed

5 files changed

+258
-11
lines changed

packages/react/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,15 @@
2929
},
3030
"homepage": "https://docs.powersync.com",
3131
"peerDependencies": {
32-
"react": "*",
33-
"@powersync/common": "workspace:^1.19.0"
32+
"@powersync/common": "workspace:^1.19.0",
33+
"react": "*"
3434
},
3535
"devDependencies": {
3636
"@testing-library/react": "^15.0.2",
3737
"@types/react": "^18.2.34",
3838
"jsdom": "^24.0.0",
3939
"react": "18.2.0",
40+
"react-error-boundary": "^4.1.0",
4041
"typescript": "^5.5.3"
4142
}
4243
}

packages/react/src/QueryStore.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import { AbstractPowerSyncDatabase, SQLWatchOptions } from '@powersync/common';
22
import { Query, WatchedQuery } from './WatchedQuery';
3+
import { AdditionalOptions } from './hooks/useQuery';
34

4-
export function generateQueryKey(
5-
sqlStatement: string,
6-
parameters: any[],
7-
options: Omit<SQLWatchOptions, 'signal'>
8-
): string {
5+
export function generateQueryKey(sqlStatement: string, parameters: any[], options: AdditionalOptions): string {
96
return `${sqlStatement} -- ${JSON.stringify(parameters)} -- ${JSON.stringify(options)}`;
107
}
118

@@ -14,7 +11,7 @@ export class QueryStore {
1411

1512
constructor(private db: AbstractPowerSyncDatabase) {}
1613

17-
getQuery(key: string, query: Query<unknown>, options: SQLWatchOptions) {
14+
getQuery(key: string, query: Query<unknown>, options: AdditionalOptions) {
1815
if (this.cache.has(key)) {
1916
return this.cache.get(key);
2017
}

packages/react/src/hooks/useSuspenseQuery.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import { generateQueryKey, getQueryStore } from '../QueryStore';
33
import { usePowerSync } from './PowerSyncContext';
44
import { CompilableQuery, ParsedQuery, parseQuery, SQLWatchOptions } from '@powersync/common';
55
import { WatchedQuery } from '../WatchedQuery';
6-
import { QueryResult } from './useQuery';
6+
import { AdditionalOptions, QueryResult } from './useQuery';
77

88
export type SuspenseQueryResult<T> = Pick<QueryResult<T>, 'data' | 'refresh'>;
99

1010
export const useSuspenseQuery = <T = any>(
1111
query: string | CompilableQuery<T>,
1212
parameters: any[] = [],
13-
options: Omit<SQLWatchOptions, 'signal'> = {}
13+
options: AdditionalOptions = {}
1414
): SuspenseQueryResult<T> => {
1515
const powerSync = usePowerSync();
1616
if (!powerSync) {
@@ -35,6 +35,7 @@ export const useSuspenseQuery = <T = any>(
3535
{ rawQuery: query, sqlStatement: parsedQuery.sqlStatement, queryParameters: parsedQuery.parameters },
3636
options
3737
);
38+
3839
const addedHoldTo = React.useRef<WatchedQuery | undefined>(undefined);
3940
const releaseTemporaryHold = React.useRef<(() => void) | undefined>(undefined);
4041

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import * as commonSdk from '@powersync/common';
2+
import { cleanup, render, renderHook, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
3+
import React, { Suspense } from 'react';
4+
import { afterEach, beforeEach, describe, expect, it, vi, Mock } from 'vitest';
5+
import { PowerSyncContext } from '../src/hooks/PowerSyncContext';
6+
import { useSuspenseQuery } from '../src/hooks/useSuspenseQuery';
7+
import { ErrorBoundary } from 'react-error-boundary';
8+
9+
const defaultQueryResult = ['list1', 'list2'];
10+
11+
const createMockPowerSync = () => {
12+
return {
13+
currentStatus: { status: 'initial' },
14+
registerListener: vi.fn(() => ({
15+
statusChanged: vi.fn(() => 'updated')
16+
})),
17+
resolveTables: vi.fn(() => ['table1', 'table2']),
18+
onChangeWithCallback: vi.fn(),
19+
getAll: vi.fn(() => Promise.resolve(defaultQueryResult)) as Mock<any, any>
20+
};
21+
};
22+
23+
let mockPowerSync = createMockPowerSync();
24+
25+
vi.mock('./PowerSyncContext', () => ({
26+
useContext: vi.fn(() => mockPowerSync)
27+
}));
28+
29+
describe('useSuspenseQuery', () => {
30+
const loadingFallback = 'Loading';
31+
const errorFallback = 'Error';
32+
33+
const wrapper = ({ children }) => (
34+
<PowerSyncContext.Provider value={mockPowerSync as any}>
35+
<ErrorBoundary fallback={errorFallback}>
36+
<Suspense fallback={loadingFallback}>{children}</Suspense>
37+
</ErrorBoundary>
38+
</PowerSyncContext.Provider>
39+
);
40+
41+
const waitForSuspend = async () => {
42+
await waitFor(
43+
async () => {
44+
expect(screen.queryByText(loadingFallback)).toBeTruthy();
45+
},
46+
{ timeout: 100 }
47+
);
48+
};
49+
50+
const waitForCompletedSuspend = async () => {
51+
await waitFor(
52+
async () => {
53+
expect(screen.queryByText(loadingFallback)).toBeFalsy();
54+
},
55+
{ timeout: 100 }
56+
);
57+
};
58+
59+
const waitForError = async () => {
60+
await waitFor(
61+
async () => {
62+
expect(screen.queryByText(errorFallback)).toBeTruthy();
63+
},
64+
{ timeout: 100 }
65+
);
66+
};
67+
68+
beforeEach(() => {
69+
vi.clearAllMocks();
70+
cleanup(); // Cleanup the DOM after each test
71+
mockPowerSync = createMockPowerSync();
72+
});
73+
74+
it('should error when PowerSync is not set', async () => {
75+
expect(() => {
76+
renderHook(() => useSuspenseQuery('SELECT * from lists'));
77+
}).toThrow('PowerSync not configured');
78+
});
79+
80+
it('should suspend on initial load', async () => {
81+
mockPowerSync.getAll = vi.fn(() => {
82+
return new Promise(() => {});
83+
});
84+
85+
const wrapper = ({ children }) => (
86+
<PowerSyncContext.Provider value={mockPowerSync as any}>
87+
<Suspense fallback={loadingFallback}>{children}</Suspense>
88+
</PowerSyncContext.Provider>
89+
);
90+
91+
renderHook(() => useSuspenseQuery('SELECT * from lists'), { wrapper });
92+
93+
await waitForSuspend();
94+
95+
expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1);
96+
});
97+
98+
it('should run the query once if runQueryOnce flag is set', async () => {
99+
let resolvePromise: (_: string[]) => void = () => {};
100+
101+
mockPowerSync.getAll = vi.fn(() => {
102+
return new Promise<string[]>((resolve) => {
103+
resolvePromise = resolve;
104+
});
105+
});
106+
107+
const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), {
108+
wrapper
109+
});
110+
111+
await waitForSuspend();
112+
113+
resolvePromise(defaultQueryResult);
114+
115+
await waitForCompletedSuspend();
116+
await waitFor(
117+
async () => {
118+
const currentResult = result.current;
119+
expect(currentResult?.data).toEqual(['list1', 'list2']);
120+
expect(mockPowerSync.onChangeWithCallback).not.toHaveBeenCalled();
121+
expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1);
122+
},
123+
{ timeout: 100 }
124+
);
125+
});
126+
127+
it('should rerun the query when refresh is used', async () => {
128+
const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), {
129+
wrapper
130+
});
131+
132+
await waitForSuspend();
133+
134+
let refresh;
135+
136+
await waitFor(
137+
async () => {
138+
const currentResult = result.current;
139+
refresh = currentResult.refresh;
140+
expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1);
141+
},
142+
{ timeout: 100 }
143+
);
144+
145+
await waitForCompletedSuspend();
146+
147+
await refresh();
148+
expect(mockPowerSync.getAll).toHaveBeenCalledTimes(2);
149+
});
150+
151+
it('should set error when error occurs', async () => {
152+
let rejectPromise: (err: string) => void = () => {};
153+
154+
mockPowerSync.getAll = vi.fn(() => {
155+
return new Promise<void>((_resolve, reject) => {
156+
rejectPromise = reject;
157+
});
158+
});
159+
160+
renderHook(() => useSuspenseQuery('SELECT * from lists', []), { wrapper });
161+
162+
await waitForSuspend();
163+
164+
rejectPromise('failure');
165+
await waitForCompletedSuspend();
166+
await waitForError();
167+
});
168+
169+
it('should set error when error occurs and runQueryOnce flag is set', async () => {
170+
let rejectPromise: (err: string) => void = () => {};
171+
172+
mockPowerSync.getAll = vi.fn(() => {
173+
return new Promise<void>((_resolve, reject) => {
174+
rejectPromise = reject;
175+
});
176+
});
177+
178+
renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), {
179+
wrapper
180+
});
181+
182+
await waitForSuspend();
183+
184+
rejectPromise('failure');
185+
await waitForCompletedSuspend();
186+
await waitForError();
187+
});
188+
189+
it('should accept compilable queries', async () => {
190+
renderHook(
191+
() =>
192+
useSuspenseQuery({ execute: () => [] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }),
193+
{ wrapper }
194+
);
195+
196+
await waitForSuspend();
197+
});
198+
199+
it('should execute compatible queries', async () => {
200+
const query = () =>
201+
useSuspenseQuery({
202+
execute: () => [{ test: 'custom' }] as any,
203+
compile: () => ({ sql: 'SELECT * from lists', parameters: [] })
204+
});
205+
const { result } = renderHook(query, { wrapper });
206+
207+
await waitForSuspend();
208+
209+
await waitForCompletedSuspend();
210+
await waitFor(
211+
async () => {
212+
expect(result.current?.data).toEqual([{ test: 'custom' }]);
213+
},
214+
{ timeout: 100 }
215+
);
216+
});
217+
218+
it('should show an error if parsing the query results in an error', async () => {
219+
vi.spyOn(commonSdk, 'parseQuery').mockImplementation(() => {
220+
throw new Error('error');
221+
});
222+
223+
const { result } = renderHook(
224+
() =>
225+
useSuspenseQuery({
226+
execute: () => [] as any,
227+
compile: () => ({ sql: 'SELECT * from lists', parameters: ['param'] })
228+
}),
229+
{ wrapper }
230+
);
231+
232+
await waitForCompletedSuspend();
233+
await waitForError();
234+
});
235+
});

pnpm-lock.yaml

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)