diff --git a/.changeset/slow-melons-raise.md b/.changeset/slow-melons-raise.md new file mode 100644 index 000000000..4b7c73b1b --- /dev/null +++ b/.changeset/slow-melons-raise.md @@ -0,0 +1,5 @@ +--- +'@powersync/react': patch +--- + +Refactor useQuery hook to avoid calling internal hooks conditionally. diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts index 7e938294e..53b7c6003 100644 --- a/packages/react/src/hooks/watched/useQuery.ts +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -61,26 +61,26 @@ export function useQuery( return { isLoading: false, isFetching: false, data: [], error: new Error('PowerSync not configured.') }; } const { parsedQuery, queryChanged } = constructCompatibleQuery(query, parameters, options); + const runOnce = options?.runQueryOnce == true; + const single = useSingleQuery({ + query: parsedQuery, + powerSync, + queryChanged, + active: runOnce + }); + const watched = useWatchedQuery({ + query: parsedQuery, + powerSync, + queryChanged, + options: { + reportFetching: options.reportFetching, + // Maintains backwards compatibility with previous versions + // Differentiation is opt-in by default + // We emit new data for each table change by default. + rowComparator: options.rowComparator + }, + active: !runOnce + }); - switch (options?.runQueryOnce) { - case true: - return useSingleQuery({ - query: parsedQuery, - powerSync, - queryChanged - }); - default: - return useWatchedQuery({ - query: parsedQuery, - powerSync, - queryChanged, - options: { - reportFetching: options.reportFetching, - // Maintains backwards compatibility with previous versions - // Differentiation is opt-in by default - // We emit new data for each table change by default. - rowComparator: options.rowComparator - } - }); - } + return runOnce ? single : watched; } diff --git a/packages/react/src/hooks/watched/useSingleQuery.ts b/packages/react/src/hooks/watched/useSingleQuery.ts index 2c6c4cde2..be434c0d4 100644 --- a/packages/react/src/hooks/watched/useSingleQuery.ts +++ b/packages/react/src/hooks/watched/useSingleQuery.ts @@ -2,8 +2,11 @@ import React from 'react'; import { QueryResult } from './watch-types.js'; import { InternalHookOptions } from './watch-utils.js'; +/** + * @internal not exported from `index.ts` + */ export const useSingleQuery = (options: InternalHookOptions): QueryResult => { - const { query, powerSync, queryChanged } = options; + const { query, powerSync, queryChanged, active } = options; const [output, setOutputState] = React.useState>({ isLoading: true, @@ -46,13 +49,16 @@ export const useSingleQuery = (options: InternalHookOptions { - const abortController = new AbortController(); - runQuery(abortController.signal); - return () => { - abortController.abort(); - }; - }, [powerSync, queryChanged]); + if (active) { + const abortController = new AbortController(); + runQuery(abortController.signal); + return () => { + abortController.abort(); + }; + } + }, [powerSync, active, queryChanged]); return { ...output, diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index 7e833fa46..b26cbf000 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { useWatchedQuerySubscription } from './useWatchedQuerySubscription.js'; +import { useNullableWatchedQuerySubscription } from './useWatchedQuerySubscription.js'; import { DifferentialHookOptions, QueryResult, ReadonlyQueryResult } from './watch-types.js'; import { InternalHookOptions } from './watch-utils.js'; @@ -14,9 +14,13 @@ import { InternalHookOptions } from './watch-utils.js'; export const useWatchedQuery = ( options: InternalHookOptions & { options: DifferentialHookOptions } ): QueryResult | ReadonlyQueryResult => { - const { query, powerSync, queryChanged, options: hookOptions } = options; + const { query, powerSync, queryChanged, options: hookOptions, active } = options; + + function createWatchedQuery() { + if (!active) { + return null; + } - const createWatchedQuery = React.useCallback(() => { const watch = hookOptions.rowComparator ? powerSync.customQuery(query).differentialWatch({ rowComparator: hookOptions.rowComparator, @@ -28,20 +32,20 @@ export const useWatchedQuery = ( throttleMs: hookOptions.throttleMs }); return watch; - }, []); + } const [watchedQuery, setWatchedQuery] = React.useState(createWatchedQuery); React.useEffect(() => { - watchedQuery.close(); + watchedQuery?.close(); setWatchedQuery(createWatchedQuery); - }, [powerSync]); + }, [powerSync, active]); // Indicates that the query will be re-fetched due to a change in the query. // Used when `isFetching` hasn't been set to true yet due to React execution. React.useEffect(() => { if (queryChanged) { - watchedQuery.updateSettings({ + watchedQuery?.updateSettings({ query, throttleMs: hookOptions.throttleMs, reportFetching: hookOptions.reportFetching @@ -49,5 +53,5 @@ export const useWatchedQuery = ( } }, [queryChanged]); - return useWatchedQuerySubscription(watchedQuery); + return useNullableWatchedQuerySubscription(watchedQuery); }; diff --git a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts index c8cfa9252..4403f25b6 100644 --- a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts +++ b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts @@ -21,18 +21,31 @@ export const useWatchedQuerySubscription = < >( query: Query ): Query['state'] => { - const [output, setOutputState] = React.useState(query.state); + return useNullableWatchedQuerySubscription(query); +}; + +/** + * @internal + */ +export const useNullableWatchedQuerySubscription = < + ResultType = unknown, + Query extends WatchedQuery = WatchedQuery +>( + query: Query | null +): Query['state'] | undefined => { + const [output, setOutputState] = React.useState(query?.state); + // @ts-ignore: Complains about not all code paths returning a value React.useEffect(() => { - const dispose = query.registerListener({ - onStateChange: (state) => { - setOutputState({ ...state }); - } - }); + if (query) { + setOutputState(query.state); - return () => { - dispose(); - }; + return query.registerListener({ + onStateChange: (state) => { + setOutputState({ ...state }); + } + }); + } }, [query]); return output; diff --git a/packages/react/src/hooks/watched/watch-utils.ts b/packages/react/src/hooks/watched/watch-utils.ts index 421f7eea7..fb482f928 100644 --- a/packages/react/src/hooks/watched/watch-utils.ts +++ b/packages/react/src/hooks/watched/watch-utils.ts @@ -7,6 +7,7 @@ export type InternalHookOptions = { query: WatchCompatibleQuery; powerSync: AbstractPowerSyncDatabase; queryChanged: boolean; + active: boolean; }; export const checkQueryChanged = (query: WatchCompatibleQuery, options: AdditionalOptions) => { diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 297e1d21c..65ce53b88 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -309,6 +309,53 @@ describe('useQuery', () => { expect(result.current.data == previousData).false; }); + it('should be able to switch between single and watched query', async () => { + const db = openPowerSync(); + const wrapper = ({ children }) => {children}; + + let changeRunOnce: React.Dispatch>; + const { result } = renderHook( + () => { + const [runOnce, setRunOnce] = React.useState(true); + changeRunOnce = setRunOnce; + + return useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], { runQueryOnce: runOnce }); + }, + { wrapper } + ); + + // Wait for the query to run once. + await waitFor( + async () => { + const { current } = result; + expect(current.isLoading).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // Then switch to watched queries. + act(() => changeRunOnce(false)); + expect(result.current.isLoading).toBeTruthy(); + + await waitFor( + async () => { + const { current } = result; + expect(current.isLoading).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // Because we're watching, this should trigger an update. + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['aname']); + await waitFor( + async () => { + const { current } = result; + expect(current.data.length).toEqual(1); + }, + { timeout: 500, interval: 100 } + ); + }); + it('should use an existing WatchedQuery instance', async () => { const db = openPowerSync();