From 640798136d660b7d9085d466105b556a5f0e0392 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Mon, 3 Aug 2026 17:14:27 -0700 Subject: [PATCH] fix: tighten ReactFireOptions generic types, remove T | any widening Removes `| any` from `initialData` and `startWithValue`, so a value of the wrong type is a TypeScript error rather than silently accepted. `T | any` collapses to `any`, so the generic never enforced anything. Tightens `checkIdField` to read `options?.idField` directly, and narrows `checkOptions` / `checkinitialData` from `any` to `unknown`. Both are kept as exports; removal is tracked in #754. Also fixes #741, so this does not break correct code: the raw snapshot hooks resolve to a DocumentSnapshot/QuerySnapshot, so their options are now typed with the hook's own resolved type rather than the unwrapped data type. Without that, seeding them with a snapshot (the only value a caller can actually have) stopped compiling. Adds `src/reactfire-options.type-test.ts`, type-level assertions riding the existing `tsc --noEmit` CI and kept out of the tarball by `.npmignore`. Both directions are covered: a snapshot hook refuses raw data, a data hook refuses a snapshot. Mutation-verified, reverting either fix fails them. No runtime change. Fixes #383. Fixes #741. --- .npmignore | 1 + README.md | 2 +- docs/reference/functions/checkIdField.md | 6 +-- docs/reference/functions/checkOptions.md | 6 +-- docs/reference/functions/checkinitialData.md | 6 +-- .../functions/useFirestoreCollection.md | 2 +- docs/reference/functions/useFirestoreDoc.md | 2 +- .../functions/useFirestoreDocOnce.md | 2 +- docs/reference/interfaces/ReactFireOptions.md | 4 +- .../interfaces/SignInCheckOptionsBasic.md | 4 +- .../SignInCheckOptionsClaimsObject.md | 4 +- .../SignInCheckOptionsClaimsValidator.md | 4 +- src/firestore.tsx | 12 ++--- src/index.ts | 11 +++-- src/reactfire-options.type-test.ts | 46 +++++++++++++++++++ 15 files changed, 81 insertions(+), 31 deletions(-) create mode 100644 .npmignore create mode 100644 src/reactfire-options.type-test.ts diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..451a5a6b --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +src/*.type-test.ts diff --git a/README.md b/README.md index 880b18ed..331b3ba2 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Firebase. ## Platform support -ReactFire is designed for **web React apps** and wraps the [Firebase Web SDK](https://firebase.google.com/docs/web/setup). It is not compatible with React Native or Expo. For React Native projects, use [react-native-firebase](https://rnfirebase.io/) instead. +ReactFire is designed for **web React apps** and wraps the [Firebase JavaScript SDK](https://firebase.google.com/docs/web/setup). It is not compatible with React Native or Expo. For React Native projects, use [react-native-firebase](https://rnfirebase.io/) instead. ## Install diff --git a/docs/reference/functions/checkIdField.md b/docs/reference/functions/checkIdField.md index 82d7c5a9..379fbd6f 100644 --- a/docs/reference/functions/checkIdField.md +++ b/docs/reference/functions/checkIdField.md @@ -6,9 +6,9 @@ # Function: checkIdField() -> **checkIdField**(`options`): `any` +> **checkIdField**(`options`): `string` \| `undefined` -Defined in: [src/index.ts:47](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L47) +Defined in: [src/index.ts:50](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L50) ## Parameters @@ -18,4 +18,4 @@ Defined in: [src/index.ts:47](https://github.com/FirebaseExtended/reactfire/blob ## Returns -`any` +`string` \| `undefined` diff --git a/docs/reference/functions/checkOptions.md b/docs/reference/functions/checkOptions.md index 4f4fdaf0..1b9a8ec1 100644 --- a/docs/reference/functions/checkOptions.md +++ b/docs/reference/functions/checkOptions.md @@ -6,9 +6,9 @@ # Function: checkOptions() -> **checkOptions**(`options`, `field`): `any` +> **checkOptions**(`options`, `field`): `unknown` -Defined in: [src/index.ts:34](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L34) +Defined in: [src/index.ts:37](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L37) ## Parameters @@ -22,4 +22,4 @@ Defined in: [src/index.ts:34](https://github.com/FirebaseExtended/reactfire/blob ## Returns -`any` +`unknown` diff --git a/docs/reference/functions/checkinitialData.md b/docs/reference/functions/checkinitialData.md index f2c78405..d72589eb 100644 --- a/docs/reference/functions/checkinitialData.md +++ b/docs/reference/functions/checkinitialData.md @@ -6,9 +6,9 @@ # Function: checkinitialData() -> **checkinitialData**(`options`): `any` +> **checkinitialData**(`options`): `unknown` -Defined in: [src/index.ts:43](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L43) +Defined in: [src/index.ts:46](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L46) ## Parameters @@ -18,4 +18,4 @@ Defined in: [src/index.ts:43](https://github.com/FirebaseExtended/reactfire/blob ## Returns -`any` +`unknown` diff --git a/docs/reference/functions/useFirestoreCollection.md b/docs/reference/functions/useFirestoreCollection.md index 8cc2a2e4..bca8fc0d 100644 --- a/docs/reference/functions/useFirestoreCollection.md +++ b/docs/reference/functions/useFirestoreCollection.md @@ -26,7 +26,7 @@ Subscribe to a Firestore collection ### options? -[`ReactFireOptions`](../interfaces/ReactFireOptions.md)\<`T`[]\> +[`ReactFireOptions`](../interfaces/ReactFireOptions.md)\<`QuerySnapshot`\<`T`, `DocumentData`\>\> ## Returns diff --git a/docs/reference/functions/useFirestoreDoc.md b/docs/reference/functions/useFirestoreDoc.md index f14bb8b4..3b363640 100644 --- a/docs/reference/functions/useFirestoreDoc.md +++ b/docs/reference/functions/useFirestoreDoc.md @@ -28,7 +28,7 @@ You can preload data for this hook by calling `preloadFirestoreDoc` ### options? -[`ReactFireOptions`](../interfaces/ReactFireOptions.md)\<`T`\> +[`ReactFireOptions`](../interfaces/ReactFireOptions.md)\<`DocumentSnapshot`\<`T`, `DocumentData`\>\> ## Returns diff --git a/docs/reference/functions/useFirestoreDocOnce.md b/docs/reference/functions/useFirestoreDocOnce.md index 6280573d..444ef217 100644 --- a/docs/reference/functions/useFirestoreDocOnce.md +++ b/docs/reference/functions/useFirestoreDocOnce.md @@ -26,7 +26,7 @@ Get a firestore document and don't subscribe to changes ### options? -[`ReactFireOptions`](../interfaces/ReactFireOptions.md)\<`T`\> +[`ReactFireOptions`](../interfaces/ReactFireOptions.md)\<`DocumentSnapshot`\<`T`, `DocumentData`\>\> ## Returns diff --git a/docs/reference/interfaces/ReactFireOptions.md b/docs/reference/interfaces/ReactFireOptions.md index 7ca63c5e..0503ebb7 100644 --- a/docs/reference/interfaces/ReactFireOptions.md +++ b/docs/reference/interfaces/ReactFireOptions.md @@ -30,7 +30,7 @@ Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob ### initialData? -> `optional` **initialData?**: `any` +> `optional` **initialData?**: `T` Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) @@ -38,7 +38,7 @@ Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob ### ~~startWithValue?~~ -> `optional` **startWithValue?**: `any` +> `optional` **startWithValue?**: `T` Defined in: [src/index.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L30) diff --git a/docs/reference/interfaces/SignInCheckOptionsBasic.md b/docs/reference/interfaces/SignInCheckOptionsBasic.md index 66967bf8..fdd95e31 100644 --- a/docs/reference/interfaces/SignInCheckOptionsBasic.md +++ b/docs/reference/interfaces/SignInCheckOptionsBasic.md @@ -41,7 +41,7 @@ Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob ### initialData? -> `optional` **initialData?**: `any` +> `optional` **initialData?**: [`SigninCheckResult`](../type-aliases/SigninCheckResult.md) Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) @@ -53,7 +53,7 @@ Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob ### ~~startWithValue?~~ -> `optional` **startWithValue?**: `any` +> `optional` **startWithValue?**: [`SigninCheckResult`](../type-aliases/SigninCheckResult.md) Defined in: [src/index.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L30) diff --git a/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md b/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md index 09a1eae0..37cc99c8 100644 --- a/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md +++ b/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md @@ -40,7 +40,7 @@ Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob ### initialData? -> `optional` **initialData?**: `any` +> `optional` **initialData?**: [`SigninCheckResult`](../type-aliases/SigninCheckResult.md) Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) @@ -60,7 +60,7 @@ Defined in: [src/auth.tsx:90](https://github.com/FirebaseExtended/reactfire/blob ### ~~startWithValue?~~ -> `optional` **startWithValue?**: `any` +> `optional` **startWithValue?**: [`SigninCheckResult`](../type-aliases/SigninCheckResult.md) Defined in: [src/index.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L30) diff --git a/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md b/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md index 5ba7f609..682e2f47 100644 --- a/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md +++ b/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md @@ -40,7 +40,7 @@ Defined in: [src/index.ts:25](https://github.com/FirebaseExtended/reactfire/blob ### initialData? -> `optional` **initialData?**: `any` +> `optional` **initialData?**: [`SigninCheckResult`](../type-aliases/SigninCheckResult.md) Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L26) @@ -52,7 +52,7 @@ Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob ### ~~startWithValue?~~ -> `optional` **startWithValue?**: `any` +> `optional` **startWithValue?**: [`SigninCheckResult`](../type-aliases/SigninCheckResult.md) Defined in: [src/index.ts:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/index.ts#L30) diff --git a/src/firestore.tsx b/src/firestore.tsx index d5efa8bd..698af2f6 100644 --- a/src/firestore.tsx +++ b/src/firestore.tsx @@ -39,7 +39,7 @@ function getDocObservableId(ref: DocumentReference) { * * You can preload data for this hook by calling `preloadFirestoreDoc` */ -export function useFirestoreDoc(ref: DocumentReference, options?: ReactFireOptions): ObservableStatus> { +export function useFirestoreDoc(ref: DocumentReference, options?: ReactFireOptions>): ObservableStatus> { const observableId = getDocObservableId(ref); const observable$ = doc(ref); @@ -49,7 +49,7 @@ export function useFirestoreDoc(ref: DocumentReference, opt /** * Get a firestore document and don't subscribe to changes */ -export function useFirestoreDocOnce(ref: DocumentReference, options?: ReactFireOptions): ObservableStatus> { +export function useFirestoreDocOnce(ref: DocumentReference, options?: ReactFireOptions>): ObservableStatus> { const observableId = `firestore:docOnce:${ref.firestore.app.name}:${ref.path}`; const observable$ = doc(ref).pipe(first()); @@ -63,7 +63,7 @@ export function useFirestoreDocData(ref: DocumentReference, opti const idField = options ? checkIdField(options) : undefined; const observableId = `firestore:docData:${ref.firestore.app.name}:${ref.path}:idField=${JSON.stringify(idField)}`; - const observable = docData(ref, { idField }); + const observable = docData(ref, { idField: idField as keyof T }); return useObservable(observableId, observable, options) as ObservableStatus; } @@ -75,7 +75,7 @@ export function useFirestoreDocDataOnce(ref: DocumentReference, const idField = options ? checkIdField(options) : undefined; const observableId = `firestore:docDataOnce:${ref.firestore.app.name}:${ref.path}:idField=${JSON.stringify(idField)}`; - const observable$ = docData(ref, { idField }).pipe(first()); + const observable$ = docData(ref, { idField: idField as keyof T }).pipe(first()); return useObservable(observableId, observable$, options) as ObservableStatus; } @@ -83,7 +83,7 @@ export function useFirestoreDocDataOnce(ref: DocumentReference, /** * Subscribe to a Firestore collection */ -export function useFirestoreCollection(query: FirestoreQuery, options?: ReactFireOptions): ObservableStatus> { +export function useFirestoreCollection(query: FirestoreQuery, options?: ReactFireOptions>): ObservableStatus> { const observableId = `firestore:collection:${getUniqueIdForFirestoreQuery(query)}`; const observable$ = fromRef(query); @@ -96,7 +96,7 @@ export function useFirestoreCollection(query: FirestoreQuery(query: FirestoreQuery, options?: ReactFireOptions): ObservableStatus { const idField = options ? checkIdField(options) : undefined; const observableId = `firestore:collectionData:${getUniqueIdForFirestoreQuery(query)}:idField=${JSON.stringify(idField)}`; - const observable$ = collectionData(query, { idField }); + const observable$ = collectionData(query, { idField: idField as (string & keyof T) }); return useObservable(observableId, observable$, options); } diff --git a/src/index.ts b/src/index.ts index ab60a1fe..bbff220b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,14 +23,17 @@ export class ReactFireError extends Error { export interface ReactFireOptions { idField?: string; - initialData?: T | any; + initialData?: T; /** * @deprecated use initialData instead */ - startWithValue?: T | any; + startWithValue?: T; suspense?: boolean; } +// Deprecated: unused internally as of the ReactFireOptions generic tightening. +// Removal is tracked in #754 alongside the other deprecated exports, so it +// lands as its own change rather than inside this one. export function checkOptions(options: ReactFireOptions, field: string) { // make sure the field passed in is a valid key of ReactFire Options if (field === 'idField' || field === 'initialData' || field === 'suspense') { @@ -44,8 +47,8 @@ export function checkinitialData(options: ReactFireOptions) { return checkOptions(options, 'initialData'); } -export function checkIdField(options: ReactFireOptions) { - return checkOptions(options, 'idField'); +export function checkIdField(options: ReactFireOptions): string | undefined { + return options?.idField; } export * from './auth'; diff --git a/src/reactfire-options.type-test.ts b/src/reactfire-options.type-test.ts new file mode 100644 index 00000000..d5e0e6ab --- /dev/null +++ b/src/reactfire-options.type-test.ts @@ -0,0 +1,46 @@ +/** + * Type-level regression tests for ReactFireOptions generic constraints. + * Checked by `tsc --noEmit` in CI. No runtime behavior — not bundled. + */ +import type { ReactFireOptions } from './index'; + +// ---- initialData must match T ---- + +void ((): ReactFireOptions => ({ initialData: 'hello' }))(); +void ((): ReactFireOptions => ({ initialData: 42 }))(); + +// @ts-expect-error initialData must be T, not a different type +const _wrongInitialData: ReactFireOptions = { initialData: 123 }; +void _wrongInitialData; + +// @ts-expect-error startWithValue must be T, not a different type +const _wrongStartWithValue: ReactFireOptions = { startWithValue: 123 }; +void _wrongStartWithValue; + +// ---- snapshot hooks take the snapshot, data hooks take the data (#741) ---- +// +// Removing `T | any` also removed what was masking a mismatch: the raw snapshot +// hooks resolve to a DocumentSnapshot/QuerySnapshot, so that is what a correct +// initialData is. Without the hook-level fix, seeding them with a snapshot (the +// only value you can actually have) stops compiling. + +import type { DocumentReference, DocumentSnapshot, Query, QuerySnapshot } from 'firebase/firestore'; +import type { useFirestoreCollection, useFirestoreDoc, useFirestoreDocData } from './firestore'; + +declare const ref: DocumentReference<{ a: string }>; +declare const query: Query<{ a: string }>; +declare const snap: DocumentSnapshot<{ a: string }>; +declare const querySnap: QuerySnapshot<{ a: string }>; +declare const docHook: typeof useFirestoreDoc; +declare const collectionHook: typeof useFirestoreCollection; +declare const dataHook: typeof useFirestoreDocData; + +void (() => docHook(ref, { initialData: snap })); +void (() => collectionHook(query, { initialData: querySnap })); +void (() => dataHook(ref, { initialData: { a: 'x' } })); + +// @ts-expect-error a snapshot hook must not accept the unwrapped data type +void (() => docHook(ref, { initialData: { a: 'x' } })); + +// @ts-expect-error a data hook must not accept a snapshot +void (() => dataHook(ref, { initialData: snap }));