@@ -186,14 +186,19 @@ export class PouchDbSyncStorage extends PouchDbStorage {
186186 * @template T - State value type.
187187 * @returns The live push replication handle.
188188 */
189- protected _startLivePush < T extends AllowedValue = AllowedValue > ( ) : PouchDB . Replication . Replication < {
189+ protected _startLivePush <
190+ T extends AllowedValue = AllowedValue ,
191+ > ( ) : PouchDB . Replication . Replication < {
190192 value : T ;
191193 } > {
192- const push = this . _db . replicate . to < { value : T } > ( this . #remoteDb as PouchDB . Database < { value : T } > , {
193- ...this . #syncOptions,
194- live : true ,
195- retry : this . #syncOptions. retry ?? true ,
196- } ) ;
194+ const push = this . _db . replicate . to < { value : T } > (
195+ this . #remoteDb as PouchDB . Database < { value : T } > ,
196+ {
197+ ...this . #syncOptions,
198+ live : true ,
199+ retry : this . #syncOptions. retry ?? true ,
200+ } ,
201+ ) ;
197202
198203 const subscription = observePouchDbReplicate < T > ( push , 'push' , ( doc ) => ( {
199204 _id : doc . _id ,
@@ -228,19 +233,24 @@ export class PouchDbSyncStorage extends PouchDbStorage {
228233 return Promise . resolve ( ) ;
229234 }
230235 this . #pullInFlight = true ;
231- this . _emitEvent ( new StateSyncEvent . Poll ( { detail : { trigger, skipped : false } } ) as StateEventType ) ;
236+ this . _emitEvent (
237+ new StateSyncEvent . Poll ( { detail : { trigger, skipped : false } } ) as StateEventType ,
238+ ) ;
232239
233240 let pull : PouchDB . Replication . Replication < { value : T } > ;
234241 try {
235- pull = this . _db . replicate . from < { value : T } > ( this . #remoteDb as PouchDB . Database < { value : T } > , {
236- ...this . #syncOptions,
237- live : false ,
238- retry : false ,
239- // Guarantees 'complete'/'error' fires even against a backend that never answers a
240- // one-shot request - otherwise a single hung poll would wedge #pullInFlight forever,
241- // silently turning every later timer/focus trigger into a no-op skip.
242- timeout : this . #syncOptions. timeout ?? 30000 ,
243- } ) ;
242+ pull = this . _db . replicate . from < { value : T } > (
243+ this . #remoteDb as PouchDB . Database < { value : T } > ,
244+ {
245+ ...this . #syncOptions,
246+ live : false ,
247+ retry : false ,
248+ // Guarantees 'complete'/'error' fires even against a backend that never answers a
249+ // one-shot request - otherwise a single hung poll would wedge #pullInFlight forever,
250+ // silently turning every later timer/focus trigger into a no-op skip.
251+ timeout : this . #syncOptions. timeout ?? 30000 ,
252+ } ,
253+ ) ;
244254 } catch ( error ) {
245255 console . error ( '[state] failed to start one-shot pull replication' , error ) ;
246256 // A synchronous throw here (bad remote config, custom fetch misuse, etc.) would
@@ -259,31 +269,55 @@ export class PouchDbSyncStorage extends PouchDbStorage {
259269 value : doc . value ,
260270 } ) ) . subscribe ( { next : ( event ) => this . _emitEvent ( event as StateEventType ) } ) ;
261271
272+ // Registered so disposing storage while this pull is mid-flight cancels its request,
273+ // listeners, and watchdog instead of leaving them running past the storage's own lifetime.
274+ // Deregistered in `finish()` below - `_pullOnce` runs repeatedly for the life of the
275+ // storage, so leaving these registered past each pull's own completion would leak one
276+ // teardown entry per poll.
277+ const removePullTeardown = this . _addTeardown ( ( ) => pull . cancel ( ) ) ;
278+ const removeSubscriptionTeardown = this . _addTeardown ( subscription ) ;
279+
262280 return new Promise ( ( resolve ) => {
263281 let settled = false ;
264- const finish = ( ) => {
282+ const finish = ( error ?: unknown ) => {
265283 // 'complete'/'error' can both fire in some PouchDB versions, and the watchdog/promise
266284 // fallbacks below can race with either - only unblock once, whichever gets here first.
267285 if ( settled ) return ;
268286 settled = true ;
269287 clearTimeout ( watchdog ) ;
270288 subscription . unsubscribe ( ) ;
289+ removePullTeardown ( ) ;
290+ removeSubscriptionTeardown ( ) ;
271291 this . #pullInFlight = false ;
292+ // Only the rejection branch passes an error - 'complete'/'error' already emitted
293+ // their own onStateSync.error via observePouchDbReplicate, and the watchdog's forced
294+ // cancel isn't itself an error worth surfacing again.
295+ if ( error !== undefined ) {
296+ this . _emitEvent (
297+ new StateSyncEvent . Error ( { detail : { type : 'error' , error } } ) as StateEventType ,
298+ ) ;
299+ }
272300 resolve ( ) ;
273301 } ;
274- pull . on ( 'complete' , finish ) ;
275- pull . on ( 'error' , finish ) ;
302+ pull . on ( 'complete' , ( ) => finish ( ) ) ;
303+ pull . on ( 'error' , ( ) => finish ( ) ) ;
276304
277305 // `Replication` is also thenable (it resolves/rejects the same way `db.sync()`'s
278306 // `.then()` does) - a fallback for when the 'complete'/'error' *events* themselves
279307 // don't fire, which has been observed to happen even though the underlying requests succeed.
280- pull . then ( finish , finish ) ;
308+ // A rejection reaching here (rather than the 'error' event above) would otherwise surface
309+ // as a silent no-op - report it as an onStateSync.error instead of discarding the reason.
310+ pull . then (
311+ ( ) => finish ( ) ,
312+ ( error ) => finish ( error ) ,
313+ ) ;
281314
282315 // PouchDB's own `timeout` option only bounds the underlying `_changes` request, not the
283316 // full replication (checkpoint read/write, `_revs_diff`, `_bulk_get`) - observed in practice
284317 // to never emit 'complete'/'error' at all in some cases, wedging #pullInFlight forever.
285318 // This watchdog guarantees forward progress regardless of where PouchDB got stuck.
286- const watchdogMs = ( typeof this . #syncOptions. timeout === 'number' ? this . #syncOptions. timeout : 30000 ) + 5000 ;
319+ const watchdogMs =
320+ ( typeof this . #syncOptions. timeout === 'number' ? this . #syncOptions. timeout : 30000 ) + 5000 ;
287321 const watchdog = setTimeout ( ( ) => {
288322 pull . cancel ( ) ;
289323 finish ( ) ;
@@ -310,7 +344,11 @@ export class PouchDbSyncStorage extends PouchDbStorage {
310344 const timer = setInterval ( ( ) => {
311345 // A backgrounded tab has no user waiting on fresh data - skip the tick rather than
312346 // hold a connection open for it, and let the visibilitychange catch-up handle it instead.
313- if ( ! pauseWhenHidden || typeof document === 'undefined' || document . visibilityState === 'visible' ) {
347+ if (
348+ ! pauseWhenHidden ||
349+ typeof document === 'undefined' ||
350+ document . visibilityState === 'visible'
351+ ) {
314352 run ( 'interval' ) ;
315353 }
316354 } , intervalMs ) ;
0 commit comments