Skip to content

Commit 4d532d4

Browse files
authored
fix: prevent isFetching flickering during query parameter changes (#706)
1 parent afca6f5 commit 4d532d4

File tree

6 files changed

+193
-5
lines changed

6 files changed

+193
-5
lines changed

.changeset/real-dolls-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/common': patch
3+
---
4+
5+
Improved the abort handling for stale watched query results when the query/parameters change. This fixes the edge case where an already fetching query would handle a query change and briefly report `isFetching` being false before becoming true again.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export abstract class AbstractQueryProcessor<
8383
* Updates the underlying query.
8484
*/
8585
async updateSettings(settings: Settings) {
86+
this.abortController.abort();
8687
await this.initialized;
8788

8889
if (!this.state.isFetching && this.reportFetching) {
@@ -92,7 +93,7 @@ export abstract class AbstractQueryProcessor<
9293
}
9394

9495
this.options.watchOptions = settings;
95-
this.abortController.abort();
96+
9697
this.abortController = new AbortController();
9798
await this.runWithReporting(() =>
9899
this.linkQuery({
@@ -121,7 +122,6 @@ export abstract class AbstractQueryProcessor<
121122
if (typeof update.data !== 'undefined') {
122123
await this.iterateAsyncListenersWithError(async (l) => l.onData?.(this.state.data));
123124
}
124-
125125
await this.iterateAsyncListenersWithError(async (l) => l.onStateChange?.(this.state));
126126
}
127127

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export class DifferentialQueryProcessor<RowType>
236236
db.onChangeWithCallback(
237237
{
238238
onChange: async () => {
239-
if (this.closed) {
239+
if (this.closed || abortSignal.aborted) {
240240
return;
241241
}
242242
// This fires for each change of the relevant tables
@@ -256,6 +256,10 @@ export class DifferentialQueryProcessor<RowType>
256256
db: this.options.db
257257
});
258258

259+
if (abortSignal.aborted) {
260+
return;
261+
}
262+
259263
if (this.reportFetching) {
260264
partialStateUpdate.isFetching = false;
261265
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export class OnChangeQueryProcessor<Data> extends AbstractQueryProcessor<Data, W
5757
db.onChangeWithCallback(
5858
{
5959
onChange: async () => {
60-
if (this.closed) {
60+
if (this.closed || abortSignal.aborted) {
6161
return;
6262
}
6363
// This fires for each change of the relevant tables
@@ -77,6 +77,10 @@ export class OnChangeQueryProcessor<Data> extends AbstractQueryProcessor<Data, W
7777
db: this.options.db
7878
});
7979

80+
if (abortSignal.aborted) {
81+
return;
82+
}
83+
8084
if (this.reportFetching) {
8185
partialStateUpdate.isFetching = false;
8286
}

packages/react/tests/useQuery.test.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,91 @@ describe('useQuery', () => {
542542
// It should be the same data array reference, no update should have happened
543543
expect(result.current.data == previousData).false;
544544
});
545+
546+
it('should handle dependent query parameter changes with correct state transitions', async () => {
547+
const db = openPowerSync();
548+
549+
await db.execute(/* sql */ `
550+
INSERT INTO
551+
lists (id, name)
552+
VALUES
553+
(uuid (), 'item1')
554+
`);
555+
556+
// Track state transitions
557+
const stateTransitions: Array<{
558+
param: string | number;
559+
dataLength: number;
560+
isFetching: boolean;
561+
isLoading: boolean;
562+
}> = [];
563+
564+
const testHook = () => {
565+
// First query that provides the parameter - starts with 0, then returns 1
566+
const { data: paramData } = useQuery('SELECT 1 as result;', []);
567+
const param = paramData?.[0]?.result ?? 0;
568+
569+
// Second query that depends on the first query's result
570+
const { data, isFetching, isLoading } = useQuery('SELECT * FROM lists LIMIT ?', [param]);
571+
572+
const currentState = {
573+
param: param,
574+
dataLength: data?.length || 0,
575+
isFetching: isFetching,
576+
isLoading: isLoading
577+
};
578+
579+
stateTransitions.push(currentState);
580+
return currentState;
581+
};
582+
583+
const { result } = renderHook(() => testHook(), {
584+
wrapper: ({ children }) => testWrapper({ children, db })
585+
});
586+
587+
// Wait for final state
588+
await waitFor(
589+
() => {
590+
const { current } = result;
591+
expect(current.isLoading).toEqual(false);
592+
expect(current.isFetching).toEqual(false);
593+
expect(current.param).toEqual(1);
594+
expect(current.dataLength).toEqual(1);
595+
},
596+
{ timeout: 500, interval: 100 }
597+
);
598+
599+
// Find the index where param changes from 0 to 1
600+
let beforeParamChangeIndex = 0;
601+
for (const transition of stateTransitions) {
602+
if (transition.param === 1) {
603+
beforeParamChangeIndex = stateTransitions.indexOf(transition) - 1;
604+
break;
605+
}
606+
}
607+
608+
const indexMultiplier = isStrictMode ? 2 : 1; // StrictMode causes 1 extra render per state
609+
const initialState = stateTransitions[beforeParamChangeIndex];
610+
expect(initialState).toBeDefined();
611+
expect(initialState?.param).toEqual(0);
612+
expect(initialState?.dataLength).toEqual(0);
613+
expect(initialState?.isFetching).toEqual(true);
614+
expect(initialState?.isLoading).toEqual(true);
615+
616+
const paramChangedState = stateTransitions[beforeParamChangeIndex + 1 * indexMultiplier];
617+
expect(paramChangedState).toBeDefined();
618+
expect(paramChangedState?.param).toEqual(1);
619+
expect(paramChangedState?.dataLength).toEqual(0);
620+
expect(paramChangedState?.isFetching).toEqual(true);
621+
expect(paramChangedState?.isLoading).toEqual(true);
622+
623+
const finalState = stateTransitions[beforeParamChangeIndex + 2 * indexMultiplier];
624+
expect(finalState).toBeDefined();
625+
expect(finalState.param).toEqual(1);
626+
expect(finalState.dataLength).toEqual(1);
627+
expect(finalState.isFetching).toEqual(false);
628+
expect(finalState.isLoading).toEqual(false);
629+
});
545630
});
546631
});
547632

packages/vue/tests/useQuery.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as commonSdk from '@powersync/common';
22
import { PowerSyncDatabase } from '@powersync/web';
33
import flushPromises from 'flush-promises';
44
import { describe, expect, it, onTestFinished, vi } from 'vitest';
5-
import { isProxy, isRef, ref } from 'vue';
5+
import { computed, isProxy, isRef, ref, watchEffect } from 'vue';
66
import { createPowerSyncPlugin } from '../src/composables/powerSync';
77
import { useQuery } from '../src/composables/useQuery';
88
import { useWatchedQuerySubscription } from '../src/composables/useWatchedQuerySubscription';
@@ -233,4 +233,94 @@ describe('useQuery', () => {
233233
'PowerSync failed to fetch data: You cannot pass parameters to a compiled query.'
234234
);
235235
});
236+
237+
it('should handle dependent query parameter changes with correct state transitions', async () => {
238+
const db = openPowerSync();
239+
240+
await db.execute(/* sql */ `
241+
INSERT INTO
242+
lists (id, name)
243+
VALUES
244+
(uuid (), 'item1')
245+
`);
246+
247+
// Track state transitions
248+
const stateTransitions: Array<{
249+
param: string | number;
250+
dataLength: number;
251+
isFetching: boolean;
252+
isLoading: boolean;
253+
}> = [];
254+
255+
const [state] = withPowerSyncSetup(() => {
256+
// First query that provides the parameter - starts with 0, then returns 1
257+
const paramQuery = useQuery('SELECT 1 as result;', []);
258+
const param = computed(() => paramQuery.data.value?.[0]?.result ?? 0);
259+
260+
// Second query that depends on the first query's result
261+
const dataQuery = useQuery(
262+
computed(() => 'SELECT * FROM lists LIMIT ?'),
263+
computed(() => [param.value])
264+
);
265+
266+
// Track state changes
267+
watchEffect(() => {
268+
const currentState = {
269+
param: param.value,
270+
dataLength: dataQuery.data.value?.length || 0,
271+
isFetching: dataQuery.isFetching.value,
272+
isLoading: dataQuery.isLoading.value
273+
};
274+
stateTransitions.push(currentState);
275+
});
276+
277+
return {
278+
paramData: paramQuery.data,
279+
data: dataQuery.data,
280+
isFetching: dataQuery.isFetching,
281+
isLoading: dataQuery.isLoading,
282+
param
283+
};
284+
});
285+
286+
// Wait for final state
287+
await vi.waitFor(
288+
() => {
289+
expect(state.isLoading.value).toEqual(false);
290+
expect(state.isFetching.value).toEqual(false);
291+
expect(state.data.value?.length).toEqual(1);
292+
expect(state.paramData.value[0].result).toEqual(1);
293+
},
294+
{ timeout: 1000 }
295+
);
296+
297+
// Find the index where param changes from 0 to 1
298+
let beforeParamChangeIndex = 0;
299+
for (const transition of stateTransitions) {
300+
if (transition.param === 1) {
301+
beforeParamChangeIndex = stateTransitions.indexOf(transition) - 1;
302+
break;
303+
}
304+
}
305+
const initialState = stateTransitions[beforeParamChangeIndex];
306+
expect(initialState).toBeDefined();
307+
expect(initialState?.param).toEqual(0);
308+
expect(initialState?.dataLength).toEqual(0);
309+
expect(initialState?.isFetching).toEqual(true);
310+
expect(initialState?.isLoading).toEqual(true);
311+
312+
const paramChangedState = stateTransitions[beforeParamChangeIndex + 1];
313+
expect(paramChangedState).toBeDefined();
314+
expect(paramChangedState?.param).toEqual(1);
315+
expect(paramChangedState?.dataLength).toEqual(0);
316+
expect(paramChangedState?.isFetching).toEqual(true);
317+
expect(paramChangedState?.isLoading).toEqual(true);
318+
319+
const finalState = stateTransitions[beforeParamChangeIndex + 2];
320+
expect(finalState).toBeDefined();
321+
expect(finalState.param).toEqual(1);
322+
expect(finalState.dataLength).toEqual(1);
323+
expect(finalState.isFetching).toEqual(false);
324+
expect(finalState.isLoading).toEqual(false);
325+
});
236326
});

0 commit comments

Comments
 (0)