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 2 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,11 @@ | ||
| 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 RELEASE_DELAY_MS = 1000; | ||
|
|
||
| export class SuspenseSubject<T> extends Subject<T> { | ||
| private _value: T | undefined; | ||
| private _hasValue = false; | ||
|
|
@@ -18,7 +22,12 @@ export class SuspenseSubject<T> extends Subject<T> { | |
| // @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, | ||
| onEvict: () => void = () => {} | ||
| ) { | ||
| super(); | ||
| this._firstEmission = new Promise<void>((resolve) => (this._resolveFirstEmission = resolve)); | ||
|
|
||
|
|
@@ -42,26 +51,41 @@ export class SuspenseSubject<T> extends Subject<T> { | |
| this._error = e; | ||
| this._resolveFirstEmission(); | ||
| this._updateImmutableStatus(); | ||
| // Evict the errored entry after the current render reads it so a remount or ErrorBoundary retry starts fresh (#742, #485). | ||
| setTimeout(onEvict, RELEASE_DELAY_MS); | ||
| }, | ||
| complete: () => { | ||
| this._isComplete = true; | ||
| this._updateImmutableStatus(); | ||
| } | ||
| }), | ||
| catchError(() => empty()), | ||
| shareReplay(1) | ||
| // Multicast one underlying Firebase listener across all subscribers: | ||
| // - `connector`: cache and replay the most recent emission to new subscribers on mount. | ||
| // - `resetOnError`: tear down the source subscription when the stream errors. | ||
| // - `resetOnComplete`: keep the final value cached when a one-shot stream (`first()`) completes. | ||
| // - `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, | ||
| 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(RELEASE_DELAY_MS).pipe(tap(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. | ||
| // Do not release while the first emission is still pending: a suspended component is waiting on `firstEmission`, and | ||
| // React will not retry the render and call `_subscribe` until that promise resolves (#525). | ||
| const releaseWarmupWhenSettled = () => { | ||
| if (this._hasValue || this._error) { | ||
| this._warmupSubscription.unsubscribe(); | ||
| } else { | ||
| this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); | ||
| } | ||
| }; | ||
| this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); | ||
| } | ||
|
|
||
| get hasValue(): boolean { | ||
|
|
@@ -108,23 +132,13 @@ 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); | ||
| // Hand off ownership of the shared source from the warm-up subscription to the real subscriber. | ||
| this._warmupSubscription.unsubscribe(); | ||
| return this._innerSubscriber; | ||
| } | ||
|
|
||
|
|
||
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.