From f11f817c0f8eea01b863cc5df2a914e71bfca4e2 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 2 Sep 2025 15:50:12 +0200 Subject: [PATCH 1/3] Fixed issue where `useQuery()` would not correctly trigger a new execution when the query or parameters changed while using StrictMode. --- .changeset/heavy-coats-serve.md | 5 + .../src/hooks/watched/useWatchedQuery.ts | 10 +- packages/react/tests/useQuery.test.tsx | 972 +++++++++--------- 3 files changed, 489 insertions(+), 498 deletions(-) create mode 100644 .changeset/heavy-coats-serve.md diff --git a/.changeset/heavy-coats-serve.md b/.changeset/heavy-coats-serve.md new file mode 100644 index 000000000..27e363336 --- /dev/null +++ b/.changeset/heavy-coats-serve.md @@ -0,0 +1,5 @@ +--- +'@powersync/react': patch +--- + +Fixed issue where `useQuery()` would not correctly trigger a new execution when the query or parameters changed while using StrictMode. diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index b26cbf000..e0fd6ed9e 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -16,6 +16,11 @@ export const useWatchedQuery = ( ): QueryResult | ReadonlyQueryResult => { const { query, powerSync, queryChanged, options: hookOptions, active } = options; + const queryChangeRef = React.useRef(false); + if (queryChanged && !queryChangeRef.current) { + queryChangeRef.current = true; + } + function createWatchedQuery() { if (!active) { return null; @@ -44,14 +49,15 @@ export const useWatchedQuery = ( // 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) { + if (queryChangeRef.current) { watchedQuery?.updateSettings({ query, throttleMs: hookOptions.throttleMs, reportFetching: hookOptions.reportFetching }); + queryChangeRef.current = false; } - }, [queryChanged]); + }, [queryChangeRef.current]); return useNullableWatchedQuerySubscription(watchedQuery); }; diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 72cbe1164..c4eb35cc1 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -35,541 +35,521 @@ describe('useQuery', () => { cleanup(); // Cleanup the DOM after each test }); - it('should error when PowerSync is not set', async () => { - const { result } = renderHook(() => useQuery('SELECT * from lists')); - const currentResult = result.current; - expect(currentResult.error).toEqual(Error('PowerSync not configured.')); - expect(currentResult.isLoading).toEqual(false); - expect(currentResult.data).toEqual([]); - }); - - it('should set isLoading to true on initial load', async () => { - const wrapper = ({ children }) => ( - // Placeholder use for `React` to prevent import cleanup from removing the React import - - {children} - - ); - - const { result } = renderHook(() => useQuery('SELECT * from lists'), { wrapper }); - const currentResult = result.current; - expect(currentResult.isLoading).toEqual(true); - }); - - it('should run the query once if runQueryOnce flag is set', async () => { - const db = openPowerSync(); - const onChangeSpy = vi.spyOn(db, 'onChangeWithCallback'); - const getAllSpy = vi.spyOn(db, 'getAll'); - - const wrapper = ({ children }) => {children}; - - await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['list1']); - - const { result } = renderHook(() => useQuery('SELECT name from lists', [], { runQueryOnce: true }), { wrapper }); - expect(result.current.isLoading).toEqual(true); - - await waitFor( - async () => { - const currentResult = result.current; - expect(currentResult.data).toEqual([{ name: 'list1' }]); - expect(currentResult.isLoading).toEqual(false); - expect(currentResult.isFetching).toEqual(false); - expect(onChangeSpy).not.toHaveBeenCalled(); - expect(getAllSpy).toHaveBeenCalledTimes(1); - }, - { timeout: 1000 } - ); - }); - - it('should rerun the query when refresh is used', async () => { - const db = openPowerSync(); - const getAllSpy = vi.spyOn(db, 'getAll'); - - const wrapper = ({ children }) => {children}; - - const { result } = renderHook(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }), { wrapper }); - - expect(result.current.isLoading).toEqual(true); - - let refresh; - - await waitFor( - () => { + const baseWrapper = ({ children, db }) => ( + {children} + ); + + const testCases = [ + { + mode: 'normal', + wrapper: baseWrapper + }, + { + mode: 'StrictMode', + wrapper: ({ children, db }) => {baseWrapper({ children, db })} + } + ]; + + testCases.forEach(({ mode, wrapper: testWrapper }) => { + const isStrictMode = mode === 'StrictMode'; + + describe(`in ${mode}`, () => { + it('should set isLoading to true on initial load', async () => { + const db = openPowerSync(); + const { result } = renderHook(() => useQuery('SELECT * from lists'), { + wrapper: ({ children }) => testWrapper({ children, db }) + }); const currentResult = result.current; - refresh = currentResult.refresh; - expect(currentResult.isLoading).toEqual(false); - expect(getAllSpy).toHaveBeenCalledTimes(1); - }, - { timeout: 500, interval: 100 } - ); - - await act(() => refresh()); - - expect(getAllSpy).toHaveBeenCalledTimes(2); - }); - - it('should set error when error occurs and runQueryOnce flag is set', async () => { - const db = openPowerSync(); + expect(currentResult.isLoading).toEqual(true); + }); - const wrapper = ({ children }) => {children}; + it('should set error when error occurs and runQueryOnce flag is set', async () => { + const db = openPowerSync(); - const { result } = renderHook(() => useQuery('SELECT * from faketable', [], { runQueryOnce: true }), { wrapper }); + const { result } = renderHook(() => useQuery('SELECT * from faketable', [], { runQueryOnce: true }), { + wrapper: ({ children }) => testWrapper({ children, db }) + }); - await waitFor( - async () => { - expect(result.current.error?.message).equal('no such table: faketable'); - }, - { timeout: 500, interval: 100 } - ); - }); + await waitFor( + async () => { + expect(result.current.error?.message).equal('no such table: faketable'); + }, + { timeout: 500, interval: 100 } + ); + }); - it('should set error when error occurs with watched query', async () => { - const db = openPowerSync(); + it('should set error when error occurs with watched query', async () => { + const db = openPowerSync(); - const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useQuery('SELECT * from faketable', []), { + wrapper: ({ children }) => testWrapper({ children, db }) + }); - const { result } = renderHook(() => useQuery('SELECT * from faketable', []), { wrapper }); + await waitFor( + async () => { + expect(result.current.error?.message).equals('no such table: faketable'); + }, + { timeout: 500, interval: 100 } + ); + }); - await waitFor( - async () => { - expect(result.current.error?.message).equals('no such table: faketable'); - }, - { timeout: 500, interval: 100 } - ); - }); + it.only('should rerun the query when refresh is used', async () => { + const db = openPowerSync(); + const getAllSpy = vi.spyOn(db, 'getAll'); - it('should accept compilable queries', async () => { - const db = openPowerSync(); + const { result } = renderHook(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }), { + wrapper: ({ children }) => testWrapper({ children, db }) + }); - const wrapper = ({ children }) => {children}; + expect(result.current.isLoading).toEqual(true); - const { result } = renderHook( - () => useQuery({ execute: () => [] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }), - { wrapper } - ); - const currentResult = result.current; - expect(currentResult.isLoading).toEqual(true); - }); + let refresh; - it('should execute compatible queries', async () => { - const db = openPowerSync(); + await waitFor( + () => { + const currentResult = result.current; + refresh = currentResult.refresh; + expect(currentResult.isLoading).toEqual(false); + expect(getAllSpy).toHaveBeenCalledTimes(isStrictMode ? 2 : 1); + }, + { timeout: 500, interval: 100 } + ); - const wrapper = ({ children }) => {children}; + await act(() => refresh()); - const query = () => - useQuery({ - execute: () => [{ test: 'custom' }] as any, - compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) + expect(getAllSpy).toHaveBeenCalledTimes(isStrictMode ? 3 : 2); }); - const { result } = renderHook(query, { wrapper }); - - await vi.waitFor( - () => { - expect(result.current.data[0]?.test).toEqual('custom'); - }, - { timeout: 500, interval: 100 } - ); - }); - it('should react to updated queries (Explicit Drizzle DB)', async () => { - const db = openPowerSync(); - - const lists = sqliteTable('lists', { - id: text('id'), - name: text('name') - }); + it('should accept compilable queries', async () => { + const db = openPowerSync(); - const drizzleDb = wrapPowerSyncWithDrizzle(db, { - schema: { - lists - } - }); - - const wrapper = ({ children }) => {children}; + const { result } = renderHook( + () => useQuery({ execute: () => [] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }), + { wrapper: ({ children }) => testWrapper({ children, db }) } + ); + const currentResult = result.current; + expect(currentResult.isLoading).toEqual(true); + }); - let updateParameters = (params: string): void => {}; - const newParametersPromise = new Promise((resolve) => { - updateParameters = resolve; - }); + it('should react to updated queries (simple update)', async () => { + const db = openPowerSync(); - await db.execute(/* sql */ ` - INSERT INTO - lists (id, name) - VALUES - (uuid (), 'first'), - (uuid (), 'second') - `); - - const query = () => { - const [name, setName] = React.useState('first'); - const drizzleQuery = drizzleDb.select().from(lists).where(eq(lists.name, name)); - - useEffect(() => { - // allow updating the parameters externally - newParametersPromise.then((params) => setName(params)); - }, []); - - return useQuery(toCompilableQuery(drizzleQuery)); - }; - - const { result } = renderHook(query, { wrapper }); - - // We should only receive the first list due to the WHERE clause - await vi.waitFor( - () => { - expect(result.current.data[0]?.name).toEqual('first'); - }, - { timeout: 500, interval: 100 } - ); - - // Now update the parameter - updateParameters('second'); - - // We should now only receive the second list due to the WHERE clause and updated parameter - await vi.waitFor( - () => { - expect(result.current.data[0]?.name).toEqual('second'); - }, - { timeout: 500, interval: 100 } - ); - }); + let updateParameters = (params: string[]): void => {}; + const newParametersPromise = new Promise((resolve) => { + updateParameters = resolve; + }); - it('should react to updated queries', async () => { - const db = openPowerSync(); + await db.execute(/* sql */ ` + INSERT INTO + lists (id, name) + VALUES + (uuid (), 'first'), + (uuid (), 'second') + `); - const wrapper = ({ children }) => {children}; + const query = () => { + const [parameters, setParameters] = React.useState(['first']); - let updateParameters = (params: string[]): void => {}; - const newParametersPromise = new Promise((resolve) => { - updateParameters = resolve; - }); + useEffect(() => { + // allow updating the parameters externally + newParametersPromise.then((params) => setParameters(params)); + }, []); - await db.execute(/* sql */ ` - INSERT INTO - lists (id, name) - VALUES - (uuid (), 'first'), - (uuid (), 'second') - `); - - const query = () => { - const [parameters, setParameters] = React.useState(['first']); - - useEffect(() => { - // allow updating the parameters externally - newParametersPromise.then((params) => setParameters(params)); - }, []); - - const query = React.useMemo(() => { - return { - execute: () => db.getAll<{ name: string }>('SELECT * FROM lists WHERE name = ?', parameters), - compile: () => ({ sql: 'SELECT * FROM lists WHERE name = ?', parameters }) + return useQuery('SELECT * FROM lists WHERE name = ?', parameters); }; - }, [parameters]); - - return useQuery(query); - }; - - const { result } = renderHook(query, { wrapper }); - - // We should only receive the first list due to the WHERE clause - await vi.waitFor( - () => { - expect(result.current.data[0]?.name).toEqual('first'); - }, - { timeout: 500, interval: 100 } - ); - - // Now update the parameter - updateParameters(['second']); - - // We should now only receive the second list due to the WHERE clause and updated parameter - await vi.waitFor( - () => { - expect(result.current.data[0]?.name).toEqual('second'); - }, - { timeout: 500, interval: 100 } - ); - }); - - it('should react to updated queries (simple update)', async () => { - const db = openPowerSync(); - - const wrapper = ({ children }) => {children}; - let updateParameters = (params: string[]): void => {}; - const newParametersPromise = new Promise((resolve) => { - updateParameters = resolve; - }); + const { result } = renderHook(query, { wrapper: ({ children }) => testWrapper({ children, db }) }); + + // We should only receive the first list due to the WHERE clause + await vi.waitFor( + () => { + expect(result.current.data[0]?.name).toEqual('first'); + }, + { timeout: 500, interval: 100 } + ); + + // Now update the parameter + updateParameters(['second']); + + // We should now only receive the second list due to the WHERE clause and updated parameter + await vi.waitFor( + () => { + expect(result.current.data[0]?.name).toEqual('second'); + }, + { timeout: 500, interval: 100 } + ); + }); - await db.execute(/* sql */ ` - INSERT INTO - lists (id, name) - VALUES - (uuid (), 'first'), - (uuid (), 'second') - `); - - const query = () => { - const [parameters, setParameters] = React.useState(['first']); - - useEffect(() => { - // allow updating the parameters externally - newParametersPromise.then((params) => setParameters(params)); - }, []); - - return useQuery('SELECT * FROM lists WHERE name = ?', parameters); - }; - - const { result } = renderHook(query, { wrapper }); - - // We should only receive the first list due to the WHERE clause - await vi.waitFor( - () => { - expect(result.current.data[0]?.name).toEqual('first'); - }, - { timeout: 500, interval: 100 } - ); - - // Now update the parameter - updateParameters(['second']); - - // We should now only receive the second list due to the WHERE clause and updated parameter - await vi.waitFor( - () => { - expect(result.current.data[0]?.name).toEqual('second'); - }, - { timeout: 500, interval: 100 } - ); - }); + it('should execute compatible queries', async () => { + const db = openPowerSync(); + + const query = () => + useQuery({ + execute: () => [{ test: 'custom' }] as any, + compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) + }); + const { result } = renderHook(query, { wrapper: ({ children }) => testWrapper({ children, db }) }); + + await vi.waitFor( + () => { + expect(result.current.data[0]?.test).toEqual('custom'); + }, + { timeout: 500, interval: 100 } + ); + }); - it('should show an error if parsing the query results in an error', async () => { - const db = openPowerSync(); + it('should react to updated queries (Explicit Drizzle DB)', async () => { + const db = openPowerSync(); - const wrapper = ({ children }) => {children}; + const lists = sqliteTable('lists', { + id: text('id'), + name: text('name') + }); - const { result } = renderHook( - () => - useQuery({ - execute: () => [] as any, - compile: () => { - throw new Error('error'); + const drizzleDb = wrapPowerSyncWithDrizzle(db, { + schema: { + lists } - }), - { wrapper } - ); + }); + + let updateParameters = (params: string): void => {}; + const newParametersPromise = new Promise((resolve) => { + updateParameters = resolve; + }); + + await db.execute(/* sql */ ` + INSERT INTO + lists (id, name) + VALUES + (uuid (), 'first'), + (uuid (), 'second') + `); + + const query = () => { + const [name, setName] = React.useState('first'); + const drizzleQuery = drizzleDb.select().from(lists).where(eq(lists.name, name)); + + useEffect(() => { + // allow updating the parameters externally + newParametersPromise.then((params) => setName(params)); + }, []); + + return useQuery(toCompilableQuery(drizzleQuery)); + }; - await waitFor( - async () => { - const currentResult = result.current; - expect(currentResult.isLoading).toEqual(false); - expect(currentResult.isFetching).toEqual(false); - expect(currentResult.data).toEqual([]); - expect(currentResult.error).toEqual(Error('error')); - }, - { timeout: 500, interval: 100 } - ); - }); + const { result } = renderHook(query, { wrapper: ({ children }) => testWrapper({ children, db }) }); + + // We should only receive the first list due to the WHERE clause + await vi.waitFor( + () => { + expect(result.current.data[0]?.name).toEqual('first'); + }, + { timeout: 500, interval: 100 } + ); + + // Now update the parameter + updateParameters('second'); + + // We should now only receive the second list due to the WHERE clause and updated parameter + await vi.waitFor( + () => { + expect(result.current.data[0]?.name).toEqual('second'); + }, + { timeout: 500, interval: 100 } + ); + }); - it('should emit result data when query changes', async () => { - const db = openPowerSync(); - const wrapper = ({ children }) => {children}; - const { result } = renderHook( - () => - useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], { - rowComparator: { - keyBy: (item) => item.id, - compareBy: (item) => JSON.stringify(item) - } - }), - { wrapper } - ); - - expect(result.current.isLoading).toEqual(true); - - await waitFor( - async () => { - const { current } = result; - expect(current.isLoading).toEqual(false); - }, - { timeout: 500, interval: 100 } - ); - - // 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 } - ); - - const { - current: { data } - } = result; - - const deferred = pDefer(); - - const baseGetAll = db.getAll; - vi.spyOn(db, 'getAll').mockImplementation(async (sql, params) => { - // Allow pausing this call in order to test isFetching - await deferred.promise; - return baseGetAll.call(db, sql, params); - }); + it('should react to updated queries', async () => { + const db = openPowerSync(); + + let updateParameters = (params: string[]): void => {}; + const newParametersPromise = new Promise((resolve) => { + updateParameters = resolve; + }); + + await db.execute(/* sql */ ` + INSERT INTO + lists (id, name) + VALUES + (uuid (), 'first'), + (uuid (), 'second') + `); + + const query = () => { + const [parameters, setParameters] = React.useState(['first']); + + useEffect(() => { + // allow updating the parameters externally + newParametersPromise.then((params) => setParameters(params)); + }, []); + + const query = React.useMemo(() => { + return { + execute: () => db.getAll<{ name: string }>('SELECT * FROM lists WHERE name = ?', parameters), + compile: () => ({ sql: 'SELECT * FROM lists WHERE name = ?', parameters }) + }; + }, [parameters]); + + return useQuery(query); + }; - // The number of calls should be incremented after we make a change - await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['anothername']); - - await waitFor( - () => { - expect(result.current.isFetching).toEqual(true); - }, - { timeout: 500, interval: 100 } - ); - - // Allow the result to be returned - deferred.resolve(); - - // We should still read the data from the DB - await waitFor( - () => { - expect(result.current.isFetching).toEqual(false); - }, - { timeout: 500, interval: 100 } - ); - - // The data reference should be the same as the previous time - expect(data == result.current.data).toEqual(true); - }); + const { result } = renderHook(query, { wrapper: ({ children }) => testWrapper({ children, db }) }); + + // We should only receive the first list due to the WHERE clause + await vi.waitFor( + () => { + expect(result.current.data[0]?.name).toEqual('first'); + }, + { timeout: 500, interval: 100 } + ); + + // Now update the parameter + updateParameters(['second']); + + // We should now only receive the second list due to the WHERE clause and updated parameter + await vi.waitFor( + () => { + expect(result.current.data[0]?.name).toEqual('second'); + }, + { timeout: 500, interval: 100 } + ); + }); - // Verifies backwards compatibility with the previous implementation (no comparison) - it('should emit result data when data changes when not using rowComparator', async () => { - const db = openPowerSync(); - const wrapper = ({ children }) => {children}; - const { result } = renderHook(() => useQuery('SELECT * FROM lists WHERE name = ?', ['aname']), { wrapper }); - - expect(result.current.isLoading).toEqual(true); - - await waitFor( - async () => { - const { current } = result; - expect(current.isLoading).toEqual(false); - }, - { timeout: 500, interval: 100 } - ); - - // This should trigger an update - await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['aname']); - - // Keep track of the previous data reference - let previousData = result.current.data; - await waitFor( - async () => { - const { current } = result; - expect(current.data.length).toEqual(1); - previousData = current.data; - }, - { timeout: 500, interval: 100 } - ); - - // This should still trigger an update since the underlying tables changed. - await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['noname']); - - // It's difficult to assert no update happened, but we can wait a bit - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // It should be the same data array reference, no update should have happened - expect(result.current.data == previousData).false; - }); + it('should show an error if parsing the query results in an error', async () => { + const db = openPowerSync(); + + const { result } = renderHook( + () => + useQuery({ + execute: () => [] as any, + compile: () => { + throw new Error('error'); + } + }), + { wrapper: ({ children }) => testWrapper({ children, db }) } + ); + + await waitFor( + async () => { + const currentResult = result.current; + expect(currentResult.isLoading).toEqual(false); + expect(currentResult.isFetching).toEqual(false); + expect(currentResult.data).toEqual([]); + expect(currentResult.error).toEqual(Error('error')); + }, + { timeout: 500, interval: 100 } + ); + }); - 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(); + + // This query can be instantiated once and reused. + // The query retains it's state and will not re-fetch the data unless the result changes. + // This is useful for queries that are used in multiple components. + const listsQuery = db + .query<{ id: string; name: string }>({ sql: `SELECT * FROM lists`, parameters: [] }) + .differentialWatch(); + + const { result } = renderHook(() => useWatchedQuerySubscription(listsQuery), { + wrapper: ({ children }) => testWrapper({ children, db }) + }); + + expect(result.current.isLoading).toEqual(true); + + await waitFor( + async () => { + const { current } = result; + expect(current.isLoading).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // 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 } + ); + + // now use the same query again, the result should be available immediately + const { result: newResult } = renderHook(() => useWatchedQuerySubscription(listsQuery), { + wrapper: ({ children }) => testWrapper({ children, db }) + }); + expect(newResult.current.isLoading).toEqual(false); + expect(newResult.current.data.length).toEqual(1); + }); - it('should use an existing WatchedQuery instance', async () => { - const db = openPowerSync(); + it('should be able to switch between single and watched query', async () => { + const db = openPowerSync(); + + 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: ({ children }) => testWrapper({ children, db }) } + ); + + // 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 } + ); + }); - // This query can be instantiated once and reused. - // The query retains it's state and will not re-fetch the data unless the result changes. - // This is useful for queries that are used in multiple components. - const listsQuery = db - .query<{ id: string; name: string }>({ sql: `SELECT * FROM lists`, parameters: [] }) - .differentialWatch(); + it('should emit result data when query changes', async () => { + const db = openPowerSync(); + const { result } = renderHook( + () => + useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], { + rowComparator: { + keyBy: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } + }), + { wrapper: ({ children }) => testWrapper({ children, db }) } + ); + + expect(result.current.isLoading).toEqual(true); + + await waitFor( + async () => { + const { current } = result; + expect(current.isLoading).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // 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 } + ); + + const { + current: { data } + } = result; + + const deferred = pDefer(); + + const baseGetAll = db.getAll; + vi.spyOn(db, 'getAll').mockImplementation(async (sql, params) => { + // Allow pausing this call in order to test isFetching + await deferred.promise; + return baseGetAll.call(db, sql, params); + }); + + // The number of calls should be incremented after we make a change + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['anothername']); + + await waitFor( + () => { + expect(result.current.isFetching).toEqual(true); + }, + { timeout: 500, interval: 100 } + ); + + // Allow the result to be returned + deferred.resolve(); + + // We should still read the data from the DB + await waitFor( + () => { + expect(result.current.isFetching).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // The data reference should be the same as the previous time + expect(data == result.current.data).toEqual(true); + }); - const wrapper = ({ children }) => {children}; - const { result } = renderHook(() => useWatchedQuerySubscription(listsQuery), { - wrapper + // Verifies backwards compatibility with the previous implementation (no comparison) + it('should emit result data when data changes when not using rowComparator', async () => { + const db = openPowerSync(); + const { result } = renderHook(() => useQuery('SELECT * FROM lists WHERE name = ?', ['aname']), { + wrapper: ({ children }) => testWrapper({ children, db }) + }); + + expect(result.current.isLoading).toEqual(true); + + await waitFor( + async () => { + const { current } = result; + expect(current.isLoading).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // This should trigger an update + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['aname']); + + // Keep track of the previous data reference + let previousData = result.current.data; + await waitFor( + async () => { + const { current } = result; + expect(current.data.length).toEqual(1); + previousData = current.data; + }, + { timeout: 500, interval: 100 } + ); + + // This should still trigger an update since the underlying tables changed. + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['noname']); + + // It's difficult to assert no update happened, but we can wait a bit + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // It should be the same data array reference, no update should have happened + expect(result.current.data == previousData).false; + }); }); + }); - expect(result.current.isLoading).toEqual(true); - - await waitFor( - async () => { - const { current } = result; - expect(current.isLoading).toEqual(false); - }, - { timeout: 500, interval: 100 } - ); - - // 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 } - ); - - // now use the same query again, the result should be available immediately - const { result: newResult } = renderHook(() => useWatchedQuerySubscription(listsQuery), { wrapper }); - expect(newResult.current.isLoading).toEqual(false); - expect(newResult.current.data.length).toEqual(1); + it('should error when PowerSync is not set', async () => { + const { result } = renderHook(() => useQuery('SELECT * from lists')); + const currentResult = result.current; + expect(currentResult.error).toEqual(Error('PowerSync not configured.')); + expect(currentResult.isLoading).toEqual(false); + expect(currentResult.data).toEqual([]); }); }); From 89cb44f714a6fbc92db30f33a375bed981e32958 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 2 Sep 2025 16:29:10 +0200 Subject: [PATCH 2/3] Cleanup .only call in test. --- packages/react/tests/useQuery.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index c4eb35cc1..1b5ce36bf 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -93,7 +93,7 @@ describe('useQuery', () => { ); }); - it.only('should rerun the query when refresh is used', async () => { + it('should rerun the query when refresh is used', async () => { const db = openPowerSync(); const getAllSpy = vi.spyOn(db, 'getAll'); From 5036ff3ba69aa7f1bd60b6a87a948f15afa7469c Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 2 Sep 2025 16:33:30 +0200 Subject: [PATCH 3/3] Added small ref comment. --- packages/react/src/hooks/watched/useWatchedQuery.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index e0fd6ed9e..d8331a710 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -16,6 +16,8 @@ export const useWatchedQuery = ( ): QueryResult | ReadonlyQueryResult => { const { query, powerSync, queryChanged, options: hookOptions, active } = options; + // This ref is used to protect against cases where `queryChanged` changes multiple times too quickly to be + // picked up by the useEffect below. This typically happens when React.StrictMode is enabled. const queryChangeRef = React.useRef(false); if (queryChanged && !queryChangeRef.current) { queryChangeRef.current = true;