Skip to content

Commit 7b58d75

Browse files
additional fixes
1 parent 048fbb5 commit 7b58d75

File tree

5 files changed

+196
-21
lines changed

5 files changed

+196
-21
lines changed

.changeset/empty-glasses-camp.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@powersync/react': patch
3+
---
4+
5+
- Fixed bug where the `useQuery` reported `error` state would not clear after updating the query to a valid query.
6+
- Fixed bug where `useQuery` `isFetching` status would not immediately be reported as true when the query has changed.

packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@ export class DifferentialQueryProcessor<RowType>
279279
});
280280
}
281281

282+
if (this.state.error) {
283+
partialStateUpdate.error = null;
284+
}
285+
282286
if (Object.keys(partialStateUpdate).length > 0) {
283287
await this.updateState(partialStateUpdate);
284288
}

packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export class OnChangeQueryProcessor<Data> extends AbstractQueryProcessor<Data, W
9696
});
9797
}
9898

99+
if (this.state.error) {
100+
partialStateUpdate.error = null;
101+
}
102+
99103
if (Object.keys(partialStateUpdate).length > 0) {
100104
await this.updateState(partialStateUpdate);
101105
}

packages/react/src/hooks/watched/useWatchedQuery.ts

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,6 @@ export const useWatchedQuery = <RowType = unknown>(
1616
): QueryResult<RowType> | ReadonlyQueryResult<RowType> => {
1717
const { query, powerSync, queryChanged, options: hookOptions, active } = options;
1818

19-
// This ref is used to protect against cases where `queryChanged` changes multiple times too quickly to be
20-
// picked up by the useEffect below. This typically happens when React.StrictMode is enabled.
21-
const queryChangeRef = React.useRef(false);
22-
if (queryChanged && !queryChangeRef.current) {
23-
queryChangeRef.current = true;
24-
}
25-
2619
function createWatchedQuery() {
2720
if (!active) {
2821
return null;
@@ -46,20 +39,40 @@ export const useWatchedQuery = <RowType = unknown>(
4639
React.useEffect(() => {
4740
watchedQuery?.close();
4841
setWatchedQuery(createWatchedQuery);
42+
43+
return () => {
44+
watchedQuery?.close();
45+
};
4946
}, [powerSync, active]);
5047

51-
// Indicates that the query will be re-fetched due to a change in the query.
52-
// Used when `isFetching` hasn't been set to true yet due to React execution.
53-
React.useEffect(() => {
54-
if (queryChangeRef.current) {
55-
watchedQuery?.updateSettings({
56-
query,
57-
throttleMs: hookOptions.throttleMs,
58-
reportFetching: hookOptions.reportFetching
59-
});
60-
queryChangeRef.current = false;
61-
}
62-
}, [queryChangeRef.current]);
48+
/**
49+
* Indicates that the query will be re-fetched due to a change in the query.
50+
* We execute this in-line (not using an effect) since effects are delayed till after the hook returns.
51+
* The `queryChanged` value should only be true for a single render.
52+
* The `updateSettings` method is asynchronous, thus it will update the state asynchronously.
53+
* In the React hooks we'd like to report that we are fetching the data for an updated query
54+
* as soon as the query has been updated. This prevents a result flow where e.g. the hook:
55+
* - already returned a result: isLoading, isFetching are both false
56+
* - the query is updated, but the state is still isFetching=false from the previous state
57+
* We only need to override the isFetching status on the initial render where the query changed
58+
* (if we report the fetching status). The fetching status will be updated internally in the `updateSettings`
59+
* method eventually.
60+
* Only overriding when the `queryChanged` value is true also prevents races if the query changes
61+
* rapidly.
62+
*/
63+
if (queryChanged) {
64+
watchedQuery?.updateSettings({
65+
query,
66+
throttleMs: hookOptions.throttleMs,
67+
reportFetching: hookOptions.reportFetching
68+
});
69+
}
70+
71+
const shouldReportCurrentlyFetching = (hookOptions.reportFetching ?? true) && queryChanged;
6372

64-
return useNullableWatchedQuerySubscription(watchedQuery);
73+
const result = useNullableWatchedQuerySubscription(watchedQuery);
74+
return {
75+
...result,
76+
isFetching: result?.isFetching || shouldReportCurrentlyFetching
77+
};
6578
};

packages/react/tests/useQuery.test.tsx

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
1010
import { PowerSyncContext } from '../src/hooks/PowerSyncContext';
1111
import { useQuery } from '../src/hooks/watched/useQuery';
1212
import { useWatchedQuerySubscription } from '../src/hooks/watched/useWatchedQuerySubscription';
13+
import { QueryResult } from '../src/hooks/watched/watch-types';
1314

1415
export const openPowerSync = () => {
1516
const db = new PowerSyncDatabase({
@@ -147,6 +148,13 @@ describe('useQuery', () => {
147148
(uuid (), 'second')
148149
`);
149150

151+
type TestEvent = {
152+
parameters: string[];
153+
hookResults: QueryResult<any>;
154+
};
155+
156+
const hookEvents: TestEvent[] = [];
157+
150158
const query = () => {
151159
const [parameters, setParameters] = React.useState<string[]>(['first']);
152160

@@ -155,7 +163,12 @@ describe('useQuery', () => {
155163
newParametersPromise.then((params) => setParameters(params));
156164
}, []);
157165

158-
return useQuery('SELECT * FROM lists WHERE name = ?', parameters);
166+
const result = useQuery('SELECT * FROM lists WHERE name = ?', parameters);
167+
hookEvents.push({
168+
parameters,
169+
hookResults: result
170+
});
171+
return result;
159172
};
160173

161174
const { result } = renderHook(query, { wrapper: ({ children }) => testWrapper({ children, db }) });
@@ -168,6 +181,12 @@ describe('useQuery', () => {
168181
{ timeout: 500, interval: 100 }
169182
);
170183

184+
// Verify that the fetching status was correlated to the parameters
185+
const firstResultEvent = hookEvents.find((event) => event.hookResults.data.length == 1);
186+
expect(firstResultEvent).toBeDefined();
187+
// Fetching should be false as soon as the results were made available
188+
expect(firstResultEvent?.hookResults.isFetching).false;
189+
171190
// Now update the parameter
172191
updateParameters(['second']);
173192

@@ -178,6 +197,135 @@ describe('useQuery', () => {
178197
},
179198
{ timeout: 500, interval: 100 }
180199
);
200+
201+
// finds the first result where the parameters have changed
202+
const secondFetchingEvent = hookEvents.find((event) => event.parameters[0] == 'second');
203+
expect(secondFetchingEvent).toBeDefined();
204+
// We should immediately report that we are fetching once we detect new params
205+
expect(secondFetchingEvent?.hookResults.isFetching).true;
206+
});
207+
208+
it('should react to updated queries (fast update)', async () => {
209+
const db = openPowerSync();
210+
211+
await db.execute(/* sql */ `
212+
INSERT INTO
213+
lists (id, name)
214+
VALUES
215+
(uuid (), 'first'),
216+
(uuid (), 'second')
217+
`);
218+
219+
type TestEvent = {
220+
parameters: string[];
221+
hookResults: QueryResult<any>;
222+
};
223+
224+
const hookEvents: TestEvent[] = [];
225+
226+
const queryObserver = new commonSdk.BaseObserver();
227+
const baseQuery = 'SELECT * FROM lists WHERE name = ?';
228+
const query = () => {
229+
const [query, setQuery] = React.useState({
230+
sql: baseQuery,
231+
params: ['']
232+
});
233+
234+
useEffect(() => {
235+
// allow updating the parameters externally
236+
queryObserver.registerListener({
237+
queryUpdated: (query) => setQuery(query)
238+
});
239+
}, []);
240+
241+
const result = useQuery(query.sql, query.params);
242+
hookEvents.push({
243+
parameters: query.params,
244+
hookResults: result
245+
});
246+
return result;
247+
};
248+
249+
const { result } = renderHook(query, { wrapper: ({ children }) => testWrapper({ children, db }) });
250+
// let the hook render once, and immediately update the query
251+
queryObserver.iterateListeners((l) =>
252+
l.queryUpdated?.({
253+
sql: baseQuery,
254+
params: ['first']
255+
})
256+
);
257+
258+
// We should only receive the first list due to the WHERE clause
259+
await vi.waitFor(
260+
() => {
261+
expect(result.current.data[0]?.name).toEqual('first');
262+
},
263+
{ timeout: 500, interval: 100 }
264+
);
265+
266+
// We changed the params before the initial query could execute (we changed the params immediately)
267+
// We should not see isLoading=false for the first set of params
268+
expect(
269+
hookEvents.find((event) => event.parameters[0] == '' && event.hookResults.isLoading == false)
270+
).toBeUndefined();
271+
expect(
272+
hookEvents.find(
273+
(event) =>
274+
event.parameters[0] == 'first' &&
275+
event.hookResults.isLoading == true &&
276+
event.hookResults.isFetching == true
277+
)
278+
).toBeDefined();
279+
280+
// Verify that the fetching status was correlated to the parameters
281+
const firstResultEvent = hookEvents.find((event) => event.hookResults.data.length == 1);
282+
expect(firstResultEvent).toBeDefined();
283+
// Fetching should be false as soon as the results were made available
284+
expect(firstResultEvent?.hookResults.isFetching).false;
285+
286+
// Now update the parameter with something which will cause an error
287+
queryObserver.iterateListeners((l) =>
288+
l.queryUpdated?.({
289+
sql: 'select this is a broken query',
290+
params: ['first']
291+
})
292+
);
293+
294+
// wait for the error to have been found
295+
await vi.waitFor(
296+
() => {
297+
console.log(result.current);
298+
expect(result.current.error).not.equal(null);
299+
expect(result.current.isFetching).false;
300+
},
301+
{ timeout: 500, interval: 100 }
302+
);
303+
304+
// The error should not be present before isFetching is false
305+
expect(
306+
hookEvents.find((event) => event.hookResults.error != null && event.hookResults.isFetching == true)
307+
).toBeUndefined();
308+
309+
queryObserver.iterateListeners((l) =>
310+
l.queryUpdated?.({
311+
sql: baseQuery,
312+
params: ['second']
313+
})
314+
);
315+
316+
// We should now only receive the second list due to the WHERE clause and updated parameter
317+
await vi.waitFor(
318+
() => {
319+
expect(result.current.data[0]?.name).toEqual('second');
320+
expect(result.current.error).null;
321+
},
322+
{ timeout: 500, interval: 100 }
323+
);
324+
325+
const secondFetchingEvent = hookEvents.find((event) => event.parameters[0] == 'second');
326+
expect(secondFetchingEvent).toBeDefined();
327+
// We should immediately report that we are fetching once we detect new params
328+
expect(secondFetchingEvent?.hookResults.isFetching).true;
181329
});
182330

183331
it('should execute compatible queries', async () => {

0 commit comments

Comments
 (0)