Repository navigation
fix(core): release subscriptions on unmount and evict errored cache entries #839
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
36d6643
b092103
ae14caa
8f99728
d9d2a97
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,15 @@ | ||
| import { empty, Observable, Subject, Subscriber, Subscription } from 'rxjs'; | ||
| import { catchError, shareReplay, tap } from 'rxjs/operators'; | ||
| import { EMPTY, Observable, ReplaySubject, Subject, Subscriber, Subscription, timer } from 'rxjs'; | ||
| import { catchError, share, tap } from 'rxjs/operators'; | ||
| import { ObservableStatus } from './useObservable.js'; | ||
|
|
||
| // Grace period before a zero-subscriber stream detaches its Firebase listener and evicts its cache entry. | ||
| // 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, | ||
| // giving React time to retry the suspended render and throw the error to an Error Boundary. | ||
| const FIRST_LOAD_ERROR_EVICT_DELAY_MS = 1000; | ||
|
|
||
| export class SuspenseSubject<T> extends Subject<T> { | ||
| private _value: T | undefined; | ||
| private _hasValue = false; | ||
|
|
@@ -12,13 +20,19 @@ export class SuspenseSubject<T> extends Subject<T> { | |
| private _warmupSubscription: Subscription; | ||
| private _immutableStatus: ObservableStatus<T>; | ||
| private _isComplete = false; | ||
| private _subscriberCount = 0; | ||
|
|
||
| // @ts-expect-error: TODO: double check to see if this is an RXJS thing or if we should listen to TS | ||
| private _innerSubscriber: Subscription; | ||
| // @ts-expect-error: TODO: double check to see if this is an RXJS thing or if we should listen to TS | ||
| private _resolveFirstEmission: () => void; | ||
|
|
||
| constructor(innerObservable: Observable<T>, private _timeoutWindow: number, private _suspenseEnabled: boolean) { | ||
| constructor( | ||
| innerObservable: Observable<T>, | ||
| private _timeoutWindow: number, | ||
| _suspenseEnabled: boolean, | ||
| private _onEvict: () => void = () => {} | ||
| ) { | ||
| super(); | ||
| this._firstEmission = new Promise<void>((resolve) => (this._resolveFirstEmission = resolve)); | ||
|
|
||
|
|
@@ -42,26 +56,53 @@ export class SuspenseSubject<T> extends Subject<T> { | |
| this._error = e; | ||
| this._resolveFirstEmission(); | ||
| this._updateImmutableStatus(); | ||
| // If all subscribers have already unmounted (such as a listener rejected after sign-out during the release grace window), | ||
| // evict immediately so a fast re-sign-in never reads the stale error (#485). | ||
| // Otherwise, keep the entry for `FIRST_LOAD_ERROR_EVICT_DELAY_MS` so a suspended first load or active render can read and throw it (#742). | ||
| // `catchError(() => EMPTY)` below converts errors into completions before `share` sees them, so `tap.error` and unmount handle eviction. | ||
| if (this._subscriberCount === 0 && this._warmupSubscription?.closed) { | ||
| this._onEvict(); | ||
| } else { | ||
| setTimeout(this._onEvict, FIRST_LOAD_ERROR_EVICT_DELAY_MS); | ||
| } | ||
| }, | ||
| complete: () => { | ||
| this._isComplete = true; | ||
| this._updateImmutableStatus(); | ||
| } | ||
| }), | ||
| catchError(() => empty()), | ||
| shareReplay(1) | ||
| catchError(() => EMPTY), | ||
| // Multicast one Firebase listener across all subscribers: | ||
| // - `connector`: replay the latest emission to new subscribers on mount. | ||
| // - `resetOnError`: tear down the source subscription on error (upstream `catchError` converts errors to completions first). | ||
| // - `resetOnComplete`: keep the final value cached when a one-shot stream (`first()` in `*Once` hooks) completes, | ||
| // because RxJS auto-closes the subscriber on completion while the component remains mounted; evicting on completion | ||
| // would cause mounted `*Once` components to re-fetch on subsequent renders. | ||
| // - `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, | ||
| resetOnComplete: false, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| resetOnRefCountZero: () => timer(LISTENER_RELEASE_DELAY_MS).pipe(tap(this._onEvict)), | ||
| }) | ||
| ); | ||
| // warm up the observable | ||
| this._warmupSubscription = this._innerObservable.subscribe(); | ||
|
|
||
| // set a timeout for resetting the cache, subscriptions will cancel the timeout | ||
| // and reschedule again on unsubscribe | ||
| if (this._suspenseEnabled) { | ||
| // Noop if suspense is enabled | ||
| this._timeoutHandler = setTimeout(() => {}, this._timeoutWindow); | ||
| } else { | ||
| this._timeoutHandler = setTimeout(this._reset.bind(this), this._timeoutWindow); | ||
| } | ||
| // Release the warm-up subscription if no component subscribes within `_timeoutWindow` after the first emission settles. | ||
| // Wait until the first emission settles so a suspended component can resolve `firstEmission`, re-render, and call `_subscribe` (#525). | ||
| // If the stream already completed during warm-up (`first()`), `share` skips `resetOnRefCountZero`, so call `_onEvict` directly (#790). | ||
| const releaseWarmupWhenSettled = () => { | ||
| if (this._hasValue || this._error) { | ||
| this._warmupSubscription.unsubscribe(); | ||
| if (this._isComplete) { | ||
| this._onEvict(); | ||
| } | ||
| } else { | ||
| this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); | ||
| } | ||
| }; | ||
| this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); | ||
| } | ||
|
|
||
| get hasValue(): boolean { | ||
|
|
@@ -72,9 +113,6 @@ export class SuspenseSubject<T> extends Subject<T> { | |
| } | ||
|
|
||
| get value(): T { | ||
| // TODO figure out how to reset the cache here, if I _reset() here before throwing | ||
| // it doesn't seem to work. | ||
| // As it is now, this will burn the cache entry until the timeout fires. | ||
| if (this._error) { | ||
| throw this._error; | ||
| } else if (!this.hasValue) { | ||
|
|
@@ -108,24 +146,35 @@ export class SuspenseSubject<T> extends Subject<T> { | |
| this._updateImmutableStatus(); | ||
| } | ||
|
|
||
| private _reset() { | ||
| // seems to be undefined in tests? | ||
| if (this._warmupSubscription) { | ||
| this._warmupSubscription.unsubscribe(); | ||
| } | ||
| this._hasValue = false; | ||
| this._value = undefined; | ||
| this._error = undefined; | ||
| this._firstEmission = new Promise<void>((resolve) => (this._resolveFirstEmission = resolve)); | ||
| this._updateImmutableStatus(); | ||
| } | ||
|
|
||
| _subscribe(subscriber: Subscriber<T>): Subscription { | ||
| if (this._timeoutHandler) { | ||
| clearTimeout(this._timeoutHandler); | ||
| } | ||
| this._innerSubscriber = this._innerObservable.subscribe(subscriber); | ||
| return this._innerSubscriber; | ||
| this._subscriberCount++; | ||
| const innerSubscription = this._innerObservable.subscribe({ | ||
| next: (v) => subscriber.next(v), | ||
| error: (e) => subscriber.error(e), | ||
| complete: () => { | ||
| if (this._error) { | ||
| // `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); | ||
| } else { | ||
| subscriber.complete(); | ||
| } | ||
| }, | ||
| }); | ||
| // Hand off ownership of the shared source from the warm-up subscription to the real subscriber. | ||
| this._warmupSubscription.unsubscribe(); | ||
| this._innerSubscriber = innerSubscription; | ||
| return new Subscription(() => { | ||
| innerSubscription.unsubscribe(); | ||
| this._subscriberCount--; | ||
| if (this._subscriberCount === 0 && this._error) { | ||
| this._onEvict(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| get ourError() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sharenever sees an error here:catchError(() => empty())upstream turns it into a completion, so this option never applies (flipping it tofalseleaves the suite green). Errored entries are evicted only by thesetTimeout(onEvict)intap's error handler. Worth saying so here, so nobody deletes that timeout as redundant.