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/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..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:80](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L80) +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 b068fabe..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:62](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L62) +Defined in: [src/firebaseApp.tsx:59](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L59) ## 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..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:69](https://github.com/FirebaseExtended/reactfire/blob/main/src/firebaseApp.tsx#L69) +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/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/docs/reference/variables/version.md b/docs/reference/variables/version.md index 35c34d90..0da80983 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](https://github.com/FirebaseExtended/reactfire/blob/main/src/telemetry.ts#L8) diff --git a/src/firebaseApp.tsx b/src/firebaseApp.tsx index 1f184b12..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,15 +19,14 @@ 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(() => { + registerReactFireVersion(); + if (props.firebaseApp) { return props.firebaseApp; } @@ -45,9 +45,6 @@ export function FirebaseAppProvider(props: React.PropsWithChildren }; + +// Vite injects REACTFIRE_VERSION from package.json at build time. +export const version = process.env.REACTFIRE_VERSION as string; + +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')`. + * + * 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 { + 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.has('reactfire-version')) { + 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. + } +} + +// 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 237f6e35..30b4fd92 100644 --- a/src/useObservable.ts +++ b/src/useObservable.ts @@ -4,6 +4,7 @@ import { Observable } from 'rxjs'; import { SuspenseSubject } from './SuspenseSubject.js'; import { useSuspenseEnabledFromConfigAndContext } from './firebaseApp.js'; import { ReactFireGlobals, ReactFireOptions } from './index.js'; +import { registerReactFireVersion } from './telemetry.js'; const DEFAULT_TIMEOUT = 30_000; @@ -69,6 +70,8 @@ export function useObservable(observableId: string, source: Observa throw new Error('cannot call useObservable without an observableId'); } + registerReactFireVersion(); + 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..354941d6 100644 --- a/test/firebaseApp.test.tsx +++ b/test/firebaseApp.test.tsx @@ -1,14 +1,38 @@ 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(); +} + +// 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(); getApps().forEach(deleteApp); + _components.delete('react-version'); + _components.delete('reactfire-version'); }); const DEFAULT_APP_CONFIG = { appId: '12345' }; @@ -99,4 +123,79 @@ describe('reactfire version', () => { it(`should match version ${pkg.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('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); + + 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..80cb4dbf 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -3,11 +3,60 @@ 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, setLogLevel } 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 without debug log spam', () => { + // 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'); + + 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', () => { it('Reports its status correctly', () => {