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..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:84](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L84) +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 0a8f8e45..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:82](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L82) +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 fd9a7056..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:83](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L83) +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 1efac90b..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:85](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L85) +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 648d679b..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:86](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L86) +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 bd5a2ade..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:87](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L87) +Defined in: [src/sdk.tsx:113](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L113) ## Returns diff --git a/docs/reference/functions/useObservable.md b/docs/reference/functions/useObservable.md index fd5579c0..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:68](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L68) +Defined in: [src/useObservable.ts:82](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L82) ## Type Parameters diff --git a/docs/reference/functions/usePerformance.md b/docs/reference/functions/usePerformance.md index 0ff1b523..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:88](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L88) +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 78b14879..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:90](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L90) +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 74d28fe2..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:89](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L89) +Defined in: [src/sdk.tsx:115](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L115) ## Returns diff --git a/docs/reference/interfaces/ObservableStatus.md b/docs/reference/interfaces/ObservableStatus.md index 6b916f26..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:31](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L31) +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: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: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:61](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L61) +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:65](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L65) +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:47](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L47) +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:51](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L51) +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:41](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L41) +Defined in: [src/useObservable.ts:55](https://github.com/FirebaseExtended/reactfire/blob/main/src/useObservable.ts#L55) 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..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:74](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L74) +Defined in: [src/sdk.tsx:100](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L100) ## 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..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:72](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L72) +Defined in: [src/sdk.tsx:98](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L98) ## 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..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:73](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L73) +Defined in: [src/sdk.tsx:99](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L99) ## 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..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:75](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L75) +Defined in: [src/sdk.tsx:101](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L101) ## 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..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:76](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L76) +Defined in: [src/sdk.tsx:102](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L102) ## 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..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:77](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L77) +Defined in: [src/sdk.tsx:103](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L103) ## 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..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:78](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L78) +Defined in: [src/sdk.tsx:104](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L104) ## 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..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:80](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L80) +Defined in: [src/sdk.tsx:106](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L106) ## 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..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:79](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L79) +Defined in: [src/sdk.tsx:105](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L105) ## 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..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:99](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L99) +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 810fc3f0..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:97](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L97) +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 bd29275b..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:98](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L98) +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 4bb0de18..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:100](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L100) +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 5ef24c7e..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:101](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L101) +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 63c7eed4..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:102](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L102) +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 150cdfdd..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:103](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L103) +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 f9871886..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:105](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L105) +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 a38823da..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:107](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L107) +Defined in: [src/sdk.tsx:133](https://github.com/FirebaseExtended/reactfire/blob/main/src/sdk.tsx#L133) diff --git a/src/SuspenseSubject.ts b/src/SuspenseSubject.ts index 5b7ef884..4514a586 100644 --- a/src/SuspenseSubject.ts +++ b/src/SuspenseSubject.ts @@ -1,24 +1,35 @@ -import { empty, Observable, Subject, Subscriber, Subscription } from 'rxjs'; -import { catchError, shareReplay, 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. +// A 1-second window lets React StrictMode double-mounts and immediate route transitions reuse the active listener. +const LISTENER_RELEASE_DELAY_MS = 1000; + +// 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 { private _value: T | undefined; private _hasValue = false; - private _timeoutHandler: ReturnType; + private _warmupRelease: Subscription; private _firstEmission: Promise; private _error: any = undefined; private _innerObservable: Observable; 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; - // @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) { + constructor( + innerObservable: Observable, + private _timeoutWindow: number, + private _suspenseEnabled: boolean, + private _onEvict: () => void, + private _onReregister: () => void + ) { super(); this._firstEmission = new Promise((resolve) => (this._resolveFirstEmission = resolve)); @@ -31,37 +42,69 @@ export class SuspenseSubject extends Subject { firstValuePromise: this._firstEmission } as ObservableStatus; - this._innerObservable = 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. + // #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) { + // 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: () => { this._isComplete = true; this._updateImmutableStatus(); } }), - catchError(() => empty()), - shareReplay(1) + catchError(() => EMPTY), + // `resetOnComplete: false` keeps a completed one-shot (`*Once`) value cached; resetting would make a mounted component re-fetch. + share({ + connector: () => new ReplaySubject(1), + resetOnComplete: false, + 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(); - // 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 `_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(() => { + this._warmupSubscription.unsubscribe(); + if (this._isComplete) { + this._onEvict(); + } + }); + } + + // 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 { @@ -72,9 +115,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) { @@ -88,9 +128,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, @@ -108,24 +146,35 @@ 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); + // 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(); } - this._innerSubscriber = this._innerObservable.subscribe(subscriber); - return this._innerSubscriber; + this._subscriberCount++; + const innerSubscription = this._innerObservable.subscribe({ + next: (value) => subscriber.next(value), + complete: () => { + 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); + } else { + subscriber.complete(); + } + }, + }); + // Hand off ownership of the shared source from the warm-up subscription to the real subscriber. + this._warmupSubscription.unsubscribe(); + return new Subscription(() => { + innerSubscription.unsubscribe(); + this._subscriberCount--; + if (this._subscriberCount === 0 && this._error) { + this._onEvict(); + } + }); } get ourError() { 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..b4ee5470 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,27 @@ 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)) { + 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/src/useObservable.ts b/src/useObservable.ts index 3d055c4a..42575419 100644 --- a/src/useObservable.ts +++ b/src/useObservable.ts @@ -20,9 +20,23 @@ 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 = new SuspenseSubject(source, DEFAULT_TIMEOUT, suspenseEnabled); + 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; } 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); + } +}); diff --git a/test/sdk-init-cache.test.tsx b/test/sdk-init-cache.test.tsx new file mode 100644 index 00000000..fc402d96 --- /dev/null +++ b/test/sdk-init-cache.test.tsx @@ -0,0 +1,121 @@ +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); + + 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); +}); diff --git a/test/suspenseSubject-error-branch.test.tsx b/test/suspenseSubject-error-branch.test.tsx new file mode 100644 index 00000000..1119c44f --- /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((resolve) => setTimeout(resolve, 1500)); + expect(subscriptions).toBe(1); + } finally { + consoleError.mockRestore(); + } + }, 10000); +}); diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index 0ba963a7..a8f7da7f 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, Observable, 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'): { @@ -483,4 +494,361 @@ 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 Obs = () => { + const source$ = shouldFail ? throwError(() => new Error('permission-denied')) : of('recovered'); + 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((resolve) => setTimeout(resolve, 1100)); + shouldFail = false; + + const second = render( + + + + ); + + 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, and remount immediately without waiting 1s. + first.unmount(); + act(() => { + firstStream$.error(new Error('permission-denied-after-unmount')); + }); + + currentStream$ = secondStream$; + + 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('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$ = defer(() => { + subscribeCount++; + return base$; + }); + + 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((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((resolve) => setTimeout(resolve, 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 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). + 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((resolve) => setTimeout(resolve, 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((resolve) => setTimeout(resolve, 1100)); + + // The identity guard in onEvict must keep secondSubject in the cache. + expect(preloadObservable(new BehaviorSubject('third'), observableId)).toBe(secondSubject); + 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(); + + 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(); + }); + }); });