Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tricky-pumas-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/react': patch
---

Fixed a crash in `useQuery` when a query throws while being compiled on some renders but not others. The internal `checkQueryChanged` helper declared a `useRef` after an early return, so the hook was called conditionally. The query is now also re-applied once it compiles again after a failed compilation.
45 changes: 33 additions & 12 deletions packages/react/src/hooks/watched/watch-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,51 @@ interface WatchCompatibleQueryWithParams<T> extends WatchCompatibleQuery<T> {
stringifiedParameters?: string;
}

interface ObservedQueryState {
sqlStatement: string;
stringifiedParams: string;
stringifiedOptions: string;
}

export const checkQueryChanged = <T>(query: WatchCompatibleQueryWithParams<T>, options: AdditionalOptions) => {
let _compiled: CompiledQuery;
/**
* The query state which was observed during the previous render.
* - `undefined` indicates the initial render, no query has been observed yet.
* - `null` indicates that the previous render could not compile the query.
*
* This ref is declared before compiling the query. Compilation can throw, and a hook may never
* be declared after a conditional return - doing so changes the order of hooks between renders,
* which crashes the component.
*/
const previousQueryRef = React.useRef<ObservedQueryState | null | undefined>(undefined);
const isInitialRender = previousQueryRef.current === undefined;

let compiled: CompiledQuery;
try {
_compiled = query.compile();
compiled = query.compile();
} catch (error) {
return false; // If compilation fails, we assume the query has changed
// If compilation fails we can't compare anything. Record the failure so that a subsequent
// successful compilation is reported as a change: consumers are still using the query which
// could not be compiled.
previousQueryRef.current = null;
return false;
}
const compiled = _compiled!;

const stringifiedParams = query.stringifiedParameters ?? JSON.stringify(compiled.parameters);
const stringifiedOptions = JSON.stringify(options);

const previousQueryRef = React.useRef({ sqlStatement: compiled.sql, stringifiedParams, stringifiedOptions });
const previousQuery = previousQueryRef.current;

if (
previousQueryRef.current.sqlStatement !== compiled.sql ||
previousQueryRef.current.stringifiedParams != stringifiedParams ||
previousQueryRef.current.stringifiedOptions != stringifiedOptions
previousQuery == null ||
previousQuery.sqlStatement !== compiled.sql ||
previousQuery.stringifiedParams != stringifiedParams ||
previousQuery.stringifiedOptions != stringifiedOptions
) {
previousQueryRef.current.sqlStatement = compiled.sql;
previousQueryRef.current.stringifiedParams = stringifiedParams;
previousQueryRef.current.stringifiedOptions = stringifiedOptions;
previousQueryRef.current = { sqlStatement: compiled.sql, stringifiedParams, stringifiedOptions };

return true;
// The initial render is never a change: the query has not been used anywhere yet.
return !isInitialRender;
}

return false;
Expand Down
41 changes: 41 additions & 0 deletions packages/react/tests/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,47 @@ describe('useQuery', () => {
);
});

it('should recover when a query compiles successfully after a failed compilation', async () => {
const db = openPowerSync();

// Emulates a conditionally constructed query, e.g. a Drizzle projection built from
// optional fields, which throws while being compiled on some renders but not others.
let compilationFails = true;
const query: commonSdk.CompilableQuery<{ a: string }> = {
execute: () => db.getAll<{ a: string }>('SELECT ? AS a', ['foo']),
compile: () => {
if (compilationFails) {
throw new Error('error');
}
return { sql: 'SELECT ? AS a', parameters: ['foo'] };
}
};

const { result, rerender } = renderHook(() => useQuery(query), {
wrapper: ({ children }) => testWrapper({ children, db })
});

await waitFor(
async () => {
expect(result.current.error).toEqual(Error('error'));
},
{ timeout: 500, interval: 100 }
);

// The query now compiles. Rendering must not break the order of hooks.
compilationFails = false;
expect(() => rerender()).not.toThrow();

await waitFor(
async () => {
const currentResult = result.current;
expect(currentResult.error).toBeFalsy();
expect(currentResult.data).toEqual([{ a: 'foo' }]);
},
{ timeout: 500, interval: 100 }
);
});

