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:
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.
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.
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.
Description
If a
WatchedQueryis closed while its initialization is still awaiting, it permanently leaks listeners.@powersync/react'suseQuerytriggers this race on every mount, so apps leak onetablesUpdatedDB-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-internals1.0.1) and on thev1branch (@powersync/common1.57.2 — same code inpackages/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:37creates the watched query inside auseStateinitializer during render, and the mount effect (lines 40-49) immediatelyclose()s that instance and creates a replacement. Onecreate → close-one-frame-latercycle per mount, while the watcher's async init is still in flight. Three gaps then compound:AbstractQueryProcessor.init(packages/shared-internals/src/client/watched/AbstractQueryProcessor.ts:158-186): registers theclosinglistener,await db.waitForReady(), registersschemaChanged, and only then assignsthis.disposeListeners. Aclose()during the await runsthis.disposeListeners?.()while it is stillnull→ theclosinglistener leaks, andschemaChangedis registered after the query is closed and leaks too.OnChangeQueryProcessor.linkQuery(packages/shared-internals/src/client/watched/OnChangeQueryProcessor.ts:42-46) andDifferentialQueryProcessor.linkQuery(DifferentialQueryProcessor.ts:146-160): afterawait db.resolveTables(...)there is nothis.closed || abortSignal.abortedcheck, and the dispose function returned bydb.onChangeWithCallback(...)is discarded — cleanup relies entirely on the signal, which by now is already aborted.BasePowerSyncDatabase.onChangeWithCallback(packages/shared-internals/src/client/BasePowerSyncDatabase.ts:770-812): registersthis.database.registerListener({ tablesUpdated: ... })unconditionally (line 799) and wires cleanup only viaresolvedOptions.signal?.addEventListener('abort', ...)(line 806). An already-aborted signal never dispatchesabort→ 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)
Expected
Closing a watched query at any point of its lifecycle — including while init is awaiting
waitForReady()orresolveTables()— removes every listener it registered. Passing an already-aborted signal toonChangeWithCallbackregisters nothing.Fix
A PR accompanies this issue: an early no-op-dispose return in
onChangeWithCallbackfor already-aborted signals, closed/aborted bail-outs after the awaitedresolveTablesin bothlinkQueryimplementations, and disposal of theclosinglistener whenclose()lands duringinit'swaitForReady()— plus regression tests that fail on currentmain. A backport tov1would be appreciated, as 1.57.x has the identical defect.