Repository navigation
Conversation
tyler-reitz
left a comment
There was a problem hiding this comment.
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 immediatereturnstays green), and the identity guard inonEvict(a plaindelete(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
valuegetter ("burn the cache entry until the timeout fires") is stale now.
| // - `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, |
There was a problem hiding this comment.
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.
| share({ | ||
| connector: () => new ReplaySubject<T>(1), | ||
| resetOnError: true, | ||
| resetOnComplete: false, |
There was a problem hiding this comment.
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?
tyler-reitz
left a comment
There was a problem hiding this comment.
The three untested paths, the comments and the TODO are all addressed, and each new test goes red when its fix is reverted. One blocker left, and it comes from how the rxjs floor installs rather than from the code.
Blocking: this is breaking, so the title wants a !. It also fails silently on rxjs 6: rxjs is in dependencies, so an rxjs 6 app installs cleanly with a nested 7.x while rxfire binds to the app's 6.x, and unsubscribing never reaches the source. In a scratch app, five remounts left 5 listeners attached on this PR versus 1 on main. Moving rxjs to peerDependencies at ^7.1.0 would turn that into an install error instead.
Non-blocking:
- The
if (this._error)branch in_subscribehas no test. Swapping it forsubscriber.complete()keeps the suite green, but then a mounted hook withinitialDatawhose stream errors resubscribes in a loop (59 subscriptions in 3s). *Onceentries still stay cached on the usual path: the component subscribes beforefirst()completes, which cancels the warm-up timer, and the teardown only evicts on error. Also evicting on_isCompletethere should cover it. Fine as a follow-up under #790.
|
Correction to my review above: on this PR's own build, rxjs and rxfire are inlined into |
…gn Observables In Suspense mode, skip the 1s timer eviction of errored entries. React can retry a suspended render after any delay; an evicted entry suspends again instead of reaching the Error Boundary. Unmount and post-unmount (#485) evictions are unchanged. Wrap Observables from another rxjs copy with `from()` so unsubscribe reaches the source, replacing the rxjs ^7.1.0 floor, which broke TypeScript for rxjs 6 apps and had no runtime effect because rxjs is bundled.
|
I'm withdrawing my The rxjs floor: rxjs is inlined in Blocker in The fix: Suspense mode skips only the 1s timer eviction; the sign-out (#485) and unmount evictions are unchanged. With the fix, Suspense reaches the boundary 24/24 and non-suspense 48/48. #790 is fixed: 0 of 5 listeners stay attached after unmount, against 5 of 5 on Release notes are in the PR body. Suspense first-load errors are still not fixed, so the body now says |
armando-navarro
left a comment
There was a problem hiding this comment.
Hey, Tyler, here's the review you requested. Three things stand between this and my approve: one fix, and two decisions for you and Jeff. The rest is tests and inline comments.
1. A suspended component can get stuck on its fallback
A suspended component has not subscribed yet, so the 1s release timer does not count it. Steps to reproduce:
- A non-suspense component, or one with
initialData, mounts on an id. The first value has not arrived yet. - It unmounts. The 1s release timer starts.
- Within that second, a Suspense-mode component mounts on the same id and suspends.
- The timer fires. It unsubscribes from the source and evicts the entry.
- The first value arrives. Nothing is subscribed, so
firstEmissionnever resolves. - The suspended component stays on its fallback until an unrelated parent render recreates the entry.
On main the same steps render the value. It needs mixed modes on one id and a first read slower than 1s, which a cold Firestore listen can be.
The fix that worked for me, in resetOnRefCountZero:
- After the 1s timer, if
firstEmissionhas not settled, wait for it. - Then allow one more grace period before evicting.
With that change:
- The full suite passes.
- The grace-window test is unchanged.
- A test for the steps above goes from red to green.
- Trade-off: a source that never emits stays subscribed after an unmount until it emits. The warm-up subscription already behaves this way for an entry nobody subscribed to.
- A polling version also works, but it makes
vi.runAllTimers()loop in app tests, so I dropped it.
2. Which eviction policy should preload-created entries get?
The 1s eviction of a first-load error follows the suspenseEnabled of the call that created the entry.
preloadFirestoreDoc,preloadUserand two-argumentpreloadObservablecreate entries withfalse.- A Suspense app that preloads a document and then gets permission denied is back in the race eb7910f closed: the error is evicted after 1s, and a retry after that suspends and reads again.
- I measured it. A preload-created entry is gone 1.3s after the error. A hook-created entry is still there.
- The reverse also happens. An entry created by a Suspense hook that errors before anyone subscribes is never evicted, so a non-suspense consumer of that id rethrows until reload.
I don't have a verified fix. Options I see:
- Let a later caller's flag update the entry's policy.
- Decide the policy per consumer at read time.
- Keep it as is and document it.
Either way, the release note "In Suspense mode, an error on first load stays cached until reload" needs to say it applies to entries the hook created.
3. The 30s release can drop a useInit* entry
useInitSdk runs the initializer in a per-instance useMemo and caches the promise under firebase-sdk:<name>:<app>. The entry has to outlive component instances. On main it did.
On the branch:
- If the init component renders in Suspense mode and never commits, the 30s warm-up release evicts the completed entry. That happens when the user leaves while the fallback is showing and comes back later, or when a sibling under the same boundary stays suspended past 30s.
- The next instance runs the initializer again. That was always true, but now nothing holds the first result.
- The initializer from
docs/use.md(initializeFirestorethenenableIndexedDbPersistence) rejects on a second run with "Firestore has already been started and persistence can no longer be enabled". ThepersistentLocalCache()form rejects too. - The error boundary shows that until reload.
- I reproduced it with a harness that mirrors
useInitSdk. Branch: error after 31.5s.main: renders.
The other way to lose the entry, an unmount while the initializer is still pending past 1s, is fixed by the change in section 1.
Options for this one, neither verified:
- Keep completed entries in Suspense mode at the warm-up release.
- Cache the initializer promise by
sdkName:appNameinuseInitSdk. - Accept the risk and say so in the release notes.
Tests
Five paths have no test. Each change below leaves the suite green:
else if (!this._suspenseEnabled)changed toelse. Suspense first-load errors get evicted and nothing fails.- The eviction when the last subscriber of an errored entry unmounts, deleted. In Suspense mode it is the only code that evicts an error that arrived while mounted.
resetOnComplete: falseflipped totrue. A mounted*Onceentry re-reads.- The
clearTimeoutat the top of_subscribe, deleted. The warm-up release then evicts a mounted one-shot entry at the next tick. - The
#525assertion runs at 80ms, inside the 1s grace. A warm-up released before the first value still passes. Checking after the window plus the grace catches it.
I have these written, plus the regression test for section 1 and the two changes from the inline comments. I can push them as a branch if that helps.
One more release-note correction. Entries whose stream completes while mounted are never evicted. That covers the *Once hooks you noted, and for the same reason useIdTokenResult, useStorageDownloadURL, useCallableFunctionResponse and useInit*. "A later remount reads again" does not apply to them.
Smaller things are inline. If anything here is wrong, tell me and I'll run it again.
| // `catchError(() => EMPTY)` converts upstream errors into completions. | ||
| // Notify `useObservable` (`onStoreChange`) without calling `subscriber.complete()`, | ||
| // because `subscriber.complete()` auto-unsubscribes `subscriber` before React unmounts. | ||
| subscriber.next(this._value as T); |
There was a problem hiding this comment.
The cast is not needed, Subscriber.next takes value?: T. It also hides a behavior change:
- A direct subscriber of what
preloadObservableorpreloadFirestoreDocreturns now getsnext(undefined), or the last value a second time, and never completes on error. - On
mainit completed, sofirstValueFromnow resolvesundefinedwhere it used to reject. - The hook is unaffected. Worth a line in the PR body for anyone who subscribes directly.
| // A 1-second window lets React StrictMode double-mounts and immediate route transitions reuse the active listener. | ||
| const LISTENER_RELEASE_DELAY_MS = 1000; | ||
|
|
||
| // Fallback retention window for an error that arrives during an initial Suspense load before a component subscribes, |
There was a problem hiding this comment.
This comment and the one at line 63 say the window gives a suspended first load time to retry. Since eb7910f the timer only runs when Suspense is off.
| // - `resetOnRefCountZero`: detach the Firebase listener and evict the cache entry after `LISTENER_RELEASE_DELAY_MS` with zero subscribers. | ||
| share({ | ||
| connector: () => new ReplaySubject<T>(1), | ||
| resetOnError: true, |
There was a problem hiding this comment.
resetOnError and the error: handler in _subscribe never run, because catchError(() => EMPTY) runs first. The comment above still describes a teardown on error.
| }); | ||
| // Hand off ownership of the shared source from the warm-up subscription to the real subscriber. | ||
| this._warmupSubscription.unsubscribe(); | ||
| this._innerSubscriber = innerSubscription; |
There was a problem hiding this comment.
_innerSubscriber is written and never read. The @ts-expect-error above it now also hides that compiler error.
| this._innerObservable = innerObservable.pipe( | ||
| // An Observable from another rxjs copy (an app on rxjs 6) cannot be torn down by these rxjs 7 | ||
| // operators; `from` re-wraps it through `Symbol.observable` so unsubscribe reaches the source. | ||
| this._innerObservable = (innerObservable instanceof Observable ? innerObservable : from(innerObservable)).pipe( |
There was a problem hiding this comment.
from() already does this check and returns an rxjs 7 Observable unchanged. from(innerObservable).pipe( is enough.
| this._onEvict(); | ||
| } | ||
| } else { | ||
| this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); |
There was a problem hiding this comment.
This re-arms until the entry settles. For a source that never emits it re-arms forever. Three effects:
vi.runAllTimers()aborts with its infinite-loop error in any app test that renders a Suspense hook on a never-emitting mock. It completes onmain.- A Node process holding such an entry no longer exits on its own.
- The release happens at the next 30s tick, not 30s after the first value. A value at 31s is released at 60s.
Fix: release from firstEmission with one delayed timer instead of polling. The suite stays green. Also on the branch.
| innerObservable: Observable<T>, | ||
| private _timeoutWindow: number, | ||
| private _suspenseEnabled: boolean, | ||
| private _onEvict: () => void = () => {} |
There was a problem hiding this comment.
Nothing uses the default, and the optional parameter lets a later caller skip eviction without a type error. Making it required costs nothing.
| const releaseWarmupWhenSettled = () => { | ||
| if (this._hasValue || this._error) { | ||
| this._warmupSubscription.unsubscribe(); | ||
| if (this._isComplete) { |
There was a problem hiding this comment.
| return this._innerSubscriber; | ||
| this._subscriberCount++; | ||
| const innerSubscription = this._innerObservable.subscribe({ | ||
| next: (v) => subscriber.next(v), |
There was a problem hiding this comment.
v and e here, and the r in the new test waits: I ask for full names everywhere. value, error and resolve would do.
| const observableId = `grace-window-${Math.random()}`; | ||
| let subscribeCount = 0; | ||
| const base$ = new BehaviorSubject<string>('cached-val'); | ||
| const counted$ = base$.lift({ |
There was a problem hiding this comment.
lift will be made internal in rxjs 8. defer(() => { subscribeCount++; return base$; }) passes here and still fails at a 0ms delay. Also, the test at line 629 is the line 608 test without the mid-test emission.
Summary
Fixes two cache lifecycle defects in
SuspenseSubjectandpreloadObservable:SuspenseSubjectpipedinnerObservablethroughshareReplay(1)(refCount: false), which keeps the underlying Firebase listener attached indefinitely even after all subscribers unmount.this._warmupSubscriptionwas never unsubscribed inside_subscribe(), keepingrefCount >= 1permanently._suspenseEnabledwastrue,_timeoutHandlerwas a no-op (setTimeout(() => {}, this._timeoutWindow)), leaking abandoned<Suspense>renders forever.#742,#485):permission-deniedbefore sign-in or on sign-out), the erroredSuspenseSubjectremained inpreloadedObservablesunder itsobservableId, causing subsequent mounts or<ErrorBoundary>retries to immediately replay the stale error.Changes
shareReplay(1)withshare({ connector: () => new ReplaySubject<T>(1), resetOnError: true, resetOnComplete: false, resetOnRefCountZero: () => timer(LISTENER_RELEASE_DELAY_MS).pipe(tap(onEvict)) })so the shared Firebase listener stays warm across React<StrictMode>double-mounts (LISTENER_RELEASE_DELAY_MS = 1000ms) and detaches + evicts frompreloadedObservablesonce no subscribers remain.this._warmupSubscriptionin_subscribe()after attaching the real subscriber so ownership hands off without detaching the underlying listener._suspenseEnabledno-op timeout withreleaseWarmupWhenSettled, which waits untilfirstEmissionhas settled (this._hasValue || this._error, preserving#525's guarantee for slow initial reads) before releasing unclaimed warm-up subscriptions.setTimeout(onEvict, FIRST_LOAD_ERROR_EVICT_DELAY_MS)(1000ms) on stream error so remounts and<ErrorBoundary>retries start a fresh subscription (non-suspense mode only; in Suspense mode a first-load error stays cached, since React may retry the suspended render after any delay).from()so unsubscribe reaches the source; rxjs 7 operators cannot tear down an rxjs 6 Observable directly.Release notes
loadingor the fallback once and reads again.Refs #742 (Suspense first-load errors are not fixed)
Closes #485