it('should use an existing WatchedQuery instance', async () => {
const db = openPowerSync();

Expand Down
134 changes: 134 additions & 0 deletions packages/react/tests/watchUtils.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { CompiledQuery, WatchCompatibleQuery } from '@powersync/common';
import { cleanup, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { AdditionalOptions } from '../src/hooks/watched/watch-types';
import { checkQueryChanged } from '../src/hooks/watched/watch-utils';

describe('checkQueryChanged', () => {
beforeEach(() => {
cleanup();
});

/**
* A query which compiles to whatever `compile` currently returns, or throws if it is set to
* a function which throws.
*/
const createQuery = (compile: () => CompiledQuery): WatchCompatibleQuery<any> & { compile: () => CompiledQuery } => ({
compile,
execute: async () => []
});

const options: AdditionalOptions = {};

it('should not report a change for the initial render', () => {
const query = createQuery(() => ({ sql: 'SELECT 1', parameters: [] }));
const { result } = renderHook(() => checkQueryChanged(query, options));
expect(result.current).toEqual(false);
});

it('should not report a change while the compiled query stays the same', () => {
const query = createQuery(() => ({ sql: 'SELECT ?', parameters: ['a'] }));
const { result, rerender } = renderHook(() => checkQueryChanged(query, options));
expect(result.current).toEqual(false);

rerender();
expect(result.current).toEqual(false);

rerender();
expect(result.current).toEqual(false);
});

it('should report a change when the SQL statement changes', () => {
let sql = 'SELECT 1';
const query = createQuery(() => ({ sql, parameters: [] }));
const { result, rerender } = renderHook(() => checkQueryChanged(query, options));
expect(result.current).toEqual(false);

sql = 'SELECT 2';
rerender();
expect(result.current).toEqual(true);

// The statement is unchanged again
rerender();
expect(result.current).toEqual(false);
});

it('should report a change when the parameters change', () => {
let parameters: any[] = ['a'];
const query = createQuery(() => ({ sql: 'SELECT ?', parameters }));
const { result, rerender } = renderHook(() => checkQueryChanged(query, options));
expect(result.current).toEqual(false);

parameters = ['b'];
rerender();
expect(result.current).toEqual(true);

rerender();
expect(result.current).toEqual(false);
});

it('should report a change when the options change', () => {
const query = createQuery(() => ({ sql: 'SELECT 1', parameters: [] }));
let currentOptions: AdditionalOptions = { throttleMs: 10 };
const { result, rerender } = renderHook(() => checkQueryChanged(query, currentOptions));
expect(result.current).toEqual(false);

currentOptions = { throttleMs: 20 };
rerender();
expect(result.current).toEqual(true);

rerender();
expect(result.current).toEqual(false);
});

it('should not report a change while the query cannot be compiled', () => {
const query = createQuery(() => {
throw new Error('could not compile');
});
const { result, rerender } = renderHook(() => checkQueryChanged(query, options));
expect(result.current).toEqual(false);

expect(() => rerender()).not.toThrow();
expect(result.current).toEqual(false);
});

it('should report a change once the query compiles after a failed compilation', () => {
let compilationFails = true;
const query = createQuery(() => {
if (compilationFails) {
throw new Error('could not compile');
}
return { sql: 'SELECT 1', parameters: [] };
});

const { result, rerender } = renderHook(() => checkQueryChanged(query, options));
expect(result.current).toEqual(false);

// The hook order must be stable, even though the first render bailed out early.
compilationFails = false;
expect(() => rerender()).not.toThrow();
// The consumers of this hook still hold the query which could not be compiled, they need to
// be notified that a usable query is available now.
expect(result.current).toEqual(true);

rerender();
expect(result.current).toEqual(false);
});

it('should not throw when a previously compiling query starts failing', () => {
let compilationFails = false;
const query = createQuery(() => {
if (compilationFails) {
throw new Error('could not compile');
}
return { sql: 'SELECT 1', parameters: [] };
});

const { result, rerender } = renderHook(() => checkQueryChanged(query, options));
expect(result.current).toEqual(false);

compilationFails = true;
expect(() => rerender()).not.toThrow();
expect(result.current).toEqual(false);
});
});