Skip to content

fix(react): don't call useRef conditionally in checkQueryChanged - #1069

Open
giaBaoJS wants to merge 1 commit into
powersync-ja:mainfrom
giaBaoJS:fix/react-conditional-use-ref-in-check-query-changed
Open

fix(react): don't call useRef conditionally in checkQueryChanged#1069
giaBaoJS wants to merge 1 commit into
powersync-ja:mainfrom
giaBaoJS:fix/react-conditional-use-ref-in-check-query-changed

Conversation

@giaBaoJS

Copy link
Copy Markdown

Summary

useQuery hard-crashes when the query it is given throws while being compiled on one render but not on the next.

checkQueryChanged declares React.useRef on line 29, after the early return false on line 22:

export const checkQueryChanged = <T>(query, options) => {
  let _compiled: CompiledQuery;
  try {
    _compiled = query.compile();
  } catch (error) {
    return false;             // <- early return
  }
  ...
  const previousQueryRef = React.useRef({ ... });   // <- hook after the early return

The hook is therefore called conditionally, which breaks the rules of hooks. checkQueryChanged runs on every useQuery, useSuspenseQuery and useSingleSuspenseQuery render through constructCompatibleQuery, so the whole component tears down:

Warning: React has detected a change in the order of Hooks called by TestComponent.

   Previous render            Next render
   ------------------------------------------------------
1. useContext                 useContext
2. useContext                 useContext
3. useMemo                    useMemo
4. useMemo                    useMemo
5. useContext                 useRef
   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

[Error: Uncaught TypeError: Cannot read properties of undefined (reading 'sqlStatement')]

The above error occurred in the <TestComponent> component:
Consider adding an error boundary to your tree to customize error handling behavior.

React reuses the value of the hook that previously occupied that slot, so previousQueryRef.current is undefined and the very next line throws.

It breaks in both directions. When a query that used to compile starts failing, the render is one hook short instead:

Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

How this is reached

compile() throwing is supported behaviour, not misuse: the suite already covers it in should show an error if parsing the query results in an error. That test survives only because its compile() throws on every render, so the hook count stays consistent. The crash needs the outcome to change between renders.

The realistic trigger is a conditionally built projection. With drizzle-orm@0.44.7 (the version this repo pins) and toCompilableQuery:

db.select({ id: lists.id, name: undefined }).from(lists).toSQL()
// TypeError: Cannot convert undefined or null to object

so a component that builds its projection from optional props/state — select({ id: lists.id, name: showName ? lists.name : undefined }) — compiles on some renders and throws on others, and toggling showName crashes the tree. I checked the other shapes I expected to throw (inArray(col, []), eq(col, undefined), limit(NaN)) and they all compile fine, so the trigger is genuinely narrow — but the failure mode when it is hit is an unrecoverable crash rather than the error state the hook is designed to report.

This survived because react-hooks/rules-of-hooks never ran here. The root package.json has "lint": "eslint .", but there is no eslint config and no eslint (or eslint-plugin-react-hooks) dependency at the root, so pnpm lint cannot run.

The fix

Declare the ref before compiling, and let it hold three states:

  • undefined — initial render, nothing observed yet
  • null — the previous render could not compile the query
  • the observed sql/params/options otherwise

Behaviour for a query that always compiles is unchanged: the initial render still reports "not changed" (previously achieved by seeding the ref with the current values), and later renders still report a change only when the SQL, the parameters or the options differ.

One behaviour is deliberately new. When a compilation failure is followed by a successful one, checkQueryChanged now returns true. Simply hoisting the ref and treating the first successful compile as "not changed" stops the crash but leaves the hook broken in a quieter way: the WatchedQuery was created with the query that could not be compiled, useWatchedQuery only propagates a new query through updateSettings when queryChanged is true, so the hook would stay stuck on error forever. I verified that — with the hoist-only variant, the added useQuery test fails with expected Error: error to be falsy. Recording the failed compilation is what lets consumers pick the query up again.

queryChanged has three consumers and the change is consistent with all of them:

  • useWatchedQuerytrue calls watchedQuery.updateSettings({ query, ... }) inline
  • useSingleQueryqueryChanged sits in the useCallback/useEffect deps, so flipping it re-runs the query
  • useSuspenseQuery / useSingleSuspenseQuery — ignore the value, but still go through constructCompatibleQuery and so were exposed to the same crash

Tests

packages/react/tests/useQuery.test.tsx — a useQuery test next to the existing "parsing the query results in an error" one, asserting that a query which starts compiling after a failed compilation neither crashes nor stays in the error state. It runs in both the normal and StrictMode variants of the existing matrix.

packages/react/tests/watchUtils.test.tsx — unit tests for checkQueryChanged covering both crash directions plus the unchanged semantics (initial render, unchanged query, changed SQL, changed parameters, changed options, permanently failing compilation). The six semantics tests pass before and after the change, which is what pins the existing behaviour down.

Before: 82 passed | 2 skipped (84).
After: 92 passed | 2 skipped (94).

With the change to watch-utils.ts reverted and the new tests kept, 4 of them fail with the TypeError / Rendered fewer hooks than expected above, and the 6 semantics tests still pass.

common, drizzle-driver, kysely-driver, tanstack-react-query, vue and web are green.

`checkQueryChanged` declared `React.useRef` after an early `return` in the
`query.compile()` catch block. A query which throws while being compiled on
one render but not the next therefore changes the order of hooks, which
crashes the component.

Move the ref above the compilation and record failed compilations, so that a
query which compiles again is re-applied to the consumers of the hook.
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5fe853d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@powersync/react Patch
@powersync/react-native Patch
@powersync/tanstack-react-query Patch
@powersync/diagnostics-app Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant