diff --git a/docs/reference/functions/AuthCheck.md b/docs/reference/functions/AuthCheck.md index 06738074..dfb0e3ea 100644 --- a/docs/reference/functions/AuthCheck.md +++ b/docs/reference/functions/AuthCheck.md @@ -8,7 +8,7 @@ > **AuthCheck**(`__namedParameters`): `ReactElement` -Defined in: [src/auth.tsx:257](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L257) +Defined in: [src/auth.tsx:259](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L259) ## Parameters diff --git a/docs/reference/functions/ClaimsCheck.md b/docs/reference/functions/ClaimsCheck.md index 5138d974..fa5ca95e 100644 --- a/docs/reference/functions/ClaimsCheck.md +++ b/docs/reference/functions/ClaimsCheck.md @@ -8,7 +8,7 @@ > **ClaimsCheck**(`__namedParameters`): `Element` -Defined in: [src/auth.tsx:213](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L213) +Defined in: [src/auth.tsx:215](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L215) ## Parameters diff --git a/docs/reference/functions/StorageImage.md b/docs/reference/functions/StorageImage.md index 24370e02..59c9a60b 100644 --- a/docs/reference/functions/StorageImage.md +++ b/docs/reference/functions/StorageImage.md @@ -8,7 +8,7 @@ > **StorageImage**(`props`): `Element` -Defined in: [src/storage.tsx:78](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L78) +Defined in: [src/storage.tsx:82](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L82) ## Parameters diff --git a/docs/reference/functions/useCallableFunctionResponse.md b/docs/reference/functions/useCallableFunctionResponse.md index ddd4987c..3ea2b8d8 100644 --- a/docs/reference/functions/useCallableFunctionResponse.md +++ b/docs/reference/functions/useCallableFunctionResponse.md @@ -8,7 +8,7 @@ > **useCallableFunctionResponse**\<`RequestData`, `ResponseData`\>(`functionName`, `options?`): [`ObservableStatus`](../interfaces/ObservableStatus.md)\<`ResponseData`\> -Defined in: [src/functions.tsx:13](https://github.com/FirebaseExtended/reactfire/blob/main/src/functions.tsx#L13) +Defined in: [src/functions.tsx:14](https://github.com/FirebaseExtended/reactfire/blob/main/src/functions.tsx#L14) Calls a callable function. diff --git a/docs/reference/functions/useSigninCheck.md b/docs/reference/functions/useSigninCheck.md index 32e3e619..895405bc 100644 --- a/docs/reference/functions/useSigninCheck.md +++ b/docs/reference/functions/useSigninCheck.md @@ -8,7 +8,7 @@ > **useSigninCheck**(`options?`): [`ObservableStatus`](../interfaces/ObservableStatus.md)\<[`SigninCheckResult`](../type-aliases/SigninCheckResult.md)\> -Defined in: [src/auth.tsx:134](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L134) +Defined in: [src/auth.tsx:136](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L136) Subscribe to the signed-in status of a user. diff --git a/docs/reference/functions/useStorageDownloadURL.md b/docs/reference/functions/useStorageDownloadURL.md index 898c4931..ced03bbf 100644 --- a/docs/reference/functions/useStorageDownloadURL.md +++ b/docs/reference/functions/useStorageDownloadURL.md @@ -8,7 +8,7 @@ > **useStorageDownloadURL**\<`T`\>(`ref`, `options?`): [`ObservableStatus`](../interfaces/ObservableStatus.md)\<`string` \| `T`\> -Defined in: [src/storage.tsx:29](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L29) +Defined in: [src/storage.tsx:30](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L30) Subscribe to a storage ref's download URL diff --git a/docs/reference/functions/useStorageTask.md b/docs/reference/functions/useStorageTask.md index ccda3ddf..f6cbc13d 100644 --- a/docs/reference/functions/useStorageTask.md +++ b/docs/reference/functions/useStorageTask.md @@ -8,7 +8,7 @@ > **useStorageTask**\<`T`\>(`task`, `ref`, `options?`): [`ObservableStatus`](../interfaces/ObservableStatus.md)\<`T` \| `UploadTaskSnapshot`\> -Defined in: [src/storage.tsx:16](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L16) +Defined in: [src/storage.tsx:17](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L17) Subscribe to the progress of a storage task diff --git a/docs/reference/interfaces/AuthCheckProps.md b/docs/reference/interfaces/AuthCheckProps.md index 37e7b895..c5edcb62 100644 --- a/docs/reference/interfaces/AuthCheckProps.md +++ b/docs/reference/interfaces/AuthCheckProps.md @@ -6,7 +6,7 @@ # Interface: AuthCheckProps -Defined in: [src/auth.tsx:52](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L52) +Defined in: [src/auth.tsx:54](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L54) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/auth.tsx:52](https://github.com/FirebaseExtended/reactfire/blob > **children**: `ReactNode` -Defined in: [src/auth.tsx:54](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L54) +Defined in: [src/auth.tsx:56](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L56) *** @@ -22,7 +22,7 @@ Defined in: [src/auth.tsx:54](https://github.com/FirebaseExtended/reactfire/blob > **fallback**: `ReactNode` -Defined in: [src/auth.tsx:53](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L53) +Defined in: [src/auth.tsx:55](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L55) *** @@ -30,4 +30,4 @@ Defined in: [src/auth.tsx:53](https://github.com/FirebaseExtended/reactfire/blob > `optional` **requiredClaims?**: `Object` -Defined in: [src/auth.tsx:55](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L55) +Defined in: [src/auth.tsx:57](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L57) diff --git a/docs/reference/interfaces/ClaimCheckErrors.md b/docs/reference/interfaces/ClaimCheckErrors.md index 470ca5ab..ce0afa98 100644 --- a/docs/reference/interfaces/ClaimCheckErrors.md +++ b/docs/reference/interfaces/ClaimCheckErrors.md @@ -6,7 +6,7 @@ # Interface: ClaimCheckErrors -Defined in: [src/auth.tsx:65](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L65) +Defined in: [src/auth.tsx:67](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L67) ## Indexable diff --git a/docs/reference/interfaces/ClaimsCheckProps.md b/docs/reference/interfaces/ClaimsCheckProps.md index 4d39e79b..34375923 100644 --- a/docs/reference/interfaces/ClaimsCheckProps.md +++ b/docs/reference/interfaces/ClaimsCheckProps.md @@ -6,7 +6,7 @@ # Interface: ClaimsCheckProps -Defined in: [src/auth.tsx:58](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L58) +Defined in: [src/auth.tsx:60](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L60) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/auth.tsx:58](https://github.com/FirebaseExtended/reactfire/blob > **children**: `ReactNode` -Defined in: [src/auth.tsx:61](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L61) +Defined in: [src/auth.tsx:63](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L63) *** @@ -22,7 +22,7 @@ Defined in: [src/auth.tsx:61](https://github.com/FirebaseExtended/reactfire/blob > **fallback**: `ReactNode` -Defined in: [src/auth.tsx:60](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L60) +Defined in: [src/auth.tsx:62](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L62) *** @@ -30,7 +30,7 @@ Defined in: [src/auth.tsx:60](https://github.com/FirebaseExtended/reactfire/blob > **requiredClaims**: `object` -Defined in: [src/auth.tsx:62](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L62) +Defined in: [src/auth.tsx:64](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L64) #### Index Signature @@ -42,4 +42,4 @@ Defined in: [src/auth.tsx:62](https://github.com/FirebaseExtended/reactfire/blob > **user**: `User` -Defined in: [src/auth.tsx:59](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L59) +Defined in: [src/auth.tsx:61](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L61) diff --git a/docs/reference/interfaces/ClaimsValidator.md b/docs/reference/interfaces/ClaimsValidator.md index feb558fd..436ddd45 100644 --- a/docs/reference/interfaces/ClaimsValidator.md +++ b/docs/reference/interfaces/ClaimsValidator.md @@ -6,11 +6,11 @@ # Interface: ClaimsValidator() -Defined in: [src/auth.tsx:91](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L91) +Defined in: [src/auth.tsx:93](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L93) > **ClaimsValidator**(`claims`): `object` -Defined in: [src/auth.tsx:92](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L92) +Defined in: [src/auth.tsx:94](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L94) ## Parameters diff --git a/docs/reference/interfaces/SignInCheckOptionsBasic.md b/docs/reference/interfaces/SignInCheckOptionsBasic.md index 6c9f2d66..66967bf8 100644 --- a/docs/reference/interfaces/SignInCheckOptionsBasic.md +++ b/docs/reference/interfaces/SignInCheckOptionsBasic.md @@ -6,7 +6,7 @@ # Interface: SignInCheckOptionsBasic -Defined in: [src/auth.tsx:83](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L83) +Defined in: [src/auth.tsx:85](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L85) ## Extends @@ -23,7 +23,7 @@ Defined in: [src/auth.tsx:83](https://github.com/FirebaseExtended/reactfire/blob > `optional` **forceRefresh?**: `boolean` -Defined in: [src/auth.tsx:84](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L84) +Defined in: [src/auth.tsx:86](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L86) *** diff --git a/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md b/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md index 092cde82..09a1eae0 100644 --- a/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md +++ b/docs/reference/interfaces/SignInCheckOptionsClaimsObject.md @@ -6,7 +6,7 @@ # Interface: SignInCheckOptionsClaimsObject -Defined in: [src/auth.tsx:87](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L87) +Defined in: [src/auth.tsx:89](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L89) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/auth.tsx:87](https://github.com/FirebaseExtended/reactfire/blob > `optional` **forceRefresh?**: `boolean` -Defined in: [src/auth.tsx:84](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L84) +Defined in: [src/auth.tsx:86](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L86) #### Inherited from @@ -54,7 +54,7 @@ Defined in: [src/index.ts:26](https://github.com/FirebaseExtended/reactfire/blob > **requiredClaims**: `ParsedToken` -Defined in: [src/auth.tsx:88](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L88) +Defined in: [src/auth.tsx:90](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L90) *** diff --git a/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md b/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md index 83517d7a..5ba7f609 100644 --- a/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md +++ b/docs/reference/interfaces/SignInCheckOptionsClaimsValidator.md @@ -6,7 +6,7 @@ # Interface: SignInCheckOptionsClaimsValidator -Defined in: [src/auth.tsx:98](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L98) +Defined in: [src/auth.tsx:100](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L100) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/auth.tsx:98](https://github.com/FirebaseExtended/reactfire/blob > `optional` **forceRefresh?**: `boolean` -Defined in: [src/auth.tsx:84](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L84) +Defined in: [src/auth.tsx:86](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L86) #### Inherited from @@ -82,4 +82,4 @@ Defined in: [src/index.ts:31](https://github.com/FirebaseExtended/reactfire/blob > **validateCustomClaims**: [`ClaimsValidator`](ClaimsValidator.md) -Defined in: [src/auth.tsx:99](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L99) +Defined in: [src/auth.tsx:101](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L101) diff --git a/docs/reference/type-aliases/SigninCheckResult.md b/docs/reference/type-aliases/SigninCheckResult.md index bb80be74..f6f71bd1 100644 --- a/docs/reference/type-aliases/SigninCheckResult.md +++ b/docs/reference/type-aliases/SigninCheckResult.md @@ -8,4 +8,4 @@ > **SigninCheckResult** = \{ `errors`: \{ \}; `hasRequiredClaims`: `false`; `signedIn`: `false`; `user`: `null`; \} \| \{ `errors`: [`ClaimCheckErrors`](../interfaces/ClaimCheckErrors.md); `hasRequiredClaims`: `boolean`; `signedIn`: `true`; `user`: `User`; \} -Defined in: [src/auth.tsx:69](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L69) +Defined in: [src/auth.tsx:71](https://github.com/FirebaseExtended/reactfire/blob/main/src/auth.tsx#L71) diff --git a/docs/reference/type-aliases/StorageImageProps.md b/docs/reference/type-aliases/StorageImageProps.md index b75fa337..f4dcf98f 100644 --- a/docs/reference/type-aliases/StorageImageProps.md +++ b/docs/reference/type-aliases/StorageImageProps.md @@ -8,7 +8,7 @@ > **StorageImageProps** = `object` -Defined in: [src/storage.tsx:36](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L36) +Defined in: [src/storage.tsx:40](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L40) ## Properties @@ -16,7 +16,7 @@ Defined in: [src/storage.tsx:36](https://github.com/FirebaseExtended/reactfire/b > `optional` **placeHolder?**: `React.ReactNode` -Defined in: [src/storage.tsx:40](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L40) +Defined in: [src/storage.tsx:44](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L44) *** @@ -24,7 +24,7 @@ Defined in: [src/storage.tsx:40](https://github.com/FirebaseExtended/reactfire/b > `optional` **storage?**: `FirebaseStorage` -Defined in: [src/storage.tsx:38](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L38) +Defined in: [src/storage.tsx:42](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L42) *** @@ -32,7 +32,7 @@ Defined in: [src/storage.tsx:38](https://github.com/FirebaseExtended/reactfire/b > **storagePath**: `string` -Defined in: [src/storage.tsx:37](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L37) +Defined in: [src/storage.tsx:41](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L41) *** @@ -40,4 +40,4 @@ Defined in: [src/storage.tsx:37](https://github.com/FirebaseExtended/reactfire/b > `optional` **suspense?**: `boolean` -Defined in: [src/storage.tsx:39](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L39) +Defined in: [src/storage.tsx:43](https://github.com/FirebaseExtended/reactfire/blob/main/src/storage.tsx#L43) diff --git a/src/auth.tsx b/src/auth.tsx index 1bebf526..9fba1a46 100644 --- a/src/auth.tsx +++ b/src/auth.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { user } from 'rxfire/auth'; import { preloadObservable, ReactFireOptions, useAuth, useObservable, ObservableStatus, ReactFireError } from './'; -import { from, of } from 'rxjs'; +import { from, of, defer } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { useSuspenseEnabledFromConfigAndContext } from './firebaseApp'; @@ -44,7 +44,9 @@ export function useIdTokenResult(user: User, forceRefresh = false, options?: Rea } const observableId = `auth:idTokenResult:${user.uid}:forceRefresh=${forceRefresh}`; - const observable$ = from(user.getIdTokenResult(forceRefresh)); + // Wrap in `defer` so the token fetch runs lazily on subscription rather than + // eagerly on every render. + const observable$ = defer(() => from(user.getIdTokenResult(forceRefresh))); return useObservable(observableId, observable$, options); } diff --git a/src/functions.tsx b/src/functions.tsx index 501822b1..e9f1f34b 100644 --- a/src/functions.tsx +++ b/src/functions.tsx @@ -1,4 +1,5 @@ import { httpsCallable as rxHttpsCallable } from 'rxfire/functions'; +import { defer } from 'rxjs'; import { ReactFireOptions, useObservable, ObservableStatus } from './'; import { useFunctions } from '.'; @@ -20,7 +21,9 @@ export function useCallableFunctionResponse( const functions = useFunctions(); const observableId = `functions:callableResponse:${functionName}:${JSON.stringify(options?.data)}:${JSON.stringify(options?.httpsCallableOptions)}`; const obsFactory = rxHttpsCallable(functions, functionName, options?.httpsCallableOptions); - const observable$ = obsFactory(options?.data); + // Wrap in `defer` so the function is invoked lazily on subscription rather than + // on every render (each render otherwise calls the cloud function and discards it). + const observable$ = defer(() => obsFactory(options?.data)); return useObservable(observableId, observable$, options); } diff --git a/src/storage.tsx b/src/storage.tsx index 5a0299da..92f27a51 100644 --- a/src/storage.tsx +++ b/src/storage.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { getDownloadURL, fromTask } from 'rxfire/storage'; +import { defer } from 'rxjs'; import { ReactFireOptions, useObservable, ObservableStatus, useStorage } from './'; import { useSuspenseEnabledFromConfigAndContext } from './firebaseApp'; import { ref } from 'firebase/storage'; @@ -28,7 +29,10 @@ export function useStorageTask(task: UploadTask, ref: StorageRefere */ export function useStorageDownloadURL(ref: StorageReference, options?: ReactFireOptions): ObservableStatus { const observableId = `storage:downloadUrl:${ref.toString()}`; - const observable$ = getDownloadURL(ref); + // Wrap in `defer` so the download URL request is created lazily on subscription + // rather than eagerly on every render (which fires a discarded request each time, + // and an unhandled rejection when the object is missing). + const observable$ = defer(() => getDownloadURL(ref)); return useObservable(observableId, observable$, options); } diff --git a/test/auth.test.tsx b/test/auth.test.tsx index 3a66784f..647ef683 100644 --- a/test/auth.test.tsx +++ b/test/auth.test.tsx @@ -9,6 +9,7 @@ import { AuthProvider, useUser, useSigninCheck, + useIdTokenResult, ClaimCheckErrors, ClaimsValidator, } from '../src/index'; @@ -16,7 +17,7 @@ import { baseConfig } from './appConfig'; import { FirebaseApp, initializeApp } from 'firebase/app'; import { randomString } from './test-utils'; -import { getAuth, GoogleAuthProvider, signInWithCredential, signInWithCustomToken, signOut, connectAuthEmulator, UserCredential } from 'firebase/auth'; +import { getAuth, GoogleAuthProvider, signInWithCredential, signInWithCustomToken, signOut, connectAuthEmulator, UserCredential, User } from 'firebase/auth'; describe('Authentication', () => { let app: FirebaseApp; @@ -388,4 +389,22 @@ describe('Authentication', () => { await findByTestId('signed-out'); }); }); + + describe('useIdTokenResult', () => { + it('defers the token fetch so it does not re-run on every render', async () => { + const getIdTokenResult = vi.fn().mockResolvedValue({ claims: {} }); + const fakeUser = { uid: randomString(), getIdTokenResult } as unknown as User; + + const { result, rerender } = renderHook(() => useIdTokenResult(fakeUser, false)); + + await waitFor(() => expect(result.current.status).toEqual('success')); + + // The request is created lazily inside `defer` and useObservable subscribes + // once, so re-rendering must not trigger additional token fetches. + rerender(); + rerender(); + + expect(getIdTokenResult).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/test/functions.test.tsx b/test/functions.test.tsx index 10ef5a23..41f4b288 100644 --- a/test/functions.test.tsx +++ b/test/functions.test.tsx @@ -7,6 +7,17 @@ import { renderHook, waitFor } from '@testing-library/react'; import { randomString } from './test-utils'; import * as React from 'react'; +// `functions.tsx` calls `httpsCallable` (via rxfire) to create the callable used inside +// `defer`. Wrapping it in `vi.fn` (calling through to the real implementation by default) +// lets the regression test below count invocations without touching the emulator. +vi.mock('firebase/functions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + httpsCallable: vi.fn(actual.httpsCallable), + }; +}); + describe('Functions', () => { const app = initializeApp(baseConfig); const functions = getFunctions(app); @@ -46,5 +57,35 @@ describe('Functions', () => { await waitFor(() => expect(result.current.status).toEqual('success')); expect(result.current.data).toEqual(testText.toUpperCase()); }); + + it('defers the function call so it does not re-invoke on every render', async () => { + const testText = randomString(); + const mockedHttpsCallable = vi.mocked(httpsCallable); + const originalImpl = mockedHttpsCallable.getMockImplementation()! as typeof httpsCallable; + + // Wrap the real callable so we can count invocations of the cloud function + // itself, not just how many times `httpsCallable` is called to create it. + const realCallable = originalImpl<{ text: string }, string>(functions, 'capitalizeText'); + const callSpy = vi.fn((data?: { text: string } | null) => realCallable(data)); + mockedHttpsCallable.mockImplementation(() => callSpy as unknown as ReturnType); + + try { + const { result, rerender } = renderHook( + () => useCallableFunctionResponse<{ text: string }, string>('capitalizeText', { data: { text: testText } }), + { wrapper: Provider } + ); + + await waitFor(() => expect(result.current.status).toEqual('success')); + + // The request is created lazily inside `defer` and useObservable subscribes + // once, so re-rendering must not trigger additional cloud function calls. + rerender(); + rerender(); + + expect(callSpy).toHaveBeenCalledTimes(1); + } finally { + mockedHttpsCallable.mockImplementation(originalImpl); + } + }); }); });