From 48154fdaa3349b961e98fae139a950863ca4f6fd Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:50:27 +0000 Subject: [PATCH 1/5] fix: unconditionally register react and reactfire versions --- docs/reference/functions/preloadObservable.md | 2 +- docs/reference/functions/useFirebaseApp.md | 2 +- .../functions/useIsSuspenseEnabled.md | 2 +- docs/reference/functions/useObservable.md | 2 +- .../useSuspenseEnabledFromConfigAndContext.md | 2 +- docs/reference/interfaces/ObservableStatus.md | 14 ++--- src/firebaseApp.tsx | 7 ++- src/useObservable.ts | 7 ++- test/firebaseApp.test.tsx | 61 ++++++++++++++++++- test/useObservable.test.tsx | 39 +++++++++++- 10 files changed, 119 insertions(+), 19 deletions(-) diff --git a/docs/reference/functions/preloadObservable.md b/docs/reference/functions/preloadObservable.md index 92aab4f9..be92d76e 100644 --- a/docs/reference/functions/preloadObservable.md +++ b/docs/reference/functions/preloadObservable.md @@ -8,7 +8,7 @@ > **preloadObservable**\<`T`\>(`source`, `id`, `suspenseEnabled?`): `SuspenseSubject`\<`T`\> -Defined in: [src/useObservable.ts:20](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L20) +Defined in: [src/useObservable.ts:21](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L21) ## Type Parameters diff --git a/docs/reference/functions/useFirebaseApp.md b/docs/reference/functions/useFirebaseApp.md index d7f1d7cc..42104ee4 100644 --- a/docs/reference/functions/useFirebaseApp.md +++ b/docs/reference/functions/useFirebaseApp.md @@ -8,7 +8,7 @@ > **useFirebaseApp**(): `FirebaseApp` -Defined in: [src/firebaseApp.tsx:80](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L80) +Defined in: [src/firebaseApp.tsx:81](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L81) ## Returns diff --git a/docs/reference/functions/useIsSuspenseEnabled.md b/docs/reference/functions/useIsSuspenseEnabled.md index b068fabe..d10eaa5d 100644 --- a/docs/reference/functions/useIsSuspenseEnabled.md +++ b/docs/reference/functions/useIsSuspenseEnabled.md @@ -8,7 +8,7 @@ > **useIsSuspenseEnabled**(): `boolean` -Defined in: [src/firebaseApp.tsx:62](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L62) +Defined in: [src/firebaseApp.tsx:63](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L63) ## Returns diff --git a/docs/reference/functions/useObservable.md b/docs/reference/functions/useObservable.md index 4ca565df..fd5579c0 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:67](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L67) +Defined in: [src/useObservable.ts:68](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L68) ## Type Parameters diff --git a/docs/reference/functions/useSuspenseEnabledFromConfigAndContext.md b/docs/reference/functions/useSuspenseEnabledFromConfigAndContext.md index 4781e595..ffd73fd2 100644 --- a/docs/reference/functions/useSuspenseEnabledFromConfigAndContext.md +++ b/docs/reference/functions/useSuspenseEnabledFromConfigAndContext.md @@ -8,7 +8,7 @@ > **useSuspenseEnabledFromConfigAndContext**(`suspenseFromConfig?`): `boolean` -Defined in: [src/firebaseApp.tsx:69](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L69) +Defined in: [src/firebaseApp.tsx:70](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L70) ## Parameters diff --git a/docs/reference/interfaces/ObservableStatus.md b/docs/reference/interfaces/ObservableStatus.md index a1846ef9..6b916f26 100644 --- a/docs/reference/interfaces/ObservableStatus.md +++ b/docs/reference/interfaces/ObservableStatus.md @@ -6,7 +6,7 @@ # Interface: ObservableStatus\ -Defined in: [src/useObservable.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L30) +Defined in: [src/useObservable.ts:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L31) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/useObservable.ts:30](https://github.com/FirebaseExtended/reactf > **data**: `T` -Defined in: [src/useObservable.ts:56](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L56) +Defined in: [src/useObservable.ts:57](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L57) 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:60](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L60) +Defined in: [src/useObservable.ts:61](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L61) 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:64](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L64) +Defined in: [src/useObservable.ts:65](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L65) 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:46](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L46) +Defined in: [src/useObservable.ts:47](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L47) 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:50](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L50) +Defined in: [src/useObservable.ts:51](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L51) 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:40](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L40) +Defined in: [src/useObservable.ts:41](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L41) The loading status. diff --git a/src/firebaseApp.tsx b/src/firebaseApp.tsx index 1f184b12..32da7399 100644 --- a/src/firebaseApp.tsx +++ b/src/firebaseApp.tsx @@ -27,6 +27,10 @@ export function FirebaseAppProvider(props: React.PropsWithChildren { + const reactVersion = React.version || 'unknown'; + registerVersion('react', reactVersion); + registerVersion('reactfire', version); + if (props.firebaseApp) { return props.firebaseApp; } @@ -45,9 +49,6 @@ export function FirebaseAppProvider(props: React.PropsWithChildren(observableId: string, source: Observa throw new Error('cannot call useObservable without an observableId'); } + const reactVersion = React.version || 'unknown'; + registerVersion('react', reactVersion); + registerVersion('reactfire', version); + const suspenseEnabled = useSuspenseEnabledFromConfigAndContext(config.suspense); // Register the observable with the cache diff --git a/test/firebaseApp.test.tsx b/test/firebaseApp.test.tsx index b46dcd0d..8a577501 100644 --- a/test/firebaseApp.test.tsx +++ b/test/firebaseApp.test.tsx @@ -1,14 +1,31 @@ import { cleanup, render, waitFor, renderHook } from '@testing-library/react'; -import { initializeApp, deleteApp, getApps } from 'firebase/app'; +import * as firebaseAppModule from 'firebase/app'; +import { initializeApp, deleteApp, getApps, FirebaseApp } from 'firebase/app'; import '@testing-library/jest-dom/extend-expect'; import * as React from 'react'; import { useFirebaseApp, FirebaseAppProvider, version } from '../src/index'; import pkg from '../package.json'; +const _components = (firebaseAppModule as unknown as { _components: Map })._components; + +interface AppWithContainer extends FirebaseApp { + container: { + getProvider(name: 'platform-logger'): { + getImmediate(): { getPlatformInfoString(): string }; + }; + }; +} + +function getPlatformInfo(app = getApps()[0]): string { + return (app as AppWithContainer).container.getProvider('platform-logger').getImmediate().getPlatformInfoString(); +} + afterEach(() => { cleanup(); getApps().forEach(deleteApp); + _components.delete('react-version'); + _components.delete('reactfire-version'); }); const DEFAULT_APP_CONFIG = { appId: '12345' }; @@ -99,4 +116,46 @@ describe('reactfire version', () => { it(`should match version ${pkg.version}`, () => { expect(pkg.version).toEqual(version); }); + + it('registers react and reactfire versions when initializing an app via firebaseConfig', () => { + expect(_components.has('react-version')).toBe(false); + expect(_components.has('reactfire-version')).toBe(false); + + render(); + + const info = getPlatformInfo(); + expect(info).toContain(`react/${React.version}`); + expect(info).toContain(`reactfire/${version}`); + }); + + it('registers react and reactfire versions when passed an existing firebaseApp prop', () => { + const app = initializeApp(DEFAULT_APP_CONFIG, 'test'); + expect(getPlatformInfo(app)).not.toContain(`reactfire/${version}`); + + render(); + + const info = getPlatformInfo(app); + expect(info).toContain(`react/${React.version}`); + expect(info).toContain(`reactfire/${version}`); + }); + + it('registers react and reactfire versions when reusing an already-initialized app via firebaseConfig', () => { + const app = initializeApp(DEFAULT_APP_CONFIG); + expect(getPlatformInfo(app)).not.toContain(`reactfire/${version}`); + + render(); + + const info = getPlatformInfo(app); + expect(info).toContain(`react/${React.version}`); + expect(info).toContain(`reactfire/${version}`); + }); + + it('registers versions idempotently across multiple FirebaseAppProvider renders', () => { + const { rerender } = render(); + rerender(); + + const tokens = getPlatformInfo().split(' '); + expect(tokens.filter((t) => t === `react/${React.version}`)).toHaveLength(1); + expect(tokens.filter((t) => t === `reactfire/${version}`)).toHaveLength(1); + }); }); diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index ada53ab5..d12f0ab4 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -3,11 +3,46 @@ 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 * as firebaseAppModule from 'firebase/app'; +import { initializeApp, deleteApp, getApps, FirebaseApp } from 'firebase/app'; import { of, Subject, BehaviorSubject, throwError } from 'rxjs'; -import { useObservable, ReactFireOptions } from '../src/index'; +import { useObservable, ReactFireOptions, version } from '../src/index'; + +const _components = (firebaseAppModule as unknown as { _components: Map })._components; + +interface AppWithContainer extends FirebaseApp { + container: { + getProvider(name: 'platform-logger'): { + getImmediate(): { getPlatformInfoString(): string }; + }; + }; +} describe('useObservable', () => { - afterEach(cleanup); + afterEach(() => { + cleanup(); + getApps().forEach(deleteApp); + _components.delete('react-version'); + _components.delete('reactfire-version'); + }); + + it('registers react and reactfire versions with firebase/app idempotently', () => { + expect(_components.has('react-version')).toBe(false); + expect(_components.has('reactfire-version')).toBe(false); + + const observable$ = new BehaviorSubject('initial'); + renderHook(() => useObservable('version-test-1', observable$)); + renderHook(() => useObservable('version-test-2', observable$)); + + expect(_components.has('react-version')).toBe(true); + expect(_components.has('reactfire-version')).toBe(true); + + const app = initializeApp({ appId: '12345' }); + const info = (app as AppWithContainer).container.getProvider('platform-logger').getImmediate().getPlatformInfoString(); + const tokens = info.split(' '); + expect(tokens.filter((t) => t === `react/${React.version}`)).toHaveLength(1); + expect(tokens.filter((t) => t === `reactfire/${version}`)).toHaveLength(1); + }); describe('Non-Suspense Mode', () => { it('Reports its status correctly', () => { From 6a3af1a6a18378706b5c6d014621b4d7e31928fe Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:37:24 +0000 Subject: [PATCH 2/5] feat(telemetry): centralize version registration in telemetry.ts and register at module load --- .../functions/FirebaseAppProvider.md | 2 +- docs/reference/functions/useFirebaseApp.md | 2 +- .../functions/useIsSuspenseEnabled.md | 2 +- .../useSuspenseEnabledFromConfigAndContext.md | 2 +- .../interfaces/FirebaseAppProviderProps.md | 10 ++--- docs/reference/variables/version.md | 2 +- src/firebaseApp.tsx | 12 ++---- src/telemetry.ts | 41 ++++++++++++++++++ src/useObservable.ts | 8 ++-- test/firebaseApp.test.tsx | 15 +++++++ test/useObservable.test.tsx | 43 +++++++++++++------ 11 files changed, 102 insertions(+), 37 deletions(-) create mode 100644 src/telemetry.ts diff --git a/docs/reference/functions/FirebaseAppProvider.md b/docs/reference/functions/FirebaseAppProvider.md index 6b851653..e2c1ae7c 100644 --- a/docs/reference/functions/FirebaseAppProvider.md +++ b/docs/reference/functions/FirebaseAppProvider.md @@ -8,7 +8,7 @@ > **FirebaseAppProvider**(`props`): `Element` -Defined in: [src/firebaseApp.tsx:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L26) +Defined in: [src/firebaseApp.tsx:24](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L24) ## Parameters diff --git a/docs/reference/functions/useFirebaseApp.md b/docs/reference/functions/useFirebaseApp.md index 42104ee4..1248d319 100644 --- a/docs/reference/functions/useFirebaseApp.md +++ b/docs/reference/functions/useFirebaseApp.md @@ -8,7 +8,7 @@ > **useFirebaseApp**(): `FirebaseApp` -Defined in: [src/firebaseApp.tsx:81](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L81) +Defined in: [src/firebaseApp.tsx:77](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L77) ## Returns diff --git a/docs/reference/functions/useIsSuspenseEnabled.md b/docs/reference/functions/useIsSuspenseEnabled.md index d10eaa5d..a478899a 100644 --- a/docs/reference/functions/useIsSuspenseEnabled.md +++ b/docs/reference/functions/useIsSuspenseEnabled.md @@ -8,7 +8,7 @@ > **useIsSuspenseEnabled**(): `boolean` -Defined in: [src/firebaseApp.tsx:63](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L63) +Defined in: [src/firebaseApp.tsx:59](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L59) ## Returns diff --git a/docs/reference/functions/useSuspenseEnabledFromConfigAndContext.md b/docs/reference/functions/useSuspenseEnabledFromConfigAndContext.md index ffd73fd2..5efdcf2f 100644 --- a/docs/reference/functions/useSuspenseEnabledFromConfigAndContext.md +++ b/docs/reference/functions/useSuspenseEnabledFromConfigAndContext.md @@ -8,7 +8,7 @@ > **useSuspenseEnabledFromConfigAndContext**(`suspenseFromConfig?`): `boolean` -Defined in: [src/firebaseApp.tsx:70](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L70) +Defined in: [src/firebaseApp.tsx:66](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L66) ## Parameters diff --git a/docs/reference/interfaces/FirebaseAppProviderProps.md b/docs/reference/interfaces/FirebaseAppProviderProps.md index 80a7ff39..f4b0d886 100644 --- a/docs/reference/interfaces/FirebaseAppProviderProps.md +++ b/docs/reference/interfaces/FirebaseAppProviderProps.md @@ -6,7 +6,7 @@ # Interface: FirebaseAppProviderProps -Defined in: [src/firebaseApp.tsx:14](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L14) +Defined in: [src/firebaseApp.tsx:15](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L15) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/firebaseApp.tsx:14](https://github.com/FirebaseExtended/reactfi > `optional` **appName?**: `string` -Defined in: [src/firebaseApp.tsx:17](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L17) +Defined in: [src/firebaseApp.tsx:18](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L18) *** @@ -22,7 +22,7 @@ Defined in: [src/firebaseApp.tsx:17](https://github.com/FirebaseExtended/reactfi > `optional` **firebaseApp?**: `FirebaseApp` -Defined in: [src/firebaseApp.tsx:15](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L15) +Defined in: [src/firebaseApp.tsx:16](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L16) *** @@ -30,7 +30,7 @@ Defined in: [src/firebaseApp.tsx:15](https://github.com/FirebaseExtended/reactfi > `optional` **firebaseConfig?**: `FirebaseOptions` -Defined in: [src/firebaseApp.tsx:16](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L16) +Defined in: [src/firebaseApp.tsx:17](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L17) *** @@ -38,4 +38,4 @@ Defined in: [src/firebaseApp.tsx:16](https://github.com/FirebaseExtended/reactfi > `optional` **suspense?**: `boolean` -Defined in: [src/firebaseApp.tsx:18](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L18) +Defined in: [src/firebaseApp.tsx:19](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L19) diff --git a/docs/reference/variables/version.md b/docs/reference/variables/version.md index 35c34d90..7d341384 100644 --- a/docs/reference/variables/version.md +++ b/docs/reference/variables/version.md @@ -8,4 +8,4 @@ > `const` **version**: `string` -Defined in: [src/firebaseApp.tsx:22](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L22) +Defined in: src/telemetry.ts:8 diff --git a/src/firebaseApp.tsx b/src/firebaseApp.tsx index 32da7399..27ca35af 100644 --- a/src/firebaseApp.tsx +++ b/src/firebaseApp.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { getApps, initializeApp, registerVersion } from 'firebase/app'; +import { getApps, initializeApp } from 'firebase/app'; +import { registerReactFireVersion, version } from './telemetry.js'; import type { FirebaseApp, FirebaseOptions } from 'firebase/app'; -declare const process: { env: Record }; +export { version }; // INVESTIGATE I don't like magic strings, can we have export this in js-sdk? const DEFAULT_APP_NAME = '[DEFAULT]'; @@ -18,18 +19,13 @@ export interface FirebaseAppProviderProps { suspense?: boolean; } -// REACTFIRE_VERSION is automatically pulled in from `package.json` by Vite -export const version = process.env.REACTFIRE_VERSION as string; - const shallowEq = (a: { [key: string]: any }, b: { [key: string]: any }) => a === b || [...Object.keys(a), ...Object.keys(b)].every((key) => a[key] === b[key]); export function FirebaseAppProvider(props: React.PropsWithChildren) { const { firebaseConfig, appName, suspense } = props; const firebaseApp: FirebaseApp = React.useMemo(() => { - const reactVersion = React.version || 'unknown'; - registerVersion('react', reactVersion); - registerVersion('reactfire', version); + registerReactFireVersion(); if (props.firebaseApp) { return props.firebaseApp; diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 00000000..4ef4002c --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,41 @@ +import * as React from 'react'; +import * as firebaseAppModule from 'firebase/app'; +import { registerVersion } from 'firebase/app'; + +declare const process: { env: Record }; + +// Vite injects REACTFIRE_VERSION from package.json at build time. +export const version = process.env.REACTFIRE_VERSION as string; + +const components = (firebaseAppModule as unknown as { _components?: Map })._components; +let hasRegisteredFallback = false; + +/** + * Registers the active React and ReactFire versions with `@firebase/app` for platform logging. + * + * Checks `_components` before calling `registerVersion` so repeated renders do not emit + * duplicate-registration debug logs under `setLogLevel('debug')`. + */ +export function registerReactFireVersion(): void { + const reactVersion = React.version || 'unknown'; + + if (components instanceof Map) { + if (!components.has('react-version')) { + registerVersion('react', reactVersion); + } + if (!components.has('reactfire-version')) { + registerVersion('reactfire', version); + } + return; + } + + if (!hasRegisteredFallback) { + hasRegisteredFallback = true; + registerVersion('react', reactVersion); + registerVersion('reactfire', version); + } +} + +// Register at module load so apps that initialize Firebase SDKs at module scope +// record React and ReactFire in the first daily heartbeat before React renders. +registerReactFireVersion(); diff --git a/src/useObservable.ts b/src/useObservable.ts index 48822627..30b4fd92 100644 --- a/src/useObservable.ts +++ b/src/useObservable.ts @@ -1,10 +1,10 @@ import * as React from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { registerVersion } from 'firebase/app'; import { Observable } from 'rxjs'; import { SuspenseSubject } from './SuspenseSubject.js'; -import { useSuspenseEnabledFromConfigAndContext, version } from './firebaseApp.js'; +import { useSuspenseEnabledFromConfigAndContext } from './firebaseApp.js'; import { ReactFireGlobals, ReactFireOptions } from './index.js'; +import { registerReactFireVersion } from './telemetry.js'; const DEFAULT_TIMEOUT = 30_000; @@ -70,9 +70,7 @@ export function useObservable(observableId: string, source: Observa throw new Error('cannot call useObservable without an observableId'); } - const reactVersion = React.version || 'unknown'; - registerVersion('react', reactVersion); - registerVersion('reactfire', version); + registerReactFireVersion(); const suspenseEnabled = useSuspenseEnabledFromConfigAndContext(config.suspense); diff --git a/test/firebaseApp.test.tsx b/test/firebaseApp.test.tsx index 8a577501..989d94f3 100644 --- a/test/firebaseApp.test.tsx +++ b/test/firebaseApp.test.tsx @@ -117,6 +117,21 @@ describe('reactfire version', () => { expect(pkg.version).toEqual(version); }); + it('registers react and reactfire versions at module import time before any component renders', async () => { + _components.delete('react-version'); + _components.delete('reactfire-version'); + expect(_components.has('react-version')).toBe(false); + expect(_components.has('reactfire-version')).toBe(false); + + vi.resetModules(); + await import('../src/telemetry'); + + const app = initializeApp(DEFAULT_APP_CONFIG, 'module-load-test'); + const info = getPlatformInfo(app); + expect(info).toContain(`react/${React.version}`); + expect(info).toContain(`reactfire/${version}`); + }); + it('registers react and reactfire versions when initializing an app via firebaseConfig', () => { expect(_components.has('react-version')).toBe(false); expect(_components.has('reactfire-version')).toBe(false); diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index d12f0ab4..b9552125 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { Writable } from 'node:stream'; import { renderToString, renderToPipeableStream } from 'react-dom/server'; import * as firebaseAppModule from 'firebase/app'; -import { initializeApp, deleteApp, getApps, FirebaseApp } from 'firebase/app'; +import { initializeApp, deleteApp, getApps, FirebaseApp, setLogLevel } from 'firebase/app'; import { of, Subject, BehaviorSubject, throwError } from 'rxjs'; import { useObservable, ReactFireOptions, version } from '../src/index'; @@ -26,22 +26,37 @@ describe('useObservable', () => { _components.delete('reactfire-version'); }); - it('registers react and reactfire versions with firebase/app idempotently', () => { - expect(_components.has('react-version')).toBe(false); - expect(_components.has('reactfire-version')).toBe(false); - - const observable$ = new BehaviorSubject('initial'); - renderHook(() => useObservable('version-test-1', observable$)); - renderHook(() => useObservable('version-test-2', observable$)); - + it('registers react and reactfire versions with firebase/app idempotently without debug log spam', () => { + // Module import already registered both versions; clear them to test useObservable registration directly. expect(_components.has('react-version')).toBe(true); expect(_components.has('reactfire-version')).toBe(true); + _components.delete('react-version'); + _components.delete('reactfire-version'); - const app = initializeApp({ appId: '12345' }); - const info = (app as AppWithContainer).container.getProvider('platform-logger').getImmediate().getPlatformInfoString(); - const tokens = info.split(' '); - expect(tokens.filter((t) => t === `react/${React.version}`)).toHaveLength(1); - expect(tokens.filter((t) => t === `reactfire/${version}`)).toHaveLength(1); + const debugMessages: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + debugMessages.push(args.join(' ')); + }); + setLogLevel('debug'); + + try { + const observable$ = new BehaviorSubject('initial'); + renderHook(() => useObservable('version-test-1', observable$)); + renderHook(() => useObservable('version-test-2', observable$)); + + expect(_components.has('react-version')).toBe(true); + expect(_components.has('reactfire-version')).toBe(true); + expect(debugMessages.some((msg) => msg.includes('There were multiple attempts to register component'))).toBe(false); + + const app = initializeApp({ appId: '12345' }); + const info = (app as AppWithContainer).container.getProvider('platform-logger').getImmediate().getPlatformInfoString(); + const tokens = info.split(' '); + expect(tokens.filter((t) => t === `react/${React.version}`)).toHaveLength(1); + expect(tokens.filter((t) => t === `reactfire/${version}`)).toHaveLength(1); + } finally { + setLogLevel('silent'); + logSpy.mockRestore(); + } }); describe('Non-Suspense Mode', () => { From 6bd462997ebe2d7c03e4836930f8d1c13db83c8b Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:45:17 +0000 Subject: [PATCH 3/5] docs: update version reference link for tracked telemetry.ts --- docs/reference/variables/version.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/variables/version.md b/docs/reference/variables/version.md index 7d341384..0da80983 100644 --- a/docs/reference/variables/version.md +++ b/docs/reference/variables/version.md @@ -8,4 +8,4 @@ > `const` **version**: `string` -Defined in: src/telemetry.ts:8 +Defined in: [src/telemetry.ts:8](https://github.com/FirebaseExtended/reactfire/blob/main/src/telemetry.ts#L8) From 40f1269b219c77aa066226d376fdce38179a165a Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:15:34 +0000 Subject: [PATCH 4/5] test(telemetry): make version tests independent of run order Three firebaseApp tests assumed an earlier test's afterEach had cleared the import-time registration, and the useObservable test assumed it ran first, so each failed when run alone or in shuffled order. Clear both version components before every test instead. --- test/firebaseApp.test.tsx | 7 +++++++ test/useObservable.test.tsx | 5 ++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/test/firebaseApp.test.tsx b/test/firebaseApp.test.tsx index 989d94f3..2cad28d7 100644 --- a/test/firebaseApp.test.tsx +++ b/test/firebaseApp.test.tsx @@ -20,6 +20,13 @@ function getPlatformInfo(app = getApps()[0]): string { return (app as AppWithContainer).container.getProvider('platform-logger').getImmediate().getPlatformInfoString(); } +// reactfire registers versions at import time, so start every test from a clean slate +// instead of relying on a previous test's afterEach. +beforeEach(() => { + _components.delete('react-version'); + _components.delete('reactfire-version'); +}); + afterEach(() => { cleanup(); diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index b9552125..80cb4dbf 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -27,9 +27,8 @@ describe('useObservable', () => { }); it('registers react and reactfire versions with firebase/app idempotently without debug log spam', () => { - // Module import already registered both versions; clear them to test useObservable registration directly. - expect(_components.has('react-version')).toBe(true); - expect(_components.has('reactfire-version')).toBe(true); + // Clear any import-time registration so this exercises useObservable's call directly + // (import-time registration is covered in firebaseApp.test.tsx). _components.delete('react-version'); _components.delete('reactfire-version'); From 58ea553b8f063228a0a1dd195449aaab5ce7d510 Mon Sep 17 00:00:00 2001 From: Jeff Huleatt <3759507+jhuleatt@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:15:34 +0000 Subject: [PATCH 5/5] fix(telemetry): never throw from version registration registerReactFireVersion runs at import time, so an error from firebase/app, such as a partial test mock or a CJS/ESM interop bug, would break importing ReactFire. Catch and drop the error. The _components lookup moves inside the try because a mocked module can throw on property access. --- src/telemetry.ts | 33 ++++++++++++++++++++------------- test/firebaseApp.test.tsx | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/telemetry.ts b/src/telemetry.ts index 4ef4002c..84038816 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -7,7 +7,6 @@ declare const process: { env: Record }; // Vite injects REACTFIRE_VERSION from package.json at build time. export const version = process.env.REACTFIRE_VERSION as string; -const components = (firebaseAppModule as unknown as { _components?: Map })._components; let hasRegisteredFallback = false; /** @@ -15,24 +14,32 @@ let hasRegisteredFallback = false; * * Checks `_components` before calling `registerVersion` so repeated renders do not emit * duplicate-registration debug logs under `setLogLevel('debug')`. + * + * Never throws: telemetry is best effort, so a partially mocked or mis-bundled `firebase/app` + * cannot break importing or rendering ReactFire. */ export function registerReactFireVersion(): void { - const reactVersion = React.version || 'unknown'; + try { + const components = (firebaseAppModule as unknown as { _components?: unknown })._components; + const reactVersion = React.version || 'unknown'; - if (components instanceof Map) { - if (!components.has('react-version')) { - registerVersion('react', reactVersion); + if (components instanceof Map) { + if (!components.has('react-version')) { + registerVersion('react', reactVersion); + } + if (!components.has('reactfire-version')) { + registerVersion('reactfire', version); + } + return; } - if (!components.has('reactfire-version')) { + + if (!hasRegisteredFallback) { + hasRegisteredFallback = true; + registerVersion('react', reactVersion); registerVersion('reactfire', version); } - return; - } - - if (!hasRegisteredFallback) { - hasRegisteredFallback = true; - registerVersion('react', reactVersion); - registerVersion('reactfire', version); + } catch { + // Best effort: drop the error rather than fail the import or render. } } diff --git a/test/firebaseApp.test.tsx b/test/firebaseApp.test.tsx index 2cad28d7..354941d6 100644 --- a/test/firebaseApp.test.tsx +++ b/test/firebaseApp.test.tsx @@ -139,6 +139,24 @@ describe('reactfire version', () => { expect(info).toContain(`reactfire/${version}`); }); + it('never throws when firebase/app fails to register versions', async () => { + const registerVersion = vi.fn(() => { + throw new Error('registerVersion failed'); + }); + vi.resetModules(); + vi.doMock('firebase/app', () => ({ _components: new Map(), registerVersion })); + + try { + // Importing runs the module-level registration, which must not throw either. + const { registerReactFireVersion } = await import('../src/telemetry'); + expect(() => registerReactFireVersion()).not.toThrow(); + expect(registerVersion).toHaveBeenCalled(); + } finally { + vi.doUnmock('firebase/app'); + vi.resetModules(); + } + }); + it('registers react and reactfire versions when initializing an app via firebaseConfig', () => { expect(_components.has('react-version')).toBe(false); expect(_components.has('reactfire-version')).toBe(false);