From 36d66432b92cc9154f9404418ee525be2ddfcd7e Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:05:13 +0000 Subject: [PATCH 01/20] fix(core): release subscriptions on unmount and evict errored cache entries --- src/SuspenseSubject.ts | 62 ++++++++++++++++----------- src/useObservable.ts | 2 +- test/useObservable.test.tsx | 84 ++++++++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 26 deletions(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index 5b7ef884..6b6f4a1a 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -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 extends Subject { private _value: T | undefined; private _hasValue = false; @@ -18,7 +22,12 @@ export class SuspenseSubject extends Subject { // @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, private _timeoutWindow: number, private _suspenseEnabled: boolean) { + constructor( + innerObservable: Observable, + private _timeoutWindow: number, + _suspenseEnabled: boolean, + onEvict: () => void = () => {} + ) { super(); this._firstEmission = new Promise((resolve) => (this._resolveFirstEmission = resolve)); @@ -42,6 +51,8 @@ export class SuspenseSubject extends Subject { 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; @@ -49,19 +60,32 @@ export class SuspenseSubject extends Subject { } }), 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(1), + resetOnError: true, + resetOnComplete: false, + 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 extends Subject { 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((resolve) => (this._resolveFirstEmission = resolve)); - this._updateImmutableStatus(); - } - _subscribe(subscriber: Subscriber): 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; } diff --git a/src/useObservable.ts b/src/useObservable.ts index 237f6e35..53816fcd 100644 --- a/src/useObservable.ts +++ b/src/useObservable.ts @@ -21,7 +21,7 @@ export function preloadObservable(source: Observable, id: string, suspense if (preloadedObservables.has(id)) { return preloadedObservables.get(id) as SuspenseSubject; } else { - const observable = new SuspenseSubject(source, DEFAULT_TIMEOUT, suspenseEnabled); + const observable: SuspenseSubject = new SuspenseSubject(source, DEFAULT_TIMEOUT, suspenseEnabled, () => preloadedObservables.get(id) === observable && preloadedObservables.delete(id)); preloadedObservables.set(id, observable); return observable; } diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index ada53ab5..5f2018d6 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, render, renderHook, waitFor } from '@testing-library/reac import * as React from 'react'; import { Writable } from 'node:stream'; import { renderToString, renderToPipeableStream } from 'react-dom/server'; -import { of, Subject, BehaviorSubject, throwError } from 'rxjs'; +import { Observable, of, Subject, BehaviorSubject, throwError } from 'rxjs'; import { useObservable, ReactFireOptions } from '../src/index'; describe('useObservable', () => { @@ -427,4 +427,86 @@ describe('useObservable', () => { expect(html).not.toContain('another-requests-value'); }); }); + + describe('Subscription lifecycle & error eviction', () => { + it('releases the underlying source subscription after unmount', async () => { + for (const suspense of [false, true]) { + const subject$ = new Subject(); + const observableId = `sub-release-${suspense}-${Math.random()}`; + + const Obs = () => { + const { data } = useObservable(observableId, subject$, { suspense }); + return
{String(data)}
; + }; + + const { unmount, getByTestId } = render( + loading}> + + + ); + + await act(async () => { + subject$.next(42); + }); + + await waitFor(() => expect(getByTestId('obs')).toHaveTextContent('42')); + expect(subject$.observed).toBe(true); + + unmount(); + await waitFor(() => expect(subject$.observed).toBe(false), { timeout: 2500 }); + } + }); + + it('evicts an errored cache entry so a subsequent mount with the same observableId recovers', async () => { + const observableId = `error-evict-${Math.random()}`; + let shouldFail = true; + const source$ = new Observable((subscriber) => { + if (shouldFail) { + subscriber.error(new Error('permission-denied')); + } else { + subscriber.next('recovered'); + } + }); + + class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return
{this.state.error.message}
; + } + return this.props.children; + } + } + + const Obs = () => { + const { data, status } = useObservable(observableId, source$); + return
{status === 'success' ? data : 'loading'}
; + }; + + const first = render( + + + + ); + + await waitFor(() => expect(first.getByTestId('err')).toHaveTextContent('permission-denied')); + first.unmount(); + + // Wait for the 1s error eviction window to clear the errored entry from preloadedObservables + await new Promise((r) => setTimeout(r, 1100)); + shouldFail = false; + + const second = render( + + + + ); + + await waitFor(() => expect(second.getByTestId('val')).toHaveTextContent('recovered')); + second.unmount(); + }); + }); }); From b092103ca85d0a342d51af1be40e255bb604c63e Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:59:32 +0000 Subject: [PATCH 02/20] test(core): add error-after-unmount test (#485) and avoid top-level import conflict --- test/useObservable.test.tsx | 39 +++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index 5f2018d6..298ebad8 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, render, renderHook, waitFor } from '@testing-library/reac import * as React from 'react'; import { Writable } from 'node:stream'; import { renderToString, renderToPipeableStream } from 'react-dom/server'; -import { Observable, of, Subject, BehaviorSubject, throwError } from 'rxjs'; +import { of, Subject, BehaviorSubject, throwError } from 'rxjs'; import { useObservable, ReactFireOptions } from '../src/index'; describe('useObservable', () => { @@ -460,13 +460,6 @@ describe('useObservable', () => { it('evicts an errored cache entry so a subsequent mount with the same observableId recovers', async () => { const observableId = `error-evict-${Math.random()}`; let shouldFail = true; - const source$ = new Observable((subscriber) => { - if (shouldFail) { - subscriber.error(new Error('permission-denied')); - } else { - subscriber.next('recovered'); - } - }); class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { state: { error: Error | null } = { error: null }; @@ -482,6 +475,7 @@ describe('useObservable', () => { } const Obs = () => { + const source$ = shouldFail ? throwError(() => new Error('permission-denied')) : of('recovered'); const { data, status } = useObservable(observableId, source$); return
{status === 'success' ? data : 'loading'}
; }; @@ -508,5 +502,34 @@ describe('useObservable', () => { await waitFor(() => expect(second.getByTestId('val')).toHaveTextContent('recovered')); second.unmount(); }); + + it('evicts a cache entry when an error arrives after unmount during the release grace window (#485)', async () => { + const observableId = `error-after-unmount-${Math.random()}`; + const firstStream$ = new BehaviorSubject('initial-user-doc'); + const secondStream$ = new BehaviorSubject('reauthenticated-user-doc'); + let currentStream$ = firstStream$; + + const Obs = () => { + const { data, status } = useObservable(observableId, currentStream$); + return
{status === 'success' ? data : 'loading'}
; + }; + + const first = render(); + await waitFor(() => expect(first.getByTestId('val')).toHaveTextContent('initial-user-doc')); + + // Unmount first (e.g. route leaves protected view on sign-out), then emit permission-denied + // while the 1s release grace window is still active. + first.unmount(); + act(() => { + firstStream$.error(new Error('permission-denied-after-unmount')); + }); + + await new Promise((r) => setTimeout(r, 1100)); + currentStream$ = secondStream$; + + const second = render(); + await waitFor(() => expect(second.getByTestId('val')).toHaveTextContent('reauthenticated-user-doc')); + second.unmount(); + }); }); }); From ae14caaab5e5e1446ab886914da7d67c386917c6 Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:41:24 +0000 Subject: [PATCH 03/20] fix(core): require rxjs ^7.1.0, evict unconsumed completed warmups, and add lifecycle tests --- package-lock.json | 2 +- package.json | 2 +- src/SuspenseSubject.ts | 27 ++++++------ test/useObservable.test.tsx | 83 +++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e974b3b..87698f63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "rxfire": "^6.1.0", - "rxjs": "^6.6.3 || ^7.0.1", + "rxjs": "^7.1.0", "use-sync-external-store": "^1.2.0" }, "devDependencies": { diff --git a/package.json b/package.json index 6b6b707f..82e7ed22 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ }, "dependencies": { "rxfire": "^6.1.0", - "rxjs": "^6.6.3 || ^7.0.1", + "rxjs": "^7.1.0", "use-sync-external-store": "^1.2.0" }, "allowScripts": { diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index 6b6f4a1a..9d7c2cc5 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -1,4 +1,4 @@ -import { empty, Observable, ReplaySubject, Subject, Subscriber, Subscription, timer } from 'rxjs'; +import { EMPTY, Observable, ReplaySubject, Subject, Subscriber, Subscription, timer } from 'rxjs'; import { catchError, share, tap } from 'rxjs/operators'; import { ObservableStatus } from './useObservable.js'; @@ -52,6 +52,7 @@ export class SuspenseSubject extends Subject { this._resolveFirstEmission(); this._updateImmutableStatus(); // Evict the errored entry after the current render reads it so a remount or ErrorBoundary retry starts fresh (#742, #485). + // `catchError(() => EMPTY)` below converts errors into completions before `share` sees them, so this timer alone evicts errored entries. setTimeout(onEvict, RELEASE_DELAY_MS); }, complete: () => { @@ -59,12 +60,14 @@ export class SuspenseSubject extends Subject { this._updateImmutableStatus(); } }), - catchError(() => empty()), - // 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`. + 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 `RELEASE_DELAY_MS` with zero subscribers. share({ connector: () => new ReplaySubject(1), resetOnError: true, @@ -76,11 +79,14 @@ export class SuspenseSubject extends Subject { this._warmupSubscription = this._innerObservable.subscribe(); // 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). + // 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) { + onEvict(); + } } else { this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); } @@ -96,9 +102,6 @@ export class SuspenseSubject extends Subject { } 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) { diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index 298ebad8..7fbcea5b 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -531,5 +531,88 @@ describe('useObservable', () => { await waitFor(() => expect(second.getByTestId('val')).toHaveTextContent('reauthenticated-user-doc')); second.unmount(); }); + + it('reuses the active subscription when remounting within the 1s release grace window', async () => { + const observableId = `grace-window-${Math.random()}`; + let subscribeCount = 0; + const base$ = new BehaviorSubject('cached-val'); + const counted$ = base$.lift({ + call(subscriber, source) { + subscribeCount++; + return source.subscribe(subscriber); + } + }); + + const Obs = () => { + const { data } = useObservable(observableId, counted$); + return
{String(data)}
; + }; + + const first = render(); + await waitFor(() => expect(first.getByTestId('val')).toHaveTextContent('cached-val')); + expect(subscribeCount).toBe(1); + first.unmount(); + + // Remount after 200ms (inside the 1000ms grace window); must reuse the active subscription. + await new Promise((r) => setTimeout(r, 200)); + const second = render(); + await waitFor(() => expect(second.getByTestId('val')).toHaveTextContent('cached-val')); + expect(subscribeCount).toBe(1); + second.unmount(); + + // After the 1000ms grace window expires, the next mount subscribes fresh. + await new Promise((r) => setTimeout(r, 1100)); + const third = render(); + await waitFor(() => expect(third.getByTestId('val')).toHaveTextContent('cached-val')); + expect(subscribeCount).toBe(2); + third.unmount(); + }); + + it('releases an unconsumed warm-up subscription only after the first emission settles (#525, #790)', async () => { + const { SuspenseSubject } = await import('../src/SuspenseSubject'); + const source$ = new Subject(); + let evicted = false; + new SuspenseSubject(source$, 50, true, () => { + evicted = true; + }); + + // After the first 50ms window, the first emission is still pending, so warm-up stays subscribed (#525). + await new Promise((r) => setTimeout(r, 80)); + expect(source$.observed).toBe(true); + + // Settle and complete the stream without any subscriber mounting (e.g. SSR or unused preload). + source$.next('settled'); + source$.complete(); + + // On the next 50ms check, releaseWarmupWhenSettled unsubscribes warm-up and evicts the completed entry (#790). + await waitFor(() => expect(evicted).toBe(true), { timeout: 500 }); + expect(source$.observed).toBe(false); + }); + + it('preserves a newer cache entry when a late subscriber on an evicted subject unsubscribes', async () => { + const { preloadObservable } = await import('../src/useObservable'); + const observableId = `identity-guard-${Math.random()}`; + + const firstSubject = preloadObservable(new BehaviorSubject('first'), observableId); + const initialSub = firstSubject.subscribe(); + initialSub.unsubscribe(); + + // Wait for firstSubject to be evicted from preloadedObservables. + await new Promise((r) => setTimeout(r, 1100)); + + // Register a new subject under the same observableId and keep a subscriber active. + const secondSubject = preloadObservable(new BehaviorSubject('second'), observableId); + expect(secondSubject).not.toBe(firstSubject); + const activeSub = secondSubject.subscribe(); + + // A late caller holding the evicted firstSubject subscribes and unsubscribes, re-arming firstSubject's eviction timer. + const lateSub = firstSubject.subscribe(); + lateSub.unsubscribe(); + await new Promise((r) => setTimeout(r, 1100)); + + // The identity guard in onEvict must keep secondSubject in the cache. + expect(preloadObservable(new BehaviorSubject('third'), observableId)).toBe(secondSubject); + activeSub.unsubscribe(); + }); }); }); From 8f99728d12dcd637235c8023ea3dd454ed2406d9 Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:53:23 +0000 Subject: [PATCH 04/20] fix(core): evict immediately when an unmounted listener errors (#485) --- src/SuspenseSubject.ts | 40 ++++++++++++++++++++++++++++--------- test/useObservable.test.tsx | 3 +-- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index 9d7c2cc5..654d9bdf 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -16,6 +16,7 @@ export class SuspenseSubject extends Subject { private _warmupSubscription: Subscription; private _immutableStatus: ObservableStatus; 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; @@ -26,7 +27,7 @@ export class SuspenseSubject extends Subject { innerObservable: Observable, private _timeoutWindow: number, _suspenseEnabled: boolean, - onEvict: () => void = () => {} + private _onEvict: () => void = () => {} ) { super(); this._firstEmission = new Promise((resolve) => (this._resolveFirstEmission = resolve)); @@ -51,9 +52,15 @@ export class SuspenseSubject extends Subject { 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). - // `catchError(() => EMPTY)` below converts errors into completions before `share` sees them, so this timer alone evicts errored entries. - setTimeout(onEvict, RELEASE_DELAY_MS); + // 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 `RELEASE_DELAY_MS` so a suspended 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, RELEASE_DELAY_MS); + } }, complete: () => { this._isComplete = true; @@ -72,7 +79,7 @@ export class SuspenseSubject extends Subject { connector: () => new ReplaySubject(1), resetOnError: true, resetOnComplete: false, - resetOnRefCountZero: () => timer(RELEASE_DELAY_MS).pipe(tap(onEvict)), + resetOnRefCountZero: () => timer(RELEASE_DELAY_MS).pipe(tap(this._onEvict)), }) ); // warm up the observable @@ -80,12 +87,12 @@ export class SuspenseSubject extends Subject { // 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). + // 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) { - onEvict(); + this._onEvict(); } } else { this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); @@ -139,10 +146,25 @@ export class SuspenseSubject extends Subject { if (this._timeoutHandler) { clearTimeout(this._timeoutHandler); } - this._innerSubscriber = this._innerObservable.subscribe(subscriber); + this._subscriberCount++; + this._innerSubscriber = this._innerObservable.subscribe({ + next: (v) => subscriber.next(v), + error: (e) => subscriber.error(e), + complete: () => { + // Notify `useObservable` (`onStoreChange`) without calling `subscriber.complete()`, + // because `subscriber.complete()` auto-unsubscribes `subscriber` before React unmounts. + subscriber.next(this._value as T); + }, + }); // Hand off ownership of the shared source from the warm-up subscription to the real subscriber. this._warmupSubscription.unsubscribe(); - return this._innerSubscriber; + return new Subscription(() => { + this._innerSubscriber.unsubscribe(); + this._subscriberCount--; + if (this._subscriberCount === 0 && this._error) { + this._onEvict(); + } + }); } get ourError() { diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index 7fbcea5b..b0b35ea0 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -518,13 +518,12 @@ describe('useObservable', () => { await waitFor(() => expect(first.getByTestId('val')).toHaveTextContent('initial-user-doc')); // Unmount first (e.g. route leaves protected view on sign-out), then emit permission-denied - // while the 1s release grace window is still active. + // while the 1s release grace window is still active, and remount immediately without waiting 1s. first.unmount(); act(() => { firstStream$.error(new Error('permission-denied-after-unmount')); }); - await new Promise((r) => setTimeout(r, 1100)); currentStream$ = secondStream$; const second = render(); From d9d2a9736572a3fdf49434545ffbdd7b77639c90 Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:15:46 +0000 Subject: [PATCH 05/20] fix(core): scope inner subscription teardown per subscriber in SuspenseSubject --- src/SuspenseSubject.ts | 30 ++++++++++++------ test/useObservable.test.tsx | 63 +++++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index 654d9bdf..b9aed867 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -4,7 +4,11 @@ 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; +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 extends Subject { private _value: T | undefined; @@ -54,12 +58,12 @@ export class SuspenseSubject extends Subject { 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 `RELEASE_DELAY_MS` so a suspended or active render can read and throw it (#742). + // 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, RELEASE_DELAY_MS); + setTimeout(this._onEvict, FIRST_LOAD_ERROR_EVICT_DELAY_MS); } }, complete: () => { @@ -74,12 +78,12 @@ export class SuspenseSubject extends Subject { // - `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 `RELEASE_DELAY_MS` with zero subscribers. + // - `resetOnRefCountZero`: detach the Firebase listener and evict the cache entry after `LISTENER_RELEASE_DELAY_MS` with zero subscribers. share({ connector: () => new ReplaySubject(1), resetOnError: true, resetOnComplete: false, - resetOnRefCountZero: () => timer(RELEASE_DELAY_MS).pipe(tap(this._onEvict)), + resetOnRefCountZero: () => timer(LISTENER_RELEASE_DELAY_MS).pipe(tap(this._onEvict)), }) ); // warm up the observable @@ -147,19 +151,25 @@ export class SuspenseSubject extends Subject { clearTimeout(this._timeoutHandler); } this._subscriberCount++; - this._innerSubscriber = this._innerObservable.subscribe({ + const innerSubscription = this._innerObservable.subscribe({ next: (v) => subscriber.next(v), error: (e) => subscriber.error(e), complete: () => { - // Notify `useObservable` (`onStoreChange`) without calling `subscriber.complete()`, - // because `subscriber.complete()` auto-unsubscribes `subscriber` before React unmounts. - subscriber.next(this._value as T); + 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(() => { - this._innerSubscriber.unsubscribe(); + innerSubscription.unsubscribe(); this._subscriberCount--; if (this._subscriberCount === 0 && this._error) { this._onEvict(); diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index b0b35ea0..2278fb31 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -509,12 +509,26 @@ describe('useObservable', () => { const secondStream$ = new BehaviorSubject('reauthenticated-user-doc'); let currentStream$ = firstStream$; + class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ?
{this.state.error.message}
: this.props.children; + } + } + const Obs = () => { const { data, status } = useObservable(observableId, currentStream$); return
{status === 'success' ? data : 'loading'}
; }; - const first = render(); + const first = render( + + + + ); await waitFor(() => expect(first.getByTestId('val')).toHaveTextContent('initial-user-doc')); // Unmount first (e.g. route leaves protected view on sign-out), then emit permission-denied @@ -526,9 +540,52 @@ describe('useObservable', () => { currentStream$ = secondStream$; - const second = render(); - await waitFor(() => expect(second.getByTestId('val')).toHaveTextContent('reauthenticated-user-doc')); + const second = render( + + + + ); + await waitFor(() => expect(second.queryByTestId('val')).toHaveTextContent('reauthenticated-user-doc')); + second.unmount(); + }); + + it('keeps updating the remaining subscriber when another subscriber of the same observableId unmounts, and releases the source after both unmount', async () => { + const subject$ = new BehaviorSubject('a'); + const observableId = `shared-subscriber-update-${Math.random()}`; + + const Obs = ({ testId }: { testId: string }) => { + const { data } = useObservable(observableId, subject$); + return
{String(data)}
; + }; + + const first = render(); + const second = render(); + await waitFor(() => expect(second.getByTestId('two')).toHaveTextContent('a')); + + first.unmount(); + act(() => subject$.next('b')); + await waitFor(() => expect(second.getByTestId('two')).toHaveTextContent('b'), { timeout: 1000 }); + + second.unmount(); + await waitFor(() => expect(subject$.observed).toBe(false), { timeout: 2500 }); + }); + + it('detaches the source after two subscribers of the same observableId unmount', async () => { + const subject$ = new BehaviorSubject('a'); + const observableId = `shared-subscriber-release-${Math.random()}`; + + const Obs = ({ testId }: { testId: string }) => { + const { data } = useObservable(observableId, subject$); + return
{String(data)}
; + }; + + const first = render(); + const second = render(); + await waitFor(() => expect(second.getByTestId('two')).toHaveTextContent('a')); + + first.unmount(); second.unmount(); + await waitFor(() => expect(subject$.observed).toBe(false), { timeout: 3000 }); }); it('reuses the active subscription when remounting within the 1s release grace window', async () => { From eb7910f9e4af6e268fa9534ea8aad0f1d392824c Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Wed, 4 Nov 2026 11:51:54 -0800 Subject: [PATCH 06/20] fix(core): keep Suspense first-load errors cached and tear down foreign 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. --- src/SuspenseSubject.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index b9aed867..1632bfb8 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -1,4 +1,4 @@ -import { EMPTY, Observable, ReplaySubject, Subject, Subscriber, Subscription, timer } from 'rxjs'; +import { EMPTY, from, Observable, ReplaySubject, Subject, Subscriber, Subscription, timer } from 'rxjs'; import { catchError, share, tap } from 'rxjs/operators'; import { ObservableStatus } from './useObservable.js'; @@ -30,7 +30,7 @@ export class SuspenseSubject extends Subject { constructor( innerObservable: Observable, private _timeoutWindow: number, - _suspenseEnabled: boolean, + private _suspenseEnabled: boolean, private _onEvict: () => void = () => {} ) { super(); @@ -45,7 +45,9 @@ export class SuspenseSubject extends Subject { firstValuePromise: this._firstEmission } as ObservableStatus; - 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( tap({ next: (v) => { this._next(v); @@ -60,9 +62,11 @@ export class SuspenseSubject extends Subject { // 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. + // In Suspense mode the error is kept, as in earlier releases: React may retry the suspended + // render after any delay, and an evicted entry would suspend again instead of reaching the boundary. if (this._subscriberCount === 0 && this._warmupSubscription?.closed) { this._onEvict(); - } else { + } else if (!this._suspenseEnabled) { setTimeout(this._onEvict, FIRST_LOAD_ERROR_EVICT_DELAY_MS); } }, From 0c7c91af820e0af7e7d75444826773521a6ca5de Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Wed, 4 Nov 2026 12:33:37 -0800 Subject: [PATCH 07/20] test(core): guard the error-to-completion branch in SuspenseSubject --- test/suspenseSubject-error-branch.test.tsx | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/suspenseSubject-error-branch.test.tsx diff --git a/test/suspenseSubject-error-branch.test.tsx b/test/suspenseSubject-error-branch.test.tsx new file mode 100644 index 00000000..62bb42d3 --- /dev/null +++ b/test/suspenseSubject-error-branch.test.tsx @@ -0,0 +1,57 @@ +import '@testing-library/jest-dom/extend-expect'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { Observable, timer, throwError } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; +import { useObservable } from '../src/index'; + +// Guards the `if (this._error)` branch in SuspenseSubject._subscribe's `complete` handler. +// `catchError(() => EMPTY)` turns a source error into a completion; that branch turns it back into +// a notification instead of completing the React subscriber. Completing instead auto-unsubscribes +// the mounted subscriber, and a mounted hook with `initialData` then resubscribes in a loop. +describe('SuspenseSubject error-to-completion branch', () => { + afterEach(() => cleanup()); + + class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ?
{this.state.error.message}
: this.props.children; + } + } + + it('surfaces a source error to a mounted initialData hook once, without resubscribing', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + let subscriptions = 0; + const source$ = new Observable((subscriber) => { + subscriptions++; + return timer(50) + .pipe(mergeMap(() => throwError(() => new Error('permission-denied')))) + .subscribe(subscriber); + }); + const observableId = `error-branch-${Math.random()}`; + + const Obs = () => { + const { data } = useObservable(observableId, source$, { initialData: 'seed' }); + return
{data}
; + }; + + const { getByTestId } = render( + + + + ); + expect(getByTestId('val')).toHaveTextContent('seed'); + + await waitFor(() => expect(getByTestId('err')).toHaveTextContent('permission-denied')); + // Give a resubscribe loop time to show itself. + await new Promise((r) => setTimeout(r, 1500)); + expect(subscriptions).toBe(1); + } finally { + consoleError.mockRestore(); + } + }, 10000); +}); From 587e43081c63a944ce177962bef7befaf1ef3df6 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 14:53:44 -0800 Subject: [PATCH 08/20] fix(core): keep the Suspense error policy for preload-created entries A preload helper cannot read the Suspense context, so it creates its cache entry with Suspense off. When a Suspense hook later reads that entry, switch it to the Suspense error policy (one way), and re-check the policy when the 1s first-load error eviction fires. --- src/SuspenseSubject.ts | 13 ++++- src/useObservable.ts | 6 ++- test/preload-suspense-policy.test.tsx | 68 +++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 test/preload-suspense-policy.test.tsx diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index 1632bfb8..d1b8329c 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -67,7 +67,12 @@ export class SuspenseSubject extends Subject { if (this._subscriberCount === 0 && this._warmupSubscription?.closed) { this._onEvict(); } else if (!this._suspenseEnabled) { - setTimeout(this._onEvict, FIRST_LOAD_ERROR_EVICT_DELAY_MS); + // Re-check when the timer fires: a Suspense hook may read this entry after the error arrives. + setTimeout(() => { + if (!this._suspenseEnabled) { + this._onEvict(); + } + }, FIRST_LOAD_ERROR_EVICT_DELAY_MS); } }, complete: () => { @@ -109,6 +114,12 @@ export class SuspenseSubject extends Subject { this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); } + // An entry created with Suspense off (by a preload helper, which cannot read the Suspense context) + // switches to the Suspense error policy once a Suspense hook reads it. It never switches back. + enableSuspense() { + this._suspenseEnabled = true; + } + get hasValue(): boolean { // hasValue returns true if there's an error too // so that after we resolve the promise & useObservable is called again diff --git a/src/useObservable.ts b/src/useObservable.ts index 41f68d90..de047038 100644 --- a/src/useObservable.ts +++ b/src/useObservable.ts @@ -20,7 +20,11 @@ if (!(globalThis as any as ReactFireGlobals)._reactFirePreloadedObservables) { // child that will consume the observable export function preloadObservable(source: Observable, id: string, suspenseEnabled = false) { if (preloadedObservables.has(id)) { - return preloadedObservables.get(id) as SuspenseSubject; + const existing = preloadedObservables.get(id) as SuspenseSubject; + if (suspenseEnabled) { + existing.enableSuspense(); + } + return existing; } else { const observable: SuspenseSubject = new SuspenseSubject(source, DEFAULT_TIMEOUT, suspenseEnabled, () => preloadedObservables.get(id) === observable && preloadedObservables.delete(id)); preloadedObservables.set(id, observable); diff --git a/test/preload-suspense-policy.test.tsx b/test/preload-suspense-policy.test.tsx new file mode 100644 index 00000000..79cf43c1 --- /dev/null +++ b/test/preload-suspense-policy.test.tsx @@ -0,0 +1,68 @@ +import '@testing-library/jest-dom/extend-expect'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { Observable, timer, throwError } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; +import { preloadObservable, useObservable } from '../src/index'; + +// A preload helper cannot read the Suspense context, so it creates its entry with Suspense off. +// Once a Suspense-mode hook reads that entry, a first-load error must stay cached like a hook-created +// entry's: evicting it lets a later Suspense read suspend and refetch instead of reaching the boundary. +describe('preload-created entry read by a Suspense hook', () => { + afterEach(() => cleanup()); + + class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ?
{this.state.error.message}
: this.props.children; + } + } + + // readDelay 0: the hook reads before the error arrives. 200: the error arrives first and the 1s timer is already running. + for (const readDelay of [0, 200]) { + it(`keeps a first-load error cached past the 1s eviction (Suspense read after ${readDelay}ms)`, async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + let subscriptions = 0; + const source$ = new Observable((subscriber) => { + subscriptions++; + return timer(50) + .pipe(mergeMap(() => throwError(() => new Error('permission-denied')))) + .subscribe(subscriber); + }); + const observableId = `preload-suspense-${readDelay}-${Math.random()}`; + + preloadObservable(source$, observableId); + await new Promise((resolve) => setTimeout(resolve, readDelay)); + + const Obs = () => { + const { data } = useObservable(observableId, source$, { suspense: true }); + return
{data}
; + }; + const tree = () => ( + + loading}> + + + + ); + + const first = render(tree()); + await waitFor(() => expect(first.getByTestId('err')).toHaveTextContent('permission-denied')); + first.unmount(); + + await new Promise((resolve) => setTimeout(resolve, 1300)); + + const second = render(tree()); + expect(second.queryByTestId('fallback')).toBeNull(); + expect(second.getByTestId('err')).toHaveTextContent('permission-denied'); + expect(subscriptions).toBe(1); + } finally { + consoleError.mockRestore(); + } + }, 10000); + } +}); From b73bc24631c356dad561f2138cd4e1f8ce41aa89 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 15:02:41 -0800 Subject: [PATCH 09/20] fix(sdk): run each useInit* initializer once per app The initialized SDK no longer depends on the observable cache entry, which the warm-up release can evict while a Suspense boundary never commits. Initializers such as Firestore persistence reject a second run. --- src/index.ts | 1 + src/sdk.tsx | 20 ++++++++- test/sdk-init-cache.test.tsx | 82 ++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 test/sdk-init-cache.test.tsx diff --git a/src/index.ts b/src/index.ts index 028a4a62..2dda8cae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export type ReactFireGlobals = { _reactFireDatabaseCachedQueries: Array; _reactFireFirestoreQueryCache: Array; _reactFirePreloadedObservables: Map>; + _reactFireInitializedSdks: WeakMap>>; }; export class ReactFireError extends Error { diff --git a/src/sdk.tsx b/src/sdk.tsx index c3238604..dc96fa24 100644 --- a/src/sdk.tsx +++ b/src/sdk.tsx @@ -13,7 +13,13 @@ import { useFirebaseApp } from './firebaseApp.js'; import { FirebaseApp } from 'firebase/app'; import { ObservableStatus, useObservable } from './useObservable.js'; import { from } from 'rxjs'; -import { ReactFireOptions } from './index.js'; +import { ReactFireGlobals, ReactFireOptions } from './index.js'; + +// Initializers run once per app and SDK, independent of the observable cache, which can evict a completed entry. +const initializedSdks: WeakMap>> = (globalThis as any as ReactFireGlobals)._reactFireInitializedSdks || new WeakMap(); +if (!(globalThis as any as ReactFireGlobals)._reactFireInitializedSdks) { + (globalThis as any as ReactFireGlobals)._reactFireInitializedSdks = initializedSdks; +} export const AppCheckSdkContext = React.createContext(undefined); export const AuthSdkContext = React.createContext(undefined); @@ -64,7 +70,17 @@ function useInitSdk( throw new Error(`Cannot initialize SDK ${sdkName} because it already exists in Context`); } - const initializeSdk = React.useMemo(() => sdkInitializer(firebaseApp), [firebaseApp]); + const initializeSdk = React.useMemo(() => { + let sdks = initializedSdks.get(firebaseApp); + if (!sdks) { + sdks = new Map(); + initializedSdks.set(firebaseApp, sdks); + } + if (!sdks.has(sdkName)) { + sdks.set(sdkName, sdkInitializer(firebaseApp)); + } + return sdks.get(sdkName) as Promise; + }, [firebaseApp]); return useObservable(`firebase-sdk:${sdkName}:${firebaseApp.name}`, from(initializeSdk), options); } diff --git a/test/sdk-init-cache.test.tsx b/test/sdk-init-cache.test.tsx new file mode 100644 index 00000000..d9e39ee9 --- /dev/null +++ b/test/sdk-init-cache.test.tsx @@ -0,0 +1,82 @@ +import '@testing-library/jest-dom/extend-expect'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { deleteApp, getApps, initializeApp } from 'firebase/app'; +import type { Firestore } from 'firebase/firestore'; +import * as React from 'react'; +import { FirebaseAppProvider, useInitFirestore } from '../src/index'; +import { baseConfig } from './appConfig'; + +// SDK initializers have side effects that can only run once per app (Firestore persistence +// rejects a second `initializeFirestore`). The initialized SDK must outlive the observable +// cache entry, which the 30s warm-up release can evict while a Suspense boundary never commits. +describe('useInit* initializer', () => { + afterEach(async () => { + cleanup(); + await Promise.all(getApps().map((app) => deleteApp(app))); + }); + + class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ?
{this.state.error.message}
: this.props.children; + } + } + + it('does not run again after the warm-up release while a sibling keeps the boundary suspended', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const app = initializeApp(baseConfig, `init-cache-${Math.random()}`); + let calls = 0; + const initializer = async () => { + calls++; + if (calls > 1) { + throw new Error('Firestore has already been started'); + } + return { app } as unknown as Firestore; + }; + + const Init = () => { + useInitFirestore(initializer, { suspense: true }); + return
ready
; + }; + const never = new Promise(() => {}); + const Forever = () => { + throw never; + }; + + const first = render( + + + loading}> + + + + + + ); + expect(first.getByTestId('fallback')).toBeInTheDocument(); + // Past the 30s warm-up release; the boundary never committed, so nothing subscribed. + await new Promise((resolve) => setTimeout(resolve, 31_500)); + first.unmount(); + + const second = render( + + + loading}> + + + + + ); + await waitFor(() => expect(second.queryByTestId('ready') ?? second.queryByTestId('err')).not.toBeNull()); + expect(second.queryByTestId('err')).toBeNull(); + expect(second.getByTestId('ready')).toBeInTheDocument(); + expect(calls).toBe(1); + } finally { + consoleError.mockRestore(); + } + }, 45_000); +}); From 909ce4632289bdaf34ca40e7aa1dc3684d2e775b Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 15:29:21 -0800 Subject: [PATCH 10/20] docs: regenerate reference docs --- docs/reference/classes/ReactFireError.md | 10 +++++----- docs/reference/functions/checkIdField.md | 2 +- docs/reference/functions/checkOptions.md | 2 +- docs/reference/functions/checkinitialData.md | 2 +- docs/reference/functions/useAnalytics.md | 2 +- docs/reference/functions/useAppCheck.md | 2 +- docs/reference/functions/useAuth.md | 2 +- docs/reference/functions/useDatabase.md | 2 +- docs/reference/functions/useFirestore.md | 2 +- docs/reference/functions/useFunctions.md | 2 +- docs/reference/functions/useObservable.md | 2 +- docs/reference/functions/usePerformance.md | 2 +- docs/reference/functions/useRemoteConfig.md | 2 +- docs/reference/functions/useStorage.md | 2 +- docs/reference/interfaces/ObservableStatus.md | 14 +++++++------- docs/reference/interfaces/ReactFireOptions.md | 10 +++++----- .../interfaces/SignInCheckOptionsBasic.md | 8 ++++---- .../interfaces/SignInCheckOptionsClaimsObject.md | 8 ++++---- .../SignInCheckOptionsClaimsValidator.md | 8 ++++---- docs/reference/type-aliases/ReactFireGlobals.md | 8 ++++++++ docs/reference/variables/AnalyticsProvider.md | 2 +- docs/reference/variables/AnalyticsSdkContext.md | 2 +- docs/reference/variables/AppCheckProvider.md | 2 +- docs/reference/variables/AppCheckSdkContext.md | 2 +- docs/reference/variables/AuthProvider.md | 2 +- docs/reference/variables/AuthSdkContext.md | 2 +- docs/reference/variables/DatabaseProvider.md | 2 +- docs/reference/variables/DatabaseSdkContext.md | 2 +- docs/reference/variables/FirestoreProvider.md | 2 +- docs/reference/variables/FirestoreSdkContext.md | 2 +- docs/reference/variables/FunctionsProvider.md | 2 +- docs/reference/variables/FunctionsSdkContext.md | 2 +- docs/reference/variables/PerformanceProvider.md | 2 +- docs/reference/variables/PerformanceSdkContext.md | 2 +- docs/reference/variables/RemoteConfigProvider.md | 2 +- docs/reference/variables/RemoteConfigSdkContext.md | 2 +- docs/reference/variables/StorageProvider.md | 2 +- docs/reference/variables/StorageSdkContext.md | 2 +- docs/reference/variables/useInitAnalytics.md | 2 +- docs/reference/variables/useInitAppCheck.md | 2 +- docs/reference/variables/useInitAuth.md | 2 +- docs/reference/variables/useInitDatabase.md | 2 +- docs/reference/variables/useInitFirestore.md | 2 +- docs/reference/variables/useInitFunctions.md | 2 +- docs/reference/variables/useInitPerformance.md | 2 +- docs/reference/variables/useInitRemoteConfig.md | 2 +- docs/reference/variables/useInitStorage.md | 2 +- 47 files changed, 77 insertions(+), 69 deletions(-) diff --git a/docs/reference/classes/ReactFireError.md b/docs/reference/classes/ReactFireError.md index 591ddb68..31c99ccb 100644 --- a/docs/reference/classes/ReactFireError.md +++ b/docs/reference/classes/ReactFireError.md @@ -6,7 +6,7 @@ # Class: ReactFireError -Defined in: [src/index.ts:12](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L12) +Defined in: [src/index.ts:13](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L13) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/index.ts:12](https://github.com/FirebaseExtended/reactfire/blob > **new ReactFireError**(`code`, `message`, `customData?`): `ReactFireError` -Defined in: [src/index.ts:15](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L15) +Defined in: [src/index.ts:16](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L16) #### Parameters @@ -60,7 +60,7 @@ Defined in: node\_modules/typescript/lib/lib.es2022.error.d.ts:24 > `readonly` **code**: `string` -Defined in: [src/index.ts:15](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L15) +Defined in: [src/index.ts:16](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L16) *** @@ -68,7 +68,7 @@ Defined in: [src/index.ts:15](https://github.com/FirebaseExtended/reactfire/blob > `optional` **customData?**: `Record`\<`string`, `unknown`\> -Defined in: [src/index.ts:15](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L15) +Defined in: [src/index.ts:16](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L16) *** @@ -88,7 +88,7 @@ Defined in: node\_modules/typescript/lib/lib.es5.d.ts:1075 > `readonly` **name**: `"ReactFireError"` = `'ReactFireError'` -Defined in: [src/index.ts:13](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L13) +Defined in: [src/index.ts:14](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L14) #### Overrides diff --git a/docs/reference/functions/checkIdField.md b/docs/reference/functions/checkIdField.md index 82d7c5a9..24bd01d2 100644 --- a/docs/reference/functions/checkIdField.md +++ b/docs/reference/functions/checkIdField.md @@ -8,7 +8,7 @@ > **checkIdField**(`options`): `any` -Defined in: [src/index.ts:47](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L47) +Defined in: [src/index.ts:48](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L48) ## Parameters diff --git a/docs/reference/functions/checkOptions.md b/docs/reference/functions/checkOptions.md index 4f4fdaf0..98cdacfc 100644 --- a/docs/reference/functions/checkOptions.md +++ b/docs/reference/functions/checkOptions.md @@ -8,7 +8,7 @@ > **checkOptions**(`options`, `field`): `any` -Defined in: [src/index.ts:34](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L34) +Defined in: [src/index.ts:35](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L35) ## Parameters diff --git a/docs/reference/functions/checkinitialData.md b/docs/reference/functions/checkinitialData.md index f2c78405..fb4b2bd0 100644 --- a/docs/reference/functions/checkinitialData.md +++ b/docs/reference/functions/checkinitialData.md @@ -8,7 +8,7 @@ > **checkinitialData**(`options`): `any` -Defined in: [src/index.ts:43](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L43) +Defined in: [src/index.ts:44](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L44) ## Parameters diff --git a/docs/reference/functions/useAnalytics.md b/docs/reference/functions/useAnalytics.md index 1a8eb2eb..41701393 100644 --- a/docs/reference/functions/useAnalytics.md +++ b/docs/reference/functions/useAnalytics.md @@ -8,7 +8,7 @@ > **useAnalytics**(): `Analytics` -Defined in: [src/sdk.tsx:84](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L84) +Defined in: [src/sdk.tsx:100](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L100) ## Returns diff --git a/docs/reference/functions/useAppCheck.md b/docs/reference/functions/useAppCheck.md index 0a8f8e45..15c51746 100644 --- a/docs/reference/functions/useAppCheck.md +++ b/docs/reference/functions/useAppCheck.md @@ -8,7 +8,7 @@ > **useAppCheck**(): `AppCheck` -Defined in: [src/sdk.tsx:82](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L82) +Defined in: [src/sdk.tsx:98](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L98) ## Returns diff --git a/docs/reference/functions/useAuth.md b/docs/reference/functions/useAuth.md index fd9a7056..a9d22918 100644 --- a/docs/reference/functions/useAuth.md +++ b/docs/reference/functions/useAuth.md @@ -8,7 +8,7 @@ > **useAuth**(): `Auth` -Defined in: [src/sdk.tsx:83](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L83) +Defined in: [src/sdk.tsx:99](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L99) ## Returns diff --git a/docs/reference/functions/useDatabase.md b/docs/reference/functions/useDatabase.md index 1efac90b..6db90a25 100644 --- a/docs/reference/functions/useDatabase.md +++ b/docs/reference/functions/useDatabase.md @@ -8,7 +8,7 @@ > **useDatabase**(): `Database` -Defined in: [src/sdk.tsx:85](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L85) +Defined in: [src/sdk.tsx:101](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L101) ## Returns diff --git a/docs/reference/functions/useFirestore.md b/docs/reference/functions/useFirestore.md index 648d679b..759bc403 100644 --- a/docs/reference/functions/useFirestore.md +++ b/docs/reference/functions/useFirestore.md @@ -8,7 +8,7 @@ > **useFirestore**(): `Firestore` -Defined in: [src/sdk.tsx:86](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L86) +Defined in: [src/sdk.tsx:102](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L102) ## Returns diff --git a/docs/reference/functions/useFunctions.md b/docs/reference/functions/useFunctions.md index bd5a2ade..f29703de 100644 --- a/docs/reference/functions/useFunctions.md +++ b/docs/reference/functions/useFunctions.md @@ -8,7 +8,7 @@ > **useFunctions**(): `Functions` -Defined in: [src/sdk.tsx:87](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L87) +Defined in: [src/sdk.tsx:103](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L103) ## Returns diff --git a/docs/reference/functions/useObservable.md b/docs/reference/functions/useObservable.md index fd5579c0..f7b3d820 100644 --- a/docs/reference/functions/useObservable.md +++ b/docs/reference/functions/useObservable.md @@ -8,7 +8,7 @@ > **useObservable**\<`T`\>(`observableId`, `source`, `config?`): [`ObservableStatus`](../interfaces/ObservableStatus.md)\<`T`\> -Defined in: [src/useObservable.ts:68](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L68) +Defined in: [src/useObservable.ts:72](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L72) ## Type Parameters diff --git a/docs/reference/functions/usePerformance.md b/docs/reference/functions/usePerformance.md index 0ff1b523..7b225614 100644 --- a/docs/reference/functions/usePerformance.md +++ b/docs/reference/functions/usePerformance.md @@ -8,7 +8,7 @@ > **usePerformance**(): `FirebasePerformance` -Defined in: [src/sdk.tsx:88](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L88) +Defined in: [src/sdk.tsx:104](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L104) ## Returns diff --git a/docs/reference/functions/useRemoteConfig.md b/docs/reference/functions/useRemoteConfig.md index 78b14879..d181bf31 100644 --- a/docs/reference/functions/useRemoteConfig.md +++ b/docs/reference/functions/useRemoteConfig.md @@ -8,7 +8,7 @@ > **useRemoteConfig**(): `RemoteConfig` -Defined in: [src/sdk.tsx:90](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L90) +Defined in: [src/sdk.tsx:106](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L106) ## Returns diff --git a/docs/reference/functions/useStorage.md b/docs/reference/functions/useStorage.md index 74d28fe2..62c4ce27 100644 --- a/docs/reference/functions/useStorage.md +++ b/docs/reference/functions/useStorage.md @@ -8,7 +8,7 @@ > **useStorage**(): `FirebaseStorage` -Defined in: [src/sdk.tsx:89](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L89) +Defined in: [src/sdk.tsx:105](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L105) ## Returns diff --git a/docs/reference/interfaces/ObservableStatus.md b/docs/reference/interfaces/ObservableStatus.md index 6b916f26..2a72f032 100644 --- a/docs/reference/interfaces/ObservableStatus.md +++ b/docs/reference/interfaces/ObservableStatus.md @@ -6,7 +6,7 @@ # Interface: ObservableStatus\ -Defined in: [src/useObservable.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L31) +Defined in: [src/useObservable.ts:35](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L35) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/useObservable.ts:31](https://github.com/FirebaseExtended/reactf > **data**: `T` -Defined in: [src/useObservable.ts:57](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L57) +Defined in: [src/useObservable.ts:61](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L61) The most recent value. @@ -32,7 +32,7 @@ If `initialData` is passed in, the first value of `data` will be the valuea prov > **error**: `Error` \| `undefined` -Defined in: [src/useObservable.ts:61](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L61) +Defined in: [src/useObservable.ts:65](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L65) Any error that may have occurred in the underlying observable @@ -42,7 +42,7 @@ Any error that may have occurred in the underlying observable > **firstValuePromise**: `Promise`\<`void`\> -Defined in: [src/useObservable.ts:65](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L65) +Defined in: [src/useObservable.ts:69](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L69) Promise that resolves after first emit from observable @@ -52,7 +52,7 @@ Promise that resolves after first emit from observable > **hasEmitted**: `boolean` -Defined in: [src/useObservable.ts:47](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L47) +Defined in: [src/useObservable.ts:51](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L51) Indicates whether the hook has emitted a value at some point @@ -64,7 +64,7 @@ If `initialData` is passed in, this will be `true`. > **isComplete**: `boolean` -Defined in: [src/useObservable.ts:51](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L51) +Defined in: [src/useObservable.ts:55](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L55) If this is `true`, the hook will be emitting no further items. @@ -74,7 +74,7 @@ If this is `true`, the hook will be emitting no further items. > **status**: `"error"` \| `"loading"` \| `"success"` -Defined in: [src/useObservable.ts:41](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L41) +Defined in: [src/useObservable.ts:45](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L45) The loading status. diff --git a/docs/reference/interfaces/ReactFireOptions.md b/docs/reference/interfaces/ReactFireOptions.md index 7ca63c5e..80f7f756 100644 --- a/docs/reference/interfaces/ReactFireOptions.md +++ b/docs/reference/interfaces/ReactFireOptions.md @@ -6,7 +6,7 @@ # Interface: ReactFireOptions\ -Defined in: [src/index.ts:24](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L24) +Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L25) ## Extended by @@ -24,7 +24,7 @@ Defined in: [src/index.ts:24](https://github.com/FirebaseExtended/reactfire/blob > `optional` **idField?**: `string` -Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L25) +Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) *** @@ -32,7 +32,7 @@ Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob > `optional` **initialData?**: `any` -Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) +Defined in: [src/index.ts:27](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L27) *** @@ -40,7 +40,7 @@ Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob > `optional` **startWithValue?**: `any` -Defined in: [src/index.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L30) +Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L31) #### Deprecated @@ -52,4 +52,4 @@ use initialData instead > `optional` **suspense?**: `boolean` -Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L31) +Defined in: [src/index.ts:32](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L32) diff --git a/docs/reference/interfaces/SignInCheckOptionsBasic.md b/docs/reference/interfaces/SignInCheckOptionsBasic.md index 66967bf8..7f6ba493 100644 --- a/docs/reference/interfaces/SignInCheckOptionsBasic.md +++ b/docs/reference/interfaces/SignInCheckOptionsBasic.md @@ -31,7 +31,7 @@ Defined in: [src/auth.tsx:86](https://github.com/FirebaseExtended/reactfire/blob > `optional` **idField?**: `string` -Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L25) +Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) #### Inherited from @@ -43,7 +43,7 @@ Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob > `optional` **initialData?**: `any` -Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) +Defined in: [src/index.ts:27](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L27) #### Inherited from @@ -55,7 +55,7 @@ Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob > `optional` **startWithValue?**: `any` -Defined in: [src/index.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L30) +Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L31) #### Deprecated @@ -71,7 +71,7 @@ use initialData instead > `optional` **suspense?**: `boolean` -Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L31) +Defined in: [src/index.ts:32](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L32) #### Inherited from diff --git a/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md b/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md index 09a1eae0..36823a00 100644 --- a/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md +++ b/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md @@ -30,7 +30,7 @@ Defined in: [src/auth.tsx:86](https://github.com/FirebaseExtended/reactfire/blob > `optional` **idField?**: `string` -Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L25) +Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) #### Inherited from @@ -42,7 +42,7 @@ Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob > `optional` **initialData?**: `any` -Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) +Defined in: [src/index.ts:27](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L27) #### Inherited from @@ -62,7 +62,7 @@ Defined in: [src/auth.tsx:90](https://github.com/FirebaseExtended/reactfire/blob > `optional` **startWithValue?**: `any` -Defined in: [src/index.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L30) +Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L31) #### Deprecated @@ -78,7 +78,7 @@ use initialData instead > `optional` **suspense?**: `boolean` -Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L31) +Defined in: [src/index.ts:32](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L32) #### Inherited from diff --git a/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md b/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md index 5ba7f609..5047d54d 100644 --- a/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md +++ b/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md @@ -30,7 +30,7 @@ Defined in: [src/auth.tsx:86](https://github.com/FirebaseExtended/reactfire/blob > `optional` **idField?**: `string` -Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L25) +Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) #### Inherited from @@ -42,7 +42,7 @@ Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob > `optional` **initialData?**: `any` -Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) +Defined in: [src/index.ts:27](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L27) #### Inherited from @@ -54,7 +54,7 @@ Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob > `optional` **startWithValue?**: `any` -Defined in: [src/index.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L30) +Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L31) #### Deprecated @@ -70,7 +70,7 @@ use initialData instead > `optional` **suspense?**: `boolean` -Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L31) +Defined in: [src/index.ts:32](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L32) #### Inherited from diff --git a/docs/reference/type-aliases/ReactFireGlobals.md b/docs/reference/type-aliases/ReactFireGlobals.md index 995ef49a..5fad317b 100644 --- a/docs/reference/type-aliases/ReactFireGlobals.md +++ b/docs/reference/type-aliases/ReactFireGlobals.md @@ -28,6 +28,14 @@ Defined in: [src/index.ts:8](https://github.com/FirebaseExtended/reactfire/blob/ *** +### \_reactFireInitializedSdks + +> **\_reactFireInitializedSdks**: `WeakMap`\<`object`, `Map`\<`string`, `Promise`\<`unknown`\>\>\> + +Defined in: [src/index.ts:10](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L10) + +*** + ### \_reactFirePreloadedObservables > **\_reactFirePreloadedObservables**: `Map`\<`string`, `SuspenseSubject`\<`any`\>\> diff --git a/docs/reference/variables/AnalyticsProvider.md b/docs/reference/variables/AnalyticsProvider.md index 67edf246..6fdf2e9c 100644 --- a/docs/reference/variables/AnalyticsProvider.md +++ b/docs/reference/variables/AnalyticsProvider.md @@ -8,7 +8,7 @@ > `const` **AnalyticsProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:74](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L74) +Defined in: [src/sdk.tsx:90](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L90) ## Parameters diff --git a/docs/reference/variables/AnalyticsSdkContext.md b/docs/reference/variables/AnalyticsSdkContext.md index 8d51b3f9..a60edb38 100644 --- a/docs/reference/variables/AnalyticsSdkContext.md +++ b/docs/reference/variables/AnalyticsSdkContext.md @@ -8,4 +8,4 @@ > `const` **AnalyticsSdkContext**: `Context`\<`Analytics` \| `undefined`\> -Defined in: [src/sdk.tsx:20](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L20) +Defined in: [src/sdk.tsx:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L26) diff --git a/docs/reference/variables/AppCheckProvider.md b/docs/reference/variables/AppCheckProvider.md index ff42596a..f603adce 100644 --- a/docs/reference/variables/AppCheckProvider.md +++ b/docs/reference/variables/AppCheckProvider.md @@ -8,7 +8,7 @@ > `const` **AppCheckProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:72](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L72) +Defined in: [src/sdk.tsx:88](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L88) ## Parameters diff --git a/docs/reference/variables/AppCheckSdkContext.md b/docs/reference/variables/AppCheckSdkContext.md index 311519af..1c2ec00a 100644 --- a/docs/reference/variables/AppCheckSdkContext.md +++ b/docs/reference/variables/AppCheckSdkContext.md @@ -8,4 +8,4 @@ > `const` **AppCheckSdkContext**: `Context`\<`AppCheck` \| `undefined`\> -Defined in: [src/sdk.tsx:18](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L18) +Defined in: [src/sdk.tsx:24](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L24) diff --git a/docs/reference/variables/AuthProvider.md b/docs/reference/variables/AuthProvider.md index 11ea5392..feed41ad 100644 --- a/docs/reference/variables/AuthProvider.md +++ b/docs/reference/variables/AuthProvider.md @@ -8,7 +8,7 @@ > `const` **AuthProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:73](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L73) +Defined in: [src/sdk.tsx:89](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L89) ## Parameters diff --git a/docs/reference/variables/AuthSdkContext.md b/docs/reference/variables/AuthSdkContext.md index 22b522cb..d80f8fc8 100644 --- a/docs/reference/variables/AuthSdkContext.md +++ b/docs/reference/variables/AuthSdkContext.md @@ -8,4 +8,4 @@ > `const` **AuthSdkContext**: `Context`\<`Auth` \| `undefined`\> -Defined in: [src/sdk.tsx:19](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L19) +Defined in: [src/sdk.tsx:25](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L25) diff --git a/docs/reference/variables/DatabaseProvider.md b/docs/reference/variables/DatabaseProvider.md index e8069948..582d11f8 100644 --- a/docs/reference/variables/DatabaseProvider.md +++ b/docs/reference/variables/DatabaseProvider.md @@ -8,7 +8,7 @@ > `const` **DatabaseProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:75](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L75) +Defined in: [src/sdk.tsx:91](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L91) ## Parameters diff --git a/docs/reference/variables/DatabaseSdkContext.md b/docs/reference/variables/DatabaseSdkContext.md index 09acae02..69ddd97b 100644 --- a/docs/reference/variables/DatabaseSdkContext.md +++ b/docs/reference/variables/DatabaseSdkContext.md @@ -8,4 +8,4 @@ > `const` **DatabaseSdkContext**: `Context`\<`Database` \| `undefined`\> -Defined in: [src/sdk.tsx:21](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L21) +Defined in: [src/sdk.tsx:27](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L27) diff --git a/docs/reference/variables/FirestoreProvider.md b/docs/reference/variables/FirestoreProvider.md index d689f31f..30c3374a 100644 --- a/docs/reference/variables/FirestoreProvider.md +++ b/docs/reference/variables/FirestoreProvider.md @@ -8,7 +8,7 @@ > `const` **FirestoreProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:76](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L76) +Defined in: [src/sdk.tsx:92](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L92) ## Parameters diff --git a/docs/reference/variables/FirestoreSdkContext.md b/docs/reference/variables/FirestoreSdkContext.md index 0fcb9ea2..53a9c5a9 100644 --- a/docs/reference/variables/FirestoreSdkContext.md +++ b/docs/reference/variables/FirestoreSdkContext.md @@ -8,4 +8,4 @@ > `const` **FirestoreSdkContext**: `Context`\<`Firestore` \| `undefined`\> -Defined in: [src/sdk.tsx:22](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L22) +Defined in: [src/sdk.tsx:28](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L28) diff --git a/docs/reference/variables/FunctionsProvider.md b/docs/reference/variables/FunctionsProvider.md index 918b2609..76eeac93 100644 --- a/docs/reference/variables/FunctionsProvider.md +++ b/docs/reference/variables/FunctionsProvider.md @@ -8,7 +8,7 @@ > `const` **FunctionsProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:77](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L77) +Defined in: [src/sdk.tsx:93](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L93) ## Parameters diff --git a/docs/reference/variables/FunctionsSdkContext.md b/docs/reference/variables/FunctionsSdkContext.md index fe59787f..188e7f75 100644 --- a/docs/reference/variables/FunctionsSdkContext.md +++ b/docs/reference/variables/FunctionsSdkContext.md @@ -8,4 +8,4 @@ > `const` **FunctionsSdkContext**: `Context`\<`Functions` \| `undefined`\> -Defined in: [src/sdk.tsx:23](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L23) +Defined in: [src/sdk.tsx:29](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L29) diff --git a/docs/reference/variables/PerformanceProvider.md b/docs/reference/variables/PerformanceProvider.md index ee2fcc28..205ba786 100644 --- a/docs/reference/variables/PerformanceProvider.md +++ b/docs/reference/variables/PerformanceProvider.md @@ -8,7 +8,7 @@ > `const` **PerformanceProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:78](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L78) +Defined in: [src/sdk.tsx:94](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L94) ## Parameters diff --git a/docs/reference/variables/PerformanceSdkContext.md b/docs/reference/variables/PerformanceSdkContext.md index cff32f29..1ffe7af6 100644 --- a/docs/reference/variables/PerformanceSdkContext.md +++ b/docs/reference/variables/PerformanceSdkContext.md @@ -8,4 +8,4 @@ > `const` **PerformanceSdkContext**: `Context`\<`FirebasePerformance` \| `undefined`\> -Defined in: [src/sdk.tsx:25](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L25) +Defined in: [src/sdk.tsx:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L31) diff --git a/docs/reference/variables/RemoteConfigProvider.md b/docs/reference/variables/RemoteConfigProvider.md index 8d3d3625..d7410c91 100644 --- a/docs/reference/variables/RemoteConfigProvider.md +++ b/docs/reference/variables/RemoteConfigProvider.md @@ -8,7 +8,7 @@ > `const` **RemoteConfigProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:80](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L80) +Defined in: [src/sdk.tsx:96](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L96) ## Parameters diff --git a/docs/reference/variables/RemoteConfigSdkContext.md b/docs/reference/variables/RemoteConfigSdkContext.md index 00fbb0f7..ebfe166a 100644 --- a/docs/reference/variables/RemoteConfigSdkContext.md +++ b/docs/reference/variables/RemoteConfigSdkContext.md @@ -8,4 +8,4 @@ > `const` **RemoteConfigSdkContext**: `Context`\<`RemoteConfig` \| `undefined`\> -Defined in: [src/sdk.tsx:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L26) +Defined in: [src/sdk.tsx:32](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L32) diff --git a/docs/reference/variables/StorageProvider.md b/docs/reference/variables/StorageProvider.md index dbcb81c1..6131d4e4 100644 --- a/docs/reference/variables/StorageProvider.md +++ b/docs/reference/variables/StorageProvider.md @@ -8,7 +8,7 @@ > `const` **StorageProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:79](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L79) +Defined in: [src/sdk.tsx:95](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L95) ## Parameters diff --git a/docs/reference/variables/StorageSdkContext.md b/docs/reference/variables/StorageSdkContext.md index e921e113..daa4f7f7 100644 --- a/docs/reference/variables/StorageSdkContext.md +++ b/docs/reference/variables/StorageSdkContext.md @@ -8,4 +8,4 @@ > `const` **StorageSdkContext**: `Context`\<`FirebaseStorage` \| `undefined`\> -Defined in: [src/sdk.tsx:24](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L24) +Defined in: [src/sdk.tsx:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L30) diff --git a/docs/reference/variables/useInitAnalytics.md b/docs/reference/variables/useInitAnalytics.md index 80cddfb2..a68a6206 100644 --- a/docs/reference/variables/useInitAnalytics.md +++ b/docs/reference/variables/useInitAnalytics.md @@ -8,4 +8,4 @@ > `const` **useInitAnalytics**: `InitSdkHook`\<`Analytics`\> -Defined in: [src/sdk.tsx:99](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L99) +Defined in: [src/sdk.tsx:115](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L115) diff --git a/docs/reference/variables/useInitAppCheck.md b/docs/reference/variables/useInitAppCheck.md index 810fc3f0..1f021db9 100644 --- a/docs/reference/variables/useInitAppCheck.md +++ b/docs/reference/variables/useInitAppCheck.md @@ -8,4 +8,4 @@ > `const` **useInitAppCheck**: `InitSdkHook`\<`AppCheck`\> -Defined in: [src/sdk.tsx:97](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L97) +Defined in: [src/sdk.tsx:113](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L113) diff --git a/docs/reference/variables/useInitAuth.md b/docs/reference/variables/useInitAuth.md index bd29275b..bdbc9538 100644 --- a/docs/reference/variables/useInitAuth.md +++ b/docs/reference/variables/useInitAuth.md @@ -8,4 +8,4 @@ > `const` **useInitAuth**: `InitSdkHook`\<`Auth`\> -Defined in: [src/sdk.tsx:98](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L98) +Defined in: [src/sdk.tsx:114](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L114) diff --git a/docs/reference/variables/useInitDatabase.md b/docs/reference/variables/useInitDatabase.md index 4bb0de18..f165af5c 100644 --- a/docs/reference/variables/useInitDatabase.md +++ b/docs/reference/variables/useInitDatabase.md @@ -8,4 +8,4 @@ > `const` **useInitDatabase**: `InitSdkHook`\<`Database`\> -Defined in: [src/sdk.tsx:100](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L100) +Defined in: [src/sdk.tsx:116](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L116) diff --git a/docs/reference/variables/useInitFirestore.md b/docs/reference/variables/useInitFirestore.md index 5ef24c7e..ce37c53b 100644 --- a/docs/reference/variables/useInitFirestore.md +++ b/docs/reference/variables/useInitFirestore.md @@ -8,4 +8,4 @@ > `const` **useInitFirestore**: `InitSdkHook`\<`Firestore`\> -Defined in: [src/sdk.tsx:101](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L101) +Defined in: [src/sdk.tsx:117](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L117) diff --git a/docs/reference/variables/useInitFunctions.md b/docs/reference/variables/useInitFunctions.md index 63c7eed4..0b7815a4 100644 --- a/docs/reference/variables/useInitFunctions.md +++ b/docs/reference/variables/useInitFunctions.md @@ -8,4 +8,4 @@ > `const` **useInitFunctions**: `InitSdkHook`\<`Functions`\> -Defined in: [src/sdk.tsx:102](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L102) +Defined in: [src/sdk.tsx:118](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L118) diff --git a/docs/reference/variables/useInitPerformance.md b/docs/reference/variables/useInitPerformance.md index 150cdfdd..649627e9 100644 --- a/docs/reference/variables/useInitPerformance.md +++ b/docs/reference/variables/useInitPerformance.md @@ -8,4 +8,4 @@ > `const` **useInitPerformance**: `InitSdkHook`\<`FirebasePerformance`\> -Defined in: [src/sdk.tsx:103](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L103) +Defined in: [src/sdk.tsx:119](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L119) diff --git a/docs/reference/variables/useInitRemoteConfig.md b/docs/reference/variables/useInitRemoteConfig.md index f9871886..6bc9f441 100644 --- a/docs/reference/variables/useInitRemoteConfig.md +++ b/docs/reference/variables/useInitRemoteConfig.md @@ -8,4 +8,4 @@ > `const` **useInitRemoteConfig**: `InitSdkHook`\<`RemoteConfig`\> -Defined in: [src/sdk.tsx:105](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L105) +Defined in: [src/sdk.tsx:121](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L121) diff --git a/docs/reference/variables/useInitStorage.md b/docs/reference/variables/useInitStorage.md index a38823da..233ecf78 100644 --- a/docs/reference/variables/useInitStorage.md +++ b/docs/reference/variables/useInitStorage.md @@ -8,4 +8,4 @@ > `const` **useInitStorage**: `InitSdkHook`\<`FirebaseStorage`\> -Defined in: [src/sdk.tsx:107](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L107) +Defined in: [src/sdk.tsx:123](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L123) From f5598c14d6f7a483909fe1dd8a68e40e670f37ea Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Thu, 5 Nov 2026 16:09:30 -0800 Subject: [PATCH 11/20] fix(core): wait for the first emission before releasing an entry A suspended render holds no subscription, so the 1s refcount release could tear the source down while a component waited on firstEmission. After the timer, wait for the first emission if it has not settled, then allow one more grace period before evicting. Two related changes in the same area: - a late subscribe on an entry the release already evicted now puts the entry back under its id when the slot is empty (errored and completed entries excepted), so the next render reuses the listener instead of opening a second one - the warm-up release is driven by the firstEmission promise with one delayed timer instead of a timer that re-arms until the entry settles, so a never-emitting source holds no timer, fake-timer tests complete, and the release lands timeoutWindow after the first value --- src/SuspenseSubject.ts | 36 +++++++++++++++++++++--------------- src/useObservable.ts | 12 +++++++++++- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index d1b8329c..6cf5d6b5 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -1,5 +1,5 @@ -import { EMPTY, from, Observable, ReplaySubject, Subject, Subscriber, Subscription, timer } from 'rxjs'; -import { catchError, share, tap } from 'rxjs/operators'; +import { EMPTY, from, Observable, of, ReplaySubject, Subject, Subscriber, Subscription, timer } from 'rxjs'; +import { catchError, delay, mergeMap, 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. @@ -13,7 +13,7 @@ const FIRST_LOAD_ERROR_EVICT_DELAY_MS = 1000; export class SuspenseSubject extends Subject { private _value: T | undefined; private _hasValue = false; - private _timeoutHandler: ReturnType; + private _warmupRelease: Subscription; private _firstEmission: Promise; private _error: any = undefined; private _innerObservable: Observable; @@ -31,7 +31,8 @@ export class SuspenseSubject extends Subject { innerObservable: Observable, private _timeoutWindow: number, private _suspenseEnabled: boolean, - private _onEvict: () => void = () => {} + private _onEvict: () => void = () => {}, + private _onReregister: () => void = () => {} ) { super(); this._firstEmission = new Promise((resolve) => (this._resolveFirstEmission = resolve)); @@ -92,26 +93,29 @@ export class SuspenseSubject extends Subject { connector: () => new ReplaySubject(1), resetOnError: true, resetOnComplete: false, - resetOnRefCountZero: () => timer(LISTENER_RELEASE_DELAY_MS).pipe(tap(this._onEvict)), + resetOnRefCountZero: () => + timer(LISTENER_RELEASE_DELAY_MS).pipe( + // A suspended render holds no subscription, so never tear down before the first emission settles, + // then give that render one more grace period to subscribe. + mergeMap(() => (this._hasValue || this._error ? of(undefined) : from(this._firstEmission).pipe(delay(LISTENER_RELEASE_DELAY_MS)))), + tap(this._onEvict) + ), }) ); // warm up the observable this._warmupSubscription = this._innerObservable.subscribe(); // 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). + // Waiting for the first emission lets a suspended component 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._warmupRelease = from(this._firstEmission) + .pipe(delay(this._timeoutWindow)) + .subscribe(() => { this._warmupSubscription.unsubscribe(); if (this._isComplete) { this._onEvict(); } - } else { - this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); - } - }; - this._timeoutHandler = setTimeout(releaseWarmupWhenSettled, this._timeoutWindow); + }); } // An entry created with Suspense off (by a preload helper, which cannot read the Suspense context) @@ -162,8 +166,10 @@ export class SuspenseSubject extends Subject { } _subscribe(subscriber: Subscriber): Subscription { - if (this._timeoutHandler) { - clearTimeout(this._timeoutHandler); + // A mounted subscriber owns the source from here on, so the pending warm-up release must not fire (it would evict a mounted one-shot entry). + this._warmupRelease.unsubscribe(); + if (!this._error && !this._isComplete) { + this._onReregister(); } this._subscriberCount++; const innerSubscription = this._innerObservable.subscribe({ diff --git a/src/useObservable.ts b/src/useObservable.ts index de047038..42575419 100644 --- a/src/useObservable.ts +++ b/src/useObservable.ts @@ -26,7 +26,17 @@ export function preloadObservable(source: Observable, id: string, suspense } return existing; } else { - const observable: SuspenseSubject = new SuspenseSubject(source, DEFAULT_TIMEOUT, suspenseEnabled, () => preloadedObservables.get(id) === observable && preloadedObservables.delete(id)); + const observable: SuspenseSubject = new SuspenseSubject( + source, + DEFAULT_TIMEOUT, + suspenseEnabled, + () => preloadedObservables.get(id) === observable && preloadedObservables.delete(id), + () => { + if (!preloadedObservables.has(id)) { + preloadedObservables.set(id, observable); + } + } + ); preloadedObservables.set(id, observable); return observable; } From 19c8c300a577545e5bf6fb80d58363a8d9f663a1 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Thu, 5 Nov 2026 16:09:30 -0800 Subject: [PATCH 12/20] test(core): cover the lifecycle paths the suite could not see Each new test fails when its mechanism is removed: the Suspense retention of a first-load error, the eviction when the last Suspense subscriber of an errored entry unmounts, resetOnComplete: false, the warm-up release skipping a mounted one-shot entry, and a consumer suspended on a still-loading entry after an earlier subscriber left. The #525 assertion now checks past the release grace, the error boundary is one class at file scope, and the subscription counter uses defer instead of lift. --- test/useObservable.test.tsx | 165 +++++++++++++++++++++++++++++------- 1 file changed, 134 insertions(+), 31 deletions(-) diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index 9bcac5a8..022d2180 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -5,11 +5,22 @@ import { Writable } from 'node:stream'; import { renderToString, renderToPipeableStream } from 'react-dom/server'; import * as firebaseAppModule from 'firebase/app'; import { initializeApp, deleteApp, getApps, FirebaseApp, setLogLevel } from 'firebase/app'; -import { of, Subject, BehaviorSubject, throwError } from 'rxjs'; +import { defer, of, Subject, BehaviorSubject, throwError, timer } from 'rxjs'; +import { first, mergeMap } from 'rxjs/operators'; import { useObservable, ReactFireOptions, version } from '../src/index'; const _components = (firebaseAppModule as unknown as { _components: Map })._components; +class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ?
{this.state.error.message}
: this.props.children; + } +} + interface AppWithContainer extends FirebaseApp { container: { getProvider(name: 'platform-logger'): { @@ -517,19 +528,6 @@ describe('useObservable', () => { const observableId = `error-evict-${Math.random()}`; let shouldFail = true; - class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { - state: { error: Error | null } = { error: null }; - static getDerivedStateFromError(error: Error) { - return { error }; - } - render() { - if (this.state.error) { - return
{this.state.error.message}
; - } - return this.props.children; - } - } - const Obs = () => { const source$ = shouldFail ? throwError(() => new Error('permission-denied')) : of('recovered'); const { data, status } = useObservable(observableId, source$); @@ -565,16 +563,6 @@ describe('useObservable', () => { const secondStream$ = new BehaviorSubject('reauthenticated-user-doc'); let currentStream$ = firstStream$; - class Boundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { - state: { error: Error | null } = { error: null }; - static getDerivedStateFromError(error: Error) { - return { error }; - } - render() { - return this.state.error ?
{this.state.error.message}
: this.props.children; - } - } - const Obs = () => { const { data, status } = useObservable(observableId, currentStream$); return
{status === 'success' ? data : 'loading'}
; @@ -648,11 +636,9 @@ describe('useObservable', () => { const observableId = `grace-window-${Math.random()}`; let subscribeCount = 0; const base$ = new BehaviorSubject('cached-val'); - const counted$ = base$.lift({ - call(subscriber, source) { - subscribeCount++; - return source.subscribe(subscriber); - } + const counted$ = defer(() => { + subscribeCount++; + return base$; }); const Obs = () => { @@ -688,8 +674,8 @@ describe('useObservable', () => { evicted = true; }); - // After the first 50ms window, the first emission is still pending, so warm-up stays subscribed (#525). - await new Promise((r) => setTimeout(r, 80)); + // After the 50ms window plus the 1s release grace, the first emission is still pending, so warm-up must still hold the source (#525). + await new Promise((resolve) => setTimeout(resolve, 1100)); expect(source$.observed).toBe(true); // Settle and complete the stream without any subscriber mounting (e.g. SSR or unused preload). @@ -726,5 +712,122 @@ describe('useObservable', () => { expect(preloadObservable(new BehaviorSubject('third'), observableId)).toBe(secondSubject); activeSub.unsubscribe(); }); + + it('keeps the source attached while a consumer is suspended on a still-loading entry after an earlier subscriber unmounted', async () => { + const observableId = `suspended-consumer-${Math.random()}`; + const source$ = new Subject(); + + const NonSuspense = () => { + const { status } = useObservable(observableId, source$); + return
{status}
; + }; + const Suspended = () => { + const { data } = useObservable(observableId, source$, { suspense: true }); + return
{data}
; + }; + + const firstMount = render(); + await waitFor(() => expect(firstMount.getByTestId('a')).toHaveTextContent('loading')); + firstMount.unmount(); + + // Inside the 1s release grace window, a Suspense consumer of the same id mounts and suspends on firstEmission. + await new Promise((resolve) => setTimeout(resolve, 200)); + const secondMount = render( + fallback}> + + + ); + expect(secondMount.getByTestId('fb')).toBeInTheDocument(); + + // The grace window expires before the first value arrives. The source must still be attached. + await new Promise((resolve) => setTimeout(resolve, 1200)); + act(() => source$.next('arrived')); + + await waitFor(() => expect(secondMount.getByTestId('b')).toHaveTextContent('arrived'), { timeout: 2000 }); + secondMount.unmount(); + }, 10000); + + it('evicts a first-load error after 1s in non-suspense mode only', async () => { + const { SuspenseSubject } = await import('../src/SuspenseSubject'); + const failAfter50ms = () => timer(50).pipe(mergeMap(() => throwError(() => new Error('permission-denied')))); + + let evictedWithoutSuspense = false; + new SuspenseSubject(failAfter50ms(), 30_000, false, () => { + evictedWithoutSuspense = true; + }); + let evictedWithSuspense = false; + new SuspenseSubject(failAfter50ms(), 30_000, true, () => { + evictedWithSuspense = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 1300)); + expect(evictedWithoutSuspense).toBe(true); + expect(evictedWithSuspense).toBe(false); + }); + + it('evicts an errored entry when its last Suspense subscriber unmounts, so a remount reads fresh', async () => { + const observableId = `suspense-error-remount-${Math.random()}`; + let stream$ = new BehaviorSubject('doc-v1'); + const Obs = () => { + const { data } = useObservable(observableId, stream$, { suspense: true }); + return
{data}
; + }; + const firstMount = render( + + fallback}> + + + + ); + await waitFor(() => expect(firstMount.getByTestId('val')).toHaveTextContent('doc-v1')); + act(() => stream$.error(new Error('permission-denied'))); + await waitFor(() => expect(firstMount.getByTestId('err')).toHaveTextContent('permission-denied')); + firstMount.unmount(); + + stream$ = new BehaviorSubject('doc-v2'); + const secondMount = render( + + fallback}> + + + + ); + await waitFor(() => expect(secondMount.getByTestId('val')).toHaveTextContent('doc-v2')); + secondMount.unmount(); + }); + + it('serves a completed one-shot entry to a remount from the cache without re-subscribing the source', async () => { + const observableId = `once-cache-${Math.random()}`; + let sourceSubscriptions = 0; + const once$ = defer(() => { + sourceSubscriptions++; + return of('doc'); + }).pipe(first()); + const Obs = () => { + const { data } = useObservable(observableId, once$); + return
{data}
; + }; + + const firstMount = render(); + await waitFor(() => expect(firstMount.getByTestId('val')).toHaveTextContent('doc')); + firstMount.unmount(); + const secondMount = render(); + await waitFor(() => expect(secondMount.getByTestId('val')).toHaveTextContent('doc')); + secondMount.unmount(); + + expect(sourceSubscriptions).toBe(1); + }); + + it('does not evict a mounted one-shot entry when the warm-up release fires', async () => { + const { SuspenseSubject } = await import('../src/SuspenseSubject'); + let evicted = false; + const subject = new SuspenseSubject(of('doc'), 50, false, () => { + evicted = true; + }); + const subscription = subject.subscribe(); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(evicted).toBe(false); + subscription.unsubscribe(); + }); }); }); From 29084530000b9e220e37ad6b6d5922f1acf01865 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 16:24:20 -0800 Subject: [PATCH 13/20] docs: regenerate reference docs --- docs/reference/functions/useObservable.md | 2 +- docs/reference/interfaces/ObservableStatus.md | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/reference/functions/useObservable.md b/docs/reference/functions/useObservable.md index f7b3d820..8cd79f94 100644 --- a/docs/reference/functions/useObservable.md +++ b/docs/reference/functions/useObservable.md @@ -8,7 +8,7 @@ > **useObservable**\<`T`\>(`observableId`, `source`, `config?`): [`ObservableStatus`](../interfaces/ObservableStatus.md)\<`T`\> -Defined in: [src/useObservable.ts:72](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L72) +Defined in: [src/useObservable.ts:82](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L82) ## Type Parameters diff --git a/docs/reference/interfaces/ObservableStatus.md b/docs/reference/interfaces/ObservableStatus.md index 2a72f032..362078a4 100644 --- a/docs/reference/interfaces/ObservableStatus.md +++ b/docs/reference/interfaces/ObservableStatus.md @@ -6,7 +6,7 @@ # Interface: ObservableStatus\ -Defined in: [src/useObservable.ts:35](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L35) +Defined in: [src/useObservable.ts:45](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L45) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/useObservable.ts:35](https://github.com/FirebaseExtended/reactf > **data**: `T` -Defined in: [src/useObservable.ts:61](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L61) +Defined in: [src/useObservable.ts:71](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L71) The most recent value. @@ -32,7 +32,7 @@ If `initialData` is passed in, the first value of `data` will be the valuea prov > **error**: `Error` \| `undefined` -Defined in: [src/useObservable.ts:65](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L65) +Defined in: [src/useObservable.ts:75](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L75) Any error that may have occurred in the underlying observable @@ -42,7 +42,7 @@ Any error that may have occurred in the underlying observable > **firstValuePromise**: `Promise`\<`void`\> -Defined in: [src/useObservable.ts:69](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L69) +Defined in: [src/useObservable.ts:79](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L79) Promise that resolves after first emit from observable @@ -52,7 +52,7 @@ Promise that resolves after first emit from observable > **hasEmitted**: `boolean` -Defined in: [src/useObservable.ts:51](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L51) +Defined in: [src/useObservable.ts:61](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L61) Indicates whether the hook has emitted a value at some point @@ -64,7 +64,7 @@ If `initialData` is passed in, this will be `true`. > **isComplete**: `boolean` -Defined in: [src/useObservable.ts:55](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L55) +Defined in: [src/useObservable.ts:65](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L65) If this is `true`, the hook will be emitting no further items. @@ -74,7 +74,7 @@ If this is `true`, the hook will be emitting no further items. > **status**: `"error"` \| `"loading"` \| `"success"` -Defined in: [src/useObservable.ts:45](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L45) +Defined in: [src/useObservable.ts:55](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L55) The loading status. From 6c33768de0c20865dce6a783f4aa3fc1c8ad4bdd Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 16:39:31 -0800 Subject: [PATCH 14/20] refactor(core): drop an unneeded cast in SuspenseSubject --- src/SuspenseSubject.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index 6cf5d6b5..d5d9a01e 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -180,7 +180,7 @@ export class SuspenseSubject extends Subject { // `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); + subscriber.next(this._value); } else { subscriber.complete(); } From b16d42a4df784e96eeaf9f23d3ef686e9b52e7ec Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 16:59:33 -0800 Subject: [PATCH 15/20] refactor(core): trim SuspenseSubject comments --- src/SuspenseSubject.ts | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index d5d9a01e..d3784cd3 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -6,8 +6,7 @@ import { ObservableStatus } from './useObservable.js'; // 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. +// How long a first-load error stays cached in non-suspense mode, so the render that hit it can throw it to a boundary. const FIRST_LOAD_ERROR_EVICT_DELAY_MS = 1000; export class SuspenseSubject extends Subject { @@ -59,12 +58,8 @@ export class SuspenseSubject extends Subject { 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. - // In Suspense mode the error is kept, as in earlier releases: React may retry the suspended - // render after any delay, and an evicted entry would suspend again instead of reaching the boundary. + // #485: nobody is left to read the error, so evict now. + // #742: non-suspense evicts after a delay so the next mount retries; Suspense keeps it, since React may retry a suspended render after any delay. if (this._subscriberCount === 0 && this._warmupSubscription?.closed) { this._onEvict(); } else if (!this._suspenseEnabled) { @@ -82,13 +77,7 @@ export class SuspenseSubject extends Subject { } }), 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. + // `resetOnComplete: false` keeps a completed one-shot (`*Once`) value cached; resetting would make a mounted component re-fetch. share({ connector: () => new ReplaySubject(1), resetOnError: true, @@ -105,9 +94,8 @@ export class SuspenseSubject extends Subject { // warm up the observable this._warmupSubscription = this._innerObservable.subscribe(); - // Release the warm-up subscription if no component subscribes within `_timeoutWindow` after the first emission settles. - // Waiting for the first emission lets a suspended component 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). + // Release the warm-up `_timeoutWindow` after the first emission, giving a suspended component time to subscribe (#525). + // A stream that completed during warm-up skips `resetOnRefCountZero`, so evict it here (#790). this._warmupRelease = from(this._firstEmission) .pipe(delay(this._timeoutWindow)) .subscribe(() => { @@ -145,9 +133,7 @@ export class SuspenseSubject extends Subject { } private _updateImmutableStatus() { - // Cast required because `data` is internally `T | undefined` while a value is - // still in flight, whereas ObservableStatus exposes `data: T` to consumers. - // The relationships between the status fields are verified in tests instead. + // `data` is `T | undefined` until the first value; ObservableStatus exposes `T`. this._immutableStatus = { status: this._error ? 'error' : (this._hasValue ? 'success' : 'loading'), hasEmitted: this._hasValue, @@ -166,7 +152,7 @@ export class SuspenseSubject extends Subject { } _subscribe(subscriber: Subscriber): Subscription { - // A mounted subscriber owns the source from here on, so the pending warm-up release must not fire (it would evict a mounted one-shot entry). + // A mounted subscriber owns the source now; the warm-up release would evict a mounted one-shot entry. this._warmupRelease.unsubscribe(); if (!this._error && !this._isComplete) { this._onReregister(); From 7a69906e9a574336bfb4beba187763a49f2e7f03 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 17:00:07 -0800 Subject: [PATCH 16/20] refactor(core): remove dead code and unclear names in SuspenseSubject Drop resetOnError (catchError runs first, and true is the default), the unread _innerSubscriber, the instanceof check that from() already does, and the @ts-expect-error TODOs. Make the eviction callbacks required. --- src/SuspenseSubject.ts | 28 +++++++++++----------------- test/useObservable.test.tsx | 8 ++++---- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index d3784cd3..e3389255 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -21,17 +21,14 @@ export class SuspenseSubject extends Subject { 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; + private _resolveFirstEmission!: () => void; constructor( innerObservable: Observable, private _timeoutWindow: number, private _suspenseEnabled: boolean, - private _onEvict: () => void = () => {}, - private _onReregister: () => void = () => {} + private _onEvict: () => void, + private _onReregister: () => void ) { super(); this._firstEmission = new Promise((resolve) => (this._resolveFirstEmission = resolve)); @@ -45,17 +42,16 @@ export class SuspenseSubject extends Subject { firstValuePromise: this._firstEmission } as ObservableStatus; - // 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( + // `from` re-wraps an Observable from another rxjs copy (rxjs 6 apps) so unsubscribe reaches the source. + this._innerObservable = from(innerObservable).pipe( tap({ - next: (v) => { - this._next(v); + next: (value) => { + this._next(value); }, - error: (e) => { + error: (error) => { // save the error, so that we can raise on subscription or .value // resolve the promise, so suspense tries again - this._error = e; + this._error = error; this._resolveFirstEmission(); this._updateImmutableStatus(); // #485: nobody is left to read the error, so evict now. @@ -80,7 +76,6 @@ export class SuspenseSubject extends Subject { // `resetOnComplete: false` keeps a completed one-shot (`*Once`) value cached; resetting would make a mounted component re-fetch. share({ connector: () => new ReplaySubject(1), - resetOnError: true, resetOnComplete: false, resetOnRefCountZero: () => timer(LISTENER_RELEASE_DELAY_MS).pipe( @@ -159,8 +154,8 @@ export class SuspenseSubject extends Subject { } this._subscriberCount++; const innerSubscription = this._innerObservable.subscribe({ - next: (v) => subscriber.next(v), - error: (e) => subscriber.error(e), + next: (value) => subscriber.next(value), + error: (error) => subscriber.error(error), complete: () => { if (this._error) { // `catchError(() => EMPTY)` converts upstream errors into completions. @@ -174,7 +169,6 @@ export class SuspenseSubject extends Subject { }); // 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--; diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index 022d2180..03d7601b 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -672,7 +672,7 @@ describe('useObservable', () => { let evicted = false; new SuspenseSubject(source$, 50, true, () => { evicted = true; - }); + }, () => {}); // After the 50ms window plus the 1s release grace, the first emission is still pending, so warm-up must still hold the source (#525). await new Promise((resolve) => setTimeout(resolve, 1100)); @@ -754,11 +754,11 @@ describe('useObservable', () => { let evictedWithoutSuspense = false; new SuspenseSubject(failAfter50ms(), 30_000, false, () => { evictedWithoutSuspense = true; - }); + }, () => {}); let evictedWithSuspense = false; new SuspenseSubject(failAfter50ms(), 30_000, true, () => { evictedWithSuspense = true; - }); + }, () => {}); await new Promise((resolve) => setTimeout(resolve, 1300)); expect(evictedWithoutSuspense).toBe(true); @@ -823,7 +823,7 @@ describe('useObservable', () => { let evicted = false; const subject = new SuspenseSubject(of('doc'), 50, false, () => { evicted = true; - }); + }, () => {}); const subscription = subject.subscribe(); await new Promise((resolve) => setTimeout(resolve, 150)); expect(evicted).toBe(false); From 8e667be359d88f9a211ac4abdb8b2c506b42d7d2 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 17:10:19 -0800 Subject: [PATCH 17/20] refactor(core): address remaining #839 review comments Drop the unreachable error handler in _subscribe (catchError converts errors to completions first), rename the remaining short promise callbacks, and remove a test that duplicated its neighbour minus the mid-test emission. --- src/SuspenseSubject.ts | 1 - test/suspenseSubject-error-branch.test.tsx | 2 +- test/useObservable.test.tsx | 28 ++++------------------ 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index e3389255..4514a586 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -155,7 +155,6 @@ export class SuspenseSubject extends Subject { this._subscriberCount++; const innerSubscription = this._innerObservable.subscribe({ next: (value) => subscriber.next(value), - error: (error) => subscriber.error(error), complete: () => { if (this._error) { // `catchError(() => EMPTY)` converts upstream errors into completions. diff --git a/test/suspenseSubject-error-branch.test.tsx b/test/suspenseSubject-error-branch.test.tsx index 62bb42d3..1119c44f 100644 --- a/test/suspenseSubject-error-branch.test.tsx +++ b/test/suspenseSubject-error-branch.test.tsx @@ -48,7 +48,7 @@ describe('SuspenseSubject error-to-completion branch', () => { await waitFor(() => expect(getByTestId('err')).toHaveTextContent('permission-denied')); // Give a resubscribe loop time to show itself. - await new Promise((r) => setTimeout(r, 1500)); + await new Promise((resolve) => setTimeout(resolve, 1500)); expect(subscriptions).toBe(1); } finally { consoleError.mockRestore(); diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index 03d7601b..f73898a1 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -544,7 +544,7 @@ describe('useObservable', () => { first.unmount(); // Wait for the 1s error eviction window to clear the errored entry from preloadedObservables - await new Promise((r) => setTimeout(r, 1100)); + await new Promise((resolve) => setTimeout(resolve, 1100)); shouldFail = false; const second = render( @@ -614,24 +614,6 @@ describe('useObservable', () => { await waitFor(() => expect(subject$.observed).toBe(false), { timeout: 2500 }); }); - it('detaches the source after two subscribers of the same observableId unmount', async () => { - const subject$ = new BehaviorSubject('a'); - const observableId = `shared-subscriber-release-${Math.random()}`; - - const Obs = ({ testId }: { testId: string }) => { - const { data } = useObservable(observableId, subject$); - return
{String(data)}
; - }; - - const first = render(); - const second = render(); - await waitFor(() => expect(second.getByTestId('two')).toHaveTextContent('a')); - - first.unmount(); - second.unmount(); - await waitFor(() => expect(subject$.observed).toBe(false), { timeout: 3000 }); - }); - it('reuses the active subscription when remounting within the 1s release grace window', async () => { const observableId = `grace-window-${Math.random()}`; let subscribeCount = 0; @@ -652,14 +634,14 @@ describe('useObservable', () => { first.unmount(); // Remount after 200ms (inside the 1000ms grace window); must reuse the active subscription. - await new Promise((r) => setTimeout(r, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); const second = render(); await waitFor(() => expect(second.getByTestId('val')).toHaveTextContent('cached-val')); expect(subscribeCount).toBe(1); second.unmount(); // After the 1000ms grace window expires, the next mount subscribes fresh. - await new Promise((r) => setTimeout(r, 1100)); + await new Promise((resolve) => setTimeout(resolve, 1100)); const third = render(); await waitFor(() => expect(third.getByTestId('val')).toHaveTextContent('cached-val')); expect(subscribeCount).toBe(2); @@ -696,7 +678,7 @@ describe('useObservable', () => { initialSub.unsubscribe(); // Wait for firstSubject to be evicted from preloadedObservables. - await new Promise((r) => setTimeout(r, 1100)); + await new Promise((resolve) => setTimeout(resolve, 1100)); // Register a new subject under the same observableId and keep a subscriber active. const secondSubject = preloadObservable(new BehaviorSubject('second'), observableId); @@ -706,7 +688,7 @@ describe('useObservable', () => { // A late caller holding the evicted firstSubject subscribes and unsubscribes, re-arming firstSubject's eviction timer. const lateSub = firstSubject.subscribe(); lateSub.unsubscribe(); - await new Promise((r) => setTimeout(r, 1100)); + await new Promise((resolve) => setTimeout(resolve, 1100)); // The identity guard in onEvict must keep secondSubject in the cache. expect(preloadObservable(new BehaviorSubject('third'), observableId)).toBe(secondSubject); From 364c08af86983c71afbc32ede7d34da091e9c66d Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 5 Nov 2026 17:20:23 -0800 Subject: [PATCH 18/20] test(core): cover the re-register of an evicted entry and its skip cases A late subscriber on an evicted entry puts it back under its id, so the next read reuses its listener; errored and completed entries are skipped. Each test goes red when its half of the mechanism is removed. --- test/useObservable.test.tsx | 41 ++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index f73898a1..a8f7da7f 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -5,7 +5,7 @@ import { Writable } from 'node:stream'; import { renderToString, renderToPipeableStream } from 'react-dom/server'; import * as firebaseAppModule from 'firebase/app'; import { initializeApp, deleteApp, getApps, FirebaseApp, setLogLevel } from 'firebase/app'; -import { defer, of, Subject, BehaviorSubject, throwError, timer } from 'rxjs'; +import { defer, of, Observable, Subject, BehaviorSubject, throwError, timer } from 'rxjs'; import { first, mergeMap } from 'rxjs/operators'; import { useObservable, ReactFireOptions, version } from '../src/index'; @@ -695,6 +695,45 @@ describe('useObservable', () => { activeSub.unsubscribe(); }); + it('re-registers an evicted entry when a late subscriber attaches, so the next read reuses its listener', async () => { + const { preloadObservable } = await import('../src/useObservable'); + const observableId = `reregister-${Math.random()}`; + let subscribeCount = 0; + const source$ = defer(() => { + subscribeCount++; + return new BehaviorSubject('value'); + }); + + const subject = preloadObservable(source$, observableId); + subject.subscribe().unsubscribe(); + + // The release evicts the entry, but a render that read it earlier still holds the subject. + await new Promise((resolve) => setTimeout(resolve, 1100)); + + // That render's effect subscribes late; the next read must reuse this entry, not open a second listener. + const lateSub = subject.subscribe(); + expect(preloadObservable(source$, observableId)).toBe(subject); + expect(subscribeCount).toBe(2); + lateSub.unsubscribe(); + }); + + it.each<[string, number, () => Observable]>([ + ['an errored', 0, () => throwError(() => new Error('permission-denied'))], + ['a completed', 0, () => of('doc')], + ['a live (control)', 1, () => new BehaviorSubject('value')], + ])('late subscribe on %s entry re-registers it %i time(s)', async (_label, expectedReregisters, makeSource) => { + const { SuspenseSubject } = await import('../src/SuspenseSubject'); + let reregisters = 0; + const subject = new SuspenseSubject(makeSource(), 50, false, () => {}, () => { + reregisters++; + }); + + // Re-registering an errored entry would bring a stale error back on the next sign-in; a completed one would never leave. + const lateSub = subject.subscribe({ error: () => {} }); + expect(reregisters).toBe(expectedReregisters); + lateSub.unsubscribe(); + }); + it('keeps the source attached while a consumer is suspended on a still-loading entry after an earlier subscriber unmounted', async () => { const observableId = `suspended-consumer-${Math.random()}`; const source$ = new Subject(); From 12c4a6515d7ac4b10c4f1065344e82c68b329403 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Fri, 6 Nov 2026 11:57:16 -0800 Subject: [PATCH 19/20] fix(sdk): let a rejected useInit* initializer run again on the next mount The per-app initializer cache kept a rejected promise, so after the error eviction every later mount reused it and the initializer never ran again. Drop the entry on rejection. Also explain the useMemo deps exception. --- src/sdk.tsx | 12 ++++++++++- test/sdk-init-cache.test.tsx | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/sdk.tsx b/src/sdk.tsx index dc96fa24..b4ee5470 100644 --- a/src/sdk.tsx +++ b/src/sdk.tsx @@ -77,9 +77,19 @@ function useInitSdk( initializedSdks.set(firebaseApp, sdks); } if (!sdks.has(sdkName)) { - sdks.set(sdkName, sdkInitializer(firebaseApp)); + const cache = sdks; + const initializing = sdkInitializer(firebaseApp); + // A rejected initializer must not stay cached, or no later mount can retry it. + initializing.catch(() => { + if (cache.get(sdkName) === initializing) { + cache.delete(sdkName); + } + }); + cache.set(sdkName, initializing); } return sdks.get(sdkName) as Promise; + // Cached per app and SDK name, so a new `sdkInitializer` identity on re-render must not re-run it. + // oxlint-disable-next-line react/exhaustive-deps }, [firebaseApp]); return useObservable(`firebase-sdk:${sdkName}:${firebaseApp.name}`, from(initializeSdk), options); diff --git a/test/sdk-init-cache.test.tsx b/test/sdk-init-cache.test.tsx index d9e39ee9..fc402d96 100644 --- a/test/sdk-init-cache.test.tsx +++ b/test/sdk-init-cache.test.tsx @@ -79,4 +79,43 @@ describe('useInit* initializer', () => { consoleError.mockRestore(); } }, 45_000); + + it('runs again on the next mount after the initializer rejects', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const app = initializeApp(baseConfig, `init-retry-${Math.random()}`); + let calls = 0; + const initializer = async () => { + calls++; + if (calls === 1) { + throw new Error('network down'); + } + return { app } as unknown as Firestore; + }; + + const Init = () => { + const { status } = useInitFirestore(initializer); + return status === 'success' ?
ready
: null; + }; + const tree = ( + + + + + + ); + + const first = render(tree); + await waitFor(() => expect(first.getByTestId('err')).toHaveTextContent('network down')); + first.unmount(); + // Past the 1s error eviction, so the next mount builds a new cache entry. + await new Promise((resolve) => setTimeout(resolve, 1_500)); + + const second = render(tree); + await waitFor(() => expect(second.getByTestId('ready')).toBeInTheDocument()); + expect(calls).toBe(2); + } finally { + consoleError.mockRestore(); + } + }, 15_000); }); From 67389125a61a4d2702fb8eca33fcaf5db1de4d57 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Fri, 6 Nov 2026 12:19:48 -0800 Subject: [PATCH 20/20] docs: regenerate reference docs --- docs/reference/functions/useAnalytics.md | 2 +- docs/reference/functions/useAppCheck.md | 2 +- docs/reference/functions/useAuth.md | 2 +- docs/reference/functions/useDatabase.md | 2 +- docs/reference/functions/useFirestore.md | 2 +- docs/reference/functions/useFunctions.md | 2 +- docs/reference/functions/usePerformance.md | 2 +- docs/reference/functions/useRemoteConfig.md | 2 +- docs/reference/functions/useStorage.md | 2 +- docs/reference/variables/AnalyticsProvider.md | 2 +- docs/reference/variables/AppCheckProvider.md | 2 +- docs/reference/variables/AuthProvider.md | 2 +- docs/reference/variables/DatabaseProvider.md | 2 +- docs/reference/variables/FirestoreProvider.md | 2 +- docs/reference/variables/FunctionsProvider.md | 2 +- docs/reference/variables/PerformanceProvider.md | 2 +- docs/reference/variables/RemoteConfigProvider.md | 2 +- docs/reference/variables/StorageProvider.md | 2 +- docs/reference/variables/useInitAnalytics.md | 2 +- docs/reference/variables/useInitAppCheck.md | 2 +- docs/reference/variables/useInitAuth.md | 2 +- docs/reference/variables/useInitDatabase.md | 2 +- docs/reference/variables/useInitFirestore.md | 2 +- docs/reference/variables/useInitFunctions.md | 2 +- docs/reference/variables/useInitPerformance.md | 2 +- docs/reference/variables/useInitRemoteConfig.md | 2 +- docs/reference/variables/useInitStorage.md | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/reference/functions/useAnalytics.md b/docs/reference/functions/useAnalytics.md index 41701393..5fb3cf0b 100644 --- a/docs/reference/functions/useAnalytics.md +++ b/docs/reference/functions/useAnalytics.md @@ -8,7 +8,7 @@ > **useAnalytics**(): `Analytics` -Defined in: [src/sdk.tsx:100](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L100) +Defined in: [src/sdk.tsx:110](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L110) ## Returns diff --git a/docs/reference/functions/useAppCheck.md b/docs/reference/functions/useAppCheck.md index 15c51746..22877d3a 100644 --- a/docs/reference/functions/useAppCheck.md +++ b/docs/reference/functions/useAppCheck.md @@ -8,7 +8,7 @@ > **useAppCheck**(): `AppCheck` -Defined in: [src/sdk.tsx:98](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L98) +Defined in: [src/sdk.tsx:108](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L108) ## Returns diff --git a/docs/reference/functions/useAuth.md b/docs/reference/functions/useAuth.md index a9d22918..1c5e129b 100644 --- a/docs/reference/functions/useAuth.md +++ b/docs/reference/functions/useAuth.md @@ -8,7 +8,7 @@ > **useAuth**(): `Auth` -Defined in: [src/sdk.tsx:99](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L99) +Defined in: [src/sdk.tsx:109](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L109) ## Returns diff --git a/docs/reference/functions/useDatabase.md b/docs/reference/functions/useDatabase.md index 6db90a25..fb56002b 100644 --- a/docs/reference/functions/useDatabase.md +++ b/docs/reference/functions/useDatabase.md @@ -8,7 +8,7 @@ > **useDatabase**(): `Database` -Defined in: [src/sdk.tsx:101](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L101) +Defined in: [src/sdk.tsx:111](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L111) ## Returns diff --git a/docs/reference/functions/useFirestore.md b/docs/reference/functions/useFirestore.md index 759bc403..64bb6a10 100644 --- a/docs/reference/functions/useFirestore.md +++ b/docs/reference/functions/useFirestore.md @@ -8,7 +8,7 @@ > **useFirestore**(): `Firestore` -Defined in: [src/sdk.tsx:102](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L102) +Defined in: [src/sdk.tsx:112](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L112) ## Returns diff --git a/docs/reference/functions/useFunctions.md b/docs/reference/functions/useFunctions.md index f29703de..b17af0c1 100644 --- a/docs/reference/functions/useFunctions.md +++ b/docs/reference/functions/useFunctions.md @@ -8,7 +8,7 @@ > **useFunctions**(): `Functions` -Defined in: [src/sdk.tsx:103](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L103) +Defined in: [src/sdk.tsx:113](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L113) ## Returns diff --git a/docs/reference/functions/usePerformance.md b/docs/reference/functions/usePerformance.md index 7b225614..30b2aefe 100644 --- a/docs/reference/functions/usePerformance.md +++ b/docs/reference/functions/usePerformance.md @@ -8,7 +8,7 @@ > **usePerformance**(): `FirebasePerformance` -Defined in: [src/sdk.tsx:104](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L104) +Defined in: [src/sdk.tsx:114](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L114) ## Returns diff --git a/docs/reference/functions/useRemoteConfig.md b/docs/reference/functions/useRemoteConfig.md index d181bf31..ec2894ce 100644 --- a/docs/reference/functions/useRemoteConfig.md +++ b/docs/reference/functions/useRemoteConfig.md @@ -8,7 +8,7 @@ > **useRemoteConfig**(): `RemoteConfig` -Defined in: [src/sdk.tsx:106](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L106) +Defined in: [src/sdk.tsx:116](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L116) ## Returns diff --git a/docs/reference/functions/useStorage.md b/docs/reference/functions/useStorage.md index 62c4ce27..765a17ae 100644 --- a/docs/reference/functions/useStorage.md +++ b/docs/reference/functions/useStorage.md @@ -8,7 +8,7 @@ > **useStorage**(): `FirebaseStorage` -Defined in: [src/sdk.tsx:105](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L105) +Defined in: [src/sdk.tsx:115](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L115) ## Returns diff --git a/docs/reference/variables/AnalyticsProvider.md b/docs/reference/variables/AnalyticsProvider.md index 6fdf2e9c..e09d1387 100644 --- a/docs/reference/variables/AnalyticsProvider.md +++ b/docs/reference/variables/AnalyticsProvider.md @@ -8,7 +8,7 @@ > `const` **AnalyticsProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:90](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L90) +Defined in: [src/sdk.tsx:100](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L100) ## Parameters diff --git a/docs/reference/variables/AppCheckProvider.md b/docs/reference/variables/AppCheckProvider.md index f603adce..74707d15 100644 --- a/docs/reference/variables/AppCheckProvider.md +++ b/docs/reference/variables/AppCheckProvider.md @@ -8,7 +8,7 @@ > `const` **AppCheckProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:88](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L88) +Defined in: [src/sdk.tsx:98](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L98) ## Parameters diff --git a/docs/reference/variables/AuthProvider.md b/docs/reference/variables/AuthProvider.md index feed41ad..9139dac8 100644 --- a/docs/reference/variables/AuthProvider.md +++ b/docs/reference/variables/AuthProvider.md @@ -8,7 +8,7 @@ > `const` **AuthProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:89](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L89) +Defined in: [src/sdk.tsx:99](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L99) ## Parameters diff --git a/docs/reference/variables/DatabaseProvider.md b/docs/reference/variables/DatabaseProvider.md index 582d11f8..367f9233 100644 --- a/docs/reference/variables/DatabaseProvider.md +++ b/docs/reference/variables/DatabaseProvider.md @@ -8,7 +8,7 @@ > `const` **DatabaseProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:91](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L91) +Defined in: [src/sdk.tsx:101](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L101) ## Parameters diff --git a/docs/reference/variables/FirestoreProvider.md b/docs/reference/variables/FirestoreProvider.md index 30c3374a..036b800f 100644 --- a/docs/reference/variables/FirestoreProvider.md +++ b/docs/reference/variables/FirestoreProvider.md @@ -8,7 +8,7 @@ > `const` **FirestoreProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:92](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L92) +Defined in: [src/sdk.tsx:102](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L102) ## Parameters diff --git a/docs/reference/variables/FunctionsProvider.md b/docs/reference/variables/FunctionsProvider.md index 76eeac93..fc32521c 100644 --- a/docs/reference/variables/FunctionsProvider.md +++ b/docs/reference/variables/FunctionsProvider.md @@ -8,7 +8,7 @@ > `const` **FunctionsProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:93](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L93) +Defined in: [src/sdk.tsx:103](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L103) ## Parameters diff --git a/docs/reference/variables/PerformanceProvider.md b/docs/reference/variables/PerformanceProvider.md index 205ba786..71f58d57 100644 --- a/docs/reference/variables/PerformanceProvider.md +++ b/docs/reference/variables/PerformanceProvider.md @@ -8,7 +8,7 @@ > `const` **PerformanceProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:94](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L94) +Defined in: [src/sdk.tsx:104](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L104) ## Parameters diff --git a/docs/reference/variables/RemoteConfigProvider.md b/docs/reference/variables/RemoteConfigProvider.md index d7410c91..6b7f30f0 100644 --- a/docs/reference/variables/RemoteConfigProvider.md +++ b/docs/reference/variables/RemoteConfigProvider.md @@ -8,7 +8,7 @@ > `const` **RemoteConfigProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:96](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L96) +Defined in: [src/sdk.tsx:106](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L106) ## Parameters diff --git a/docs/reference/variables/StorageProvider.md b/docs/reference/variables/StorageProvider.md index 6131d4e4..78800875 100644 --- a/docs/reference/variables/StorageProvider.md +++ b/docs/reference/variables/StorageProvider.md @@ -8,7 +8,7 @@ > `const` **StorageProvider**: (`props`) => `ReactElement` -Defined in: [src/sdk.tsx:95](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L95) +Defined in: [src/sdk.tsx:105](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L105) ## Parameters diff --git a/docs/reference/variables/useInitAnalytics.md b/docs/reference/variables/useInitAnalytics.md index a68a6206..5172100b 100644 --- a/docs/reference/variables/useInitAnalytics.md +++ b/docs/reference/variables/useInitAnalytics.md @@ -8,4 +8,4 @@ > `const` **useInitAnalytics**: `InitSdkHook`\<`Analytics`\> -Defined in: [src/sdk.tsx:115](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L115) +Defined in: [src/sdk.tsx:125](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L125) diff --git a/docs/reference/variables/useInitAppCheck.md b/docs/reference/variables/useInitAppCheck.md index 1f021db9..433e62c8 100644 --- a/docs/reference/variables/useInitAppCheck.md +++ b/docs/reference/variables/useInitAppCheck.md @@ -8,4 +8,4 @@ > `const` **useInitAppCheck**: `InitSdkHook`\<`AppCheck`\> -Defined in: [src/sdk.tsx:113](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L113) +Defined in: [src/sdk.tsx:123](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L123) diff --git a/docs/reference/variables/useInitAuth.md b/docs/reference/variables/useInitAuth.md index bdbc9538..2e209c6a 100644 --- a/docs/reference/variables/useInitAuth.md +++ b/docs/reference/variables/useInitAuth.md @@ -8,4 +8,4 @@ > `const` **useInitAuth**: `InitSdkHook`\<`Auth`\> -Defined in: [src/sdk.tsx:114](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L114) +Defined in: [src/sdk.tsx:124](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L124) diff --git a/docs/reference/variables/useInitDatabase.md b/docs/reference/variables/useInitDatabase.md index f165af5c..1bfb954d 100644 --- a/docs/reference/variables/useInitDatabase.md +++ b/docs/reference/variables/useInitDatabase.md @@ -8,4 +8,4 @@ > `const` **useInitDatabase**: `InitSdkHook`\<`Database`\> -Defined in: [src/sdk.tsx:116](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L116) +Defined in: [src/sdk.tsx:126](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L126) diff --git a/docs/reference/variables/useInitFirestore.md b/docs/reference/variables/useInitFirestore.md index ce37c53b..901ee772 100644 --- a/docs/reference/variables/useInitFirestore.md +++ b/docs/reference/variables/useInitFirestore.md @@ -8,4 +8,4 @@ > `const` **useInitFirestore**: `InitSdkHook`\<`Firestore`\> -Defined in: [src/sdk.tsx:117](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L117) +Defined in: [src/sdk.tsx:127](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L127) diff --git a/docs/reference/variables/useInitFunctions.md b/docs/reference/variables/useInitFunctions.md index 0b7815a4..fa127eb3 100644 --- a/docs/reference/variables/useInitFunctions.md +++ b/docs/reference/variables/useInitFunctions.md @@ -8,4 +8,4 @@ > `const` **useInitFunctions**: `InitSdkHook`\<`Functions`\> -Defined in: [src/sdk.tsx:118](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L118) +Defined in: [src/sdk.tsx:128](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L128) diff --git a/docs/reference/variables/useInitPerformance.md b/docs/reference/variables/useInitPerformance.md index 649627e9..99c0cf6a 100644 --- a/docs/reference/variables/useInitPerformance.md +++ b/docs/reference/variables/useInitPerformance.md @@ -8,4 +8,4 @@ > `const` **useInitPerformance**: `InitSdkHook`\<`FirebasePerformance`\> -Defined in: [src/sdk.tsx:119](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L119) +Defined in: [src/sdk.tsx:129](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L129) diff --git a/docs/reference/variables/useInitRemoteConfig.md b/docs/reference/variables/useInitRemoteConfig.md index 6bc9f441..80961afd 100644 --- a/docs/reference/variables/useInitRemoteConfig.md +++ b/docs/reference/variables/useInitRemoteConfig.md @@ -8,4 +8,4 @@ > `const` **useInitRemoteConfig**: `InitSdkHook`\<`RemoteConfig`\> -Defined in: [src/sdk.tsx:121](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L121) +Defined in: [src/sdk.tsx:131](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L131) diff --git a/docs/reference/variables/useInitStorage.md b/docs/reference/variables/useInitStorage.md index 233ecf78..4dc80cb1 100644 --- a/docs/reference/variables/useInitStorage.md +++ b/docs/reference/variables/useInitStorage.md @@ -8,4 +8,4 @@ > `const` **useInitStorage**: `InitSdkHook`\<`FirebaseStorage`\> -Defined in: [src/sdk.tsx:123](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L123) +Defined in: [src/sdk.tsx:133](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L133)