Skip to content

Every React useQuery mount permanently leaks a tablesUpdated adapter listener (close-during-init race in watched queries) #1056

Description

@Thorsson

Description

If a WatchedQuery is closed while its initialization is still awaiting, it permanently leaks listeners. @powersync/react's useQuery triggers this race on every mount, so apps leak one tablesUpdated DB-adapter listener per mounted query. On our React Native app this degrades performance minute-over-minute: each table change fans out to an ever-growing set of dead listeners (each scheduling query re-execution machinery) that can never be removed.

Confirmed on main (2.0.0 / @powersync/shared-internals 1.0.1) and on the v1 branch (@powersync/common 1.57.2 — same code in packages/common/src/client/AbstractPowerSyncDatabase.ts:1298). We originally isolated it on 1.57.0.

The race (file refs against current main)

packages/react/src/hooks/watched/useWatchedQuery.ts:37 creates the watched query inside a useState initializer during render, and the mount effect (lines 40-49) immediately close()s that instance and creates a replacement. One create → close-one-frame-later cycle per mount, while the watcher's async init is still in flight. Three gaps then compound:

  1. AbstractQueryProcessor.init (packages/shared-internals/src/client/watched/AbstractQueryProcessor.ts:158-186): registers the closing listener, await db.waitForReady(), registers schemaChanged, and only then assigns this.disposeListeners. A close() during the await runs this.disposeListeners?.() while it is still null → the closing listener leaks, and schemaChanged is registered after the query is closed and leaks too.
  2. OnChangeQueryProcessor.linkQuery (packages/shared-internals/src/client/watched/OnChangeQueryProcessor.ts:42-46) and DifferentialQueryProcessor.linkQuery (DifferentialQueryProcessor.ts:146-160): after await db.resolveTables(...) there is no this.closed || abortSignal.aborted check, and the dispose function returned by db.onChangeWithCallback(...) is discarded — cleanup relies entirely on the signal, which by now is already aborted.
  3. BasePowerSyncDatabase.onChangeWithCallback (packages/shared-internals/src/client/BasePowerSyncDatabase.ts:770-812): registers this.database.registerListener({ tablesUpdated: ... }) unconditionally (line 799) and wires cleanup only via resolvedOptions.signal?.addEventListener('abort', ...) (line 806). An already-aborted signal never dispatches abort → the adapter listener is orphaned forever.

(watchWithCallback, BasePowerSyncDatabase.ts:699-716, has the same already-aborted-signal shape for the deprecated watch API.)

Repro (vitest, distilled)

// db.database is a real BaseObserver-backed DBAdapter fake exposing listeners.size
const controller = new AbortController();
controller.abort();

db.onChangeWithCallback({ onChange: () => {} }, { signal: controller.signal, tables: ['todos'] });
expect(adapter.listeners.size).toBe(0); // FAILS on main: 1, and nothing can ever remove it

// End-to-end (the useQuery mount shape): park resolveTables on the adapter's read lock,
// close the watch mid-await, then release:
const query = db
  .customQuery({ compile: () => ({ sql: 'SELECT 1', parameters: [] }), execute: async () => [] })
  .watch({});
await tick();
await query.close();
releaseReadLock();
await settle();
expect(adapter.listeners.size).toBe(0); // FAILS on main: 1 leaked tablesUpdated listener

Expected

Closing a watched query at any point of its lifecycle — including while init is awaiting waitForReady() or resolveTables() — removes every listener it registered. Passing an already-aborted signal to onChangeWithCallback registers nothing.

Fix

A PR accompanies this issue: an early no-op-dispose return in onChangeWithCallback for already-aborted signals, closed/aborted bail-outs after the awaited resolveTables in both linkQuery implementations, and disposal of the closing listener when close() lands during init's waitForReady() — plus regression tests that fail on current main. A backport to v1 would be appreciated, as 1.57.x has the identical defect.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions