Skip to content

fix(core): release subscriptions on unmount and evict errored cache entries - #839

Open
jhuleatt wants to merge 5 commits into
mainfrom
fix/suspense-subject-leak
Open

jhuleatt wants to merge 5 commits into
mainfrom
fix/suspense-subject-leak

Conversation

@jhuleatt

Copy link
Copy Markdown
Collaborator

Summary

Fixes two cache lifecycle defects in SuspenseSubject and preloadObservable:

  1. Source subscriptions were never released on unmount:
    • SuspenseSubject piped innerObservable through shareReplay(1) (refCount: false), which keeps the underlying Firebase listener attached indefinitely even after all subscribers unmount.
    • this._warmupSubscription was never unsubscribed inside _subscribe(), keeping refCount >= 1 permanently.
    • When _suspenseEnabled was true, _timeoutHandler was a no-op (setTimeout(() => {}, this._timeoutWindow)), leaking abandoned <Suspense> renders forever.
  2. Errored cache entries were never evicted (#742, #485):
    • Once an observable emitted an error (e.g., permission-denied before sign-in or on sign-out), the errored SuspenseSubject remained in preloadedObservables under its observableId, causing subsequent mounts or <ErrorBoundary> retries to immediately replay the stale error.

Changes

  • Replace shareReplay(1) with share({ connector: () => new ReplaySubject<T>(1), resetOnError: true, resetOnComplete: false, resetOnRefCountZero: () => timer(RELEASE_DELAY_MS).pipe(tap(onEvict)) }) so the shared Firebase listener stays warm across React <StrictMode> double-mounts (RELEASE_DELAY_MS = 1000ms) and detaches + evicts from preloadedObservables once no subscribers remain.
  • Unsubscribe this._warmupSubscription in _subscribe() after attaching the real subscriber so ownership hands off without detaching the underlying listener.
  • Replace the _suspenseEnabled no-op timeout with releaseWarmupWhenSettled, which waits until firstEmission has settled (this._hasValue || this._error, preserving #525's guarantee for slow initial reads) before releasing unclaimed warm-up subscriptions.
  • Schedule setTimeout(onEvict, RELEASE_DELAY_MS) on stream error so remounts and <ErrorBoundary> retries start a fresh subscription.

Closes #742
Closes #485

@jhuleatt
jhuleatt requested a review from tyler-reitz October 27, 2026 19:05

@tyler-reitz tyler-reitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for one thing: the fix needs rxjs 7.1.0 or later, and dependencies allows ^6.6.3 || ^7.0.1.

The function form of resetOnRefCountZero arrived in 7.1.0. On 7.0.x the function is treated as a truthy true and resets immediately; on 6.x share() takes no arguments and ignores the config. With this exact config on 6.6.3 and 7.0.1, onEvict never fires, so those installs keep the leak with no error. CI only installs 7.8.1.

Your call which way: raise the floor to ^7.1.0, which breaks rxjs 6 users and so belongs on v5 (where Closes won't fire), or keep the range and say in the body that the fix applies to 7.1+ only.

Everything else checks out. Each new test goes red against main's src/ and when its fix is reverted. Non-blocking:

  • Three paths have no test: the 1s grace window (a 0ms delay stays green), releaseWarmupWhenSettled (an immediate return stays green), and the identity guard in onEvict (a plain delete(id) stays green). The guard is right: a late subscriber holding an evicted subject re-arms its timer, which would otherwise delete the newer entry.
  • Errored entries used to stay cached and now clear after 1s, in suspense mode too since the timeout is unconditional. That's what #742 asks for, but it's a behavior change under fix:.
  • The TODO in the value getter ("burn the cache entry until the timeout fires") is stale now.

Comment thread src/SuspenseSubject.ts
// - `resetOnRefCountZero`: unsubscribe from Firebase and evict the cache entry once no subscribers remain for `RELEASE_DELAY_MS`.
share({
connector: () => new ReplaySubject<T>(1),
resetOnError: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

share never sees an error here: catchError(() => empty()) upstream turns it into a completion, so this option never applies (flipping it to false leaves the suite green). Errored entries are evicted only by the setTimeout(onEvict) in tap's error handler. Worth saying so here, so nobody deletes that timeout as redundant.

Comment thread src/SuspenseSubject.ts
share({
connector: () => new ReplaySubject<T>(1),
resetOnError: true,
resetOnComplete: false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since errors arrive as completions, this covers every errored stream too, and a completion cancels any pending refcount timer and stops it being rescheduled. So entries for streams that complete normally (the first() in useFirestoreDocOnce and useFirestoreDocDataOnce) never leave preloadedObservables: the listener is gone, but the entry stays, which on a server is still per-user growth (#790). Intended?

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.

Error recovery: no retry path once an observable cache entry errors Permission error when accessing a cached firestore collection after logout/login

2 participants