diff --git a/README.md b/README.md index 2d7ae5eb..acf4d34f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ This library consists of 6 modules with many hooks: All hooks can be imported from `react-firehooks` directly or via `react-firehooks/` to improve tree-shaking and bundle size. +All hooks suffixed with `Once` can be used in [React suspense-mode](docs/react-suspense.md). + ## Development ### Build diff --git a/docs/database.md b/docs/database.md index 4bcc24a6..4332b369 100644 --- a/docs/database.md +++ b/docs/database.md @@ -27,12 +27,14 @@ Returns: Returns the DataSnapshot of the Realtime Database query. Does not update the DataSnapshot once initially fetched ```javascript -const [dataSnap, loading, error] = useObjectOnce(query); +const [dataSnap, loading, error] = useObjectOnce(query, options); ``` Params: - `query`: Realtime Database query +- `options`: Options to configure how the object is fetched + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -73,6 +75,7 @@ Params: - `query`: Realtime Database query - `options`: Options to configure how the object is fetched - `converter`: Function to extract the desired data from the DataSnapshot. Similar to Firestore converters. Default: `snap.val()`. + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: diff --git a/docs/firestore.md b/docs/firestore.md index c8087342..3bfc49fd 100644 --- a/docs/firestore.md +++ b/docs/firestore.md @@ -9,12 +9,14 @@ import { ... } from 'react-firehooks/firestore'; Returns the number of documents in the result set of of a Firestore Query. Does not update the count once initially calculated. ```javascript -const [count, loading, error] = useCountFromServer(query); +const [count, loading, error] = useCountFromServer(query, options); ``` Params: - `query`: Firestore query whose result set size is calculated +- `options`: Options to configure how the number of documents is fetched + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -72,6 +74,9 @@ Params: - `documentReference`: Firestore DocumentReference that will be fetched - `options`: Options to configure the document will be fetched + - `source`: Firestore source to fetch the document from. Default: `default`. [Read more](https://firebase.google.com/docs/firestore/query-data/get-data#source_options) + - `snapshotOptions`: Options to configure the snapshot. [Read more](https://firebase.google.com/docs/reference/js/firestore_.snapshotoptions) + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -90,7 +95,9 @@ const [querySnap, loading, error] = useDocumentData(documentReference, options); Params: - `documentReference`: Firestore DocumentReference that will be fetched -- `options`: Options to configure how the document will be fetched +- `options`: Options to configure the document will be fetched + - `source`: Firestore source to fetch the document from. Default: `default`. [Read more](https://firebase.google.com/docs/firestore/query-data/get-data#source_options) + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -188,6 +195,9 @@ Params: - `query`: Firestore query that will be fetched - `options`: Options to configure how the query is fetched + - `source`: Firestore source to fetch the document from. Default: `default`. [Read more](https://firebase.google.com/docs/firestore/query-data/get-data#source_options) + - `snapshotOptions`: Options to configure the snapshot. [Read more](https://firebase.google.com/docs/reference/js/firestore_.snapshotoptions) + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -207,6 +217,8 @@ Params: - `query`: Firestore query that will be fetched - `options`: Options to configure how the query is fetched + - `source`: Firestore source to fetch the document from. Default: `default`. [Read more](https://firebase.google.com/docs/firestore/query-data/get-data#source_options) + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: diff --git a/docs/message.md b/docs/message.md index 38899d1c..65260067 100644 --- a/docs/message.md +++ b/docs/message.md @@ -16,6 +16,8 @@ Params: - `messaging`: Firestore Messaging instance - `options`: Options to configure how the token will be fetched + - `getTokenOptions`: Options to configure how the token will be fetched. [Read more](https://firebase.google.com/docs/reference/js/messaging_.gettokenoptions) + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: diff --git a/docs/react-suspense.md b/docs/react-suspense.md new file mode 100644 index 00000000..476ed167 --- /dev/null +++ b/docs/react-suspense.md @@ -0,0 +1,18 @@ +# React Suspense + +Hooks suffixed with `Once` can be used in React `suspense`-mode by passing `suspense: true` in the options object. When using suspense-mode, the component must be wrapped in a ``. The second (`loading`) and third (`error`) item in the returned tuple are static and cannot be used for loading state or error handling. Errors must be handled by a wrapping [error boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary). + +```jsx +function App() { + return ( + Loading...}> + + + ); +} + +function MyComponent() { + const [todos] = useQueryDataOnce(collection("todos", firestore), { suspense: true }); + return <>{JSON.stringify(todos)}; +} +``` diff --git a/docs/storage.md b/docs/storage.md index dcc92d09..0efccf46 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -17,7 +17,9 @@ const [data, loading, error] = useBlob(storageReference); Params: - `reference`: Reference to a Google Cloud Storage object -- `maxDownloadSizeBytes`: If set, the maximum allowed size in bytes to retrieve. +- `options`: Options to configure how the object is fetched + - `maxDownloadSizeBytes`: If set, the maximum allowed size in bytes to retrieve. + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -36,7 +38,9 @@ const [data, loading, error] = useBytes(storageReference); Params: - `reference`: Reference to a Google Cloud Storage object -- `maxDownloadSizeBytes`: If set, the maximum allowed size in bytes to retrieve. +- `options`: Options to configure how the object is fetched + - `maxDownloadSizeBytes`: If set, the maximum allowed size in bytes to retrieve. + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -55,6 +59,8 @@ const [url, loading, error] = useDownloadURL(storageReference); Params: - `reference`: Reference to a Google Cloud Storage object +- `options`: Options to configure how the download URL is fetched + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -73,6 +79,8 @@ const [metadata, loading, error] = useMetadata(storageReference); Params: - `reference`: Reference to a Google Cloud Storage object +- `options`: Options to configure how the metadata is fetched + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: @@ -93,7 +101,9 @@ const [data, loading, error] = useStream(storageReference); Params: - `reference`: Reference to a Google Cloud Storage object -- `maxDownloadSizeBytes`: If set, the maximum allowed size in bytes to retrieve. +- `options`: Options to configure how the object is fetched + - `maxDownloadSizeBytes`: If set, the maximum allowed size in bytes to retrieve. + - `suspense`: Whether to use React suspense-mode. Default: `false`. [Read more](docs/react-suspense.md) Returns: diff --git a/src/database/useObjectOnce.ts b/src/database/useObjectOnce.ts index 7490d538..eae3f022 100644 --- a/src/database/useObjectOnce.ts +++ b/src/database/useObjectOnce.ts @@ -6,15 +6,27 @@ import { isQueryEqual } from "./internal.js"; export type UseObjectOnceResult = ValueHookResult; +/** + * Options to configure how the object is fetched + */ +export interface UseObjectOnceOptions { + /** + * @default false + */ + suspense?: boolean; +} + /** * Returns and updates the DataSnapshot of the Realtime Database query. Does not update the DataSnapshot once initially fetched * @param query Realtime Database query + * @param [options] Options to configure how the object is fetched * @returns User, loading state, and error * value: DataSnapshot; `undefined` if query is currently being fetched, or an error occurred * loading: `true` while fetching the query; `false` if the query was fetched successfully or an error occurred * error: `undefined` if no error occurred */ -export function useObjectOnce(query: Query | undefined | null): UseObjectOnceResult { +export function useObjectOnce(query: Query | undefined | null, options?: UseObjectOnceOptions): UseObjectOnceResult { + const { suspense = false } = options ?? {}; const getData = useCallback((stableQuery: Query) => get(stableQuery), []); - return useOnce(query ?? undefined, getData, isQueryEqual); + return useOnce(query ?? undefined, getData, isQueryEqual, suspense); } diff --git a/src/database/useObjectValueOnce.ts b/src/database/useObjectValueOnce.ts index 09d0de5c..e2b0ba90 100644 --- a/src/database/useObjectValueOnce.ts +++ b/src/database/useObjectValueOnce.ts @@ -8,16 +8,26 @@ export type UseObjectValueOnceResult = ValueHookResult = (snap: DataSnapshot) => Value; +/** + * Options to configure how the object is fetched + */ export interface UseObjectValueOnceOptions { + /** + * Function to extract the desired data from the DataSnapshot. Similar to Firestore converters. Default: `snap.val()`. + */ converter?: UseObjectValueOnceConverter; + + /** + * @default false + */ + suspense?: boolean; } /** * Returns the DataSnapshot of the Realtime Database query. Does not update the DataSnapshot once initially fetched * @template Value Type of the object value * @param query Realtime Database query - * @param options Options to configure how the object is fetched - * `converter`: Function to extract the desired data from the DataSnapshot. Similar to Firestore converters. Default: `snap.val()`. + * @param [options] Options to configure how the object is fetched * @returns User, loading state, and error * value: Object value; `undefined` if query is currently being fetched, or an error occurred * loading: `true` while fetching the query; `false` if the query was fetched successfully or an error occurred @@ -27,7 +37,7 @@ export function useObjectValueOnce( query: Query | undefined | null, options?: UseObjectValueOnceOptions, ): UseObjectValueOnceResult { - const { converter = (snap: DataSnapshot) => snap.val() } = options ?? {}; + const { converter = (snap: DataSnapshot) => snap.val(), suspense = false } = options ?? {}; const getData = useCallback(async (stableQuery: Query) => { const snap = await get(stableQuery); @@ -36,5 +46,5 @@ export function useObjectValueOnce( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return useOnce(query ?? undefined, getData, isQueryEqual); + return useOnce(query ?? undefined, getData, isQueryEqual, suspense); } diff --git a/src/firestore/useCountFromServer.ts b/src/firestore/useCountFromServer.ts index 900cf072..f69fb261 100644 --- a/src/firestore/useCountFromServer.ts +++ b/src/firestore/useCountFromServer.ts @@ -5,6 +5,16 @@ import { isQueryEqual } from "./internal.js"; export type UseCountFromServerResult = ValueHookResult; +/** + * Options to configure how the number of documents is fetched + */ +export interface UseCountFromServerOptions { + /** + * @default false + */ + suspense?: boolean; +} + // eslint-disable-next-line jsdoc/require-param, jsdoc/require-returns /** * @internal @@ -17,11 +27,16 @@ async function getData(stableQuery: Query): Promise { /** * Returns the number of documents in the result set of a Firestore Query. Does not update the count once initially calculated. * @param query Firestore query whose result set size is calculated + * @param [options] Options to configure how the number of documents is fetched * @returns Size of the result set, loading state, and error * value: Size of the result set; `undefined` if the result set size is currently being calculated, or an error occurred * loading: `true` while calculating the result size set; `false` if the result size set was calculated successfully or an error occurred * error: `undefined` if no error occurred */ -export function useCountFromServer(query: Query | undefined | null): UseCountFromServerResult { - return useOnce(query ?? undefined, getData, isQueryEqual); +export function useCountFromServer( + query: Query | undefined | null, + options?: UseCountFromServerOptions, +): UseCountFromServerResult { + const { suspense = false } = options ?? {}; + return useOnce(query ?? undefined, getData, isQueryEqual, suspense); } diff --git a/src/firestore/useDocumentDataOnce.ts b/src/firestore/useDocumentDataOnce.ts index 1a2f160f..d97ee4b2 100644 --- a/src/firestore/useDocumentDataOnce.ts +++ b/src/firestore/useDocumentDataOnce.ts @@ -11,25 +11,34 @@ export type UseDocumentDataOnceResult * Options to configure how the document is fetched */ export interface UseDocumentDataOnceOptions { + /** + * @default "default" + */ source?: Source; + snapshotOptions?: SnapshotOptions; + + /** + * @default false + */ + suspense?: boolean; } /** * Returns the data of a Firestore DocumentReference * @template Value Type of the document data * @param reference Firestore DocumentReference that will be subscribed to - * @param options Options to configure how the document is fetched + * @param [options] Options to configure how the document is fetched * @returns Document data, loading state, and error * value: Document data; `undefined` if document does not exist, is currently being fetched, or an error occurred - * loading: `true` while fetching the document; `false` if the document was fetched successfully or an error occurred - * error: `undefined` if no error occurred + * loading: `true` while fetching the document; `false` if the document was fetched successfully or an error occurred; Always `false` with `supsense=true` + * error: `undefined` if no error occurred; Always `undefined` with `supsense=true` */ export function useDocumentDataOnce( reference: DocumentReference | undefined | null, options?: UseDocumentDataOnceOptions, ): UseDocumentDataOnceResult { - const { source = "default", snapshotOptions } = options ?? {}; + const { source = "default", snapshotOptions, suspense = false } = options ?? {}; const getData = useCallback(async (stableRef: DocumentReference) => { const snap = await getDocFromSource(stableRef, source); @@ -37,5 +46,5 @@ export function useDocumentDataOnce( // TODO: add options as dependency // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return useOnce(reference ?? undefined, getData, isDocRefEqual); + return useOnce(reference ?? undefined, getData, isDocRefEqual, suspense); } diff --git a/src/firestore/useDocumentOnce.ts b/src/firestore/useDocumentOnce.ts index dc47c923..4c0f9323 100644 --- a/src/firestore/useDocumentOnce.ts +++ b/src/firestore/useDocumentOnce.ts @@ -14,24 +14,32 @@ export type UseDocumentOnceResult = V * Options to configure how the document is fetched */ export interface UseDocumentOnceOptions { + /** + * @default "default" + */ source?: Source; + + /** + * @default false + */ + suspense?: boolean; } /** * Returns the DocumentSnapshot of a Firestore DocumentReference. Does not update the DocumentSnapshot once initially fetched * @template Value Type of the document data * @param reference Firestore DocumentReference that will be fetched - * @param options Options to configure how the document is fetched + * @param [options] Options to configure how the document is fetched * @returns DocumentSnapshot, loading state, and error * value: DocumentSnapshot; `undefined` if document does not exist, is currently being fetched, or an error occurred - * loading: `true` while fetching the document; `false` if the document was fetched successfully or an error occurred - * error: `undefined` if no error occurred + * loading: `true` while fetching the document; `false` if the document was fetched successfully or an error occurred; Always `false` with `supsense=true` + * error: `undefined` if no error occurred; Always `undefined` with `supsense=true` */ export function useDocumentOnce( reference: DocumentReference | undefined | null, options?: UseDocumentOnceOptions, ): UseDocumentOnceResult { - const { source = "default" } = options ?? {}; + const { source = "default", suspense = false } = options ?? {}; const getData = useCallback( (stableRef: DocumentReference) => getDocFromSource(stableRef, source), @@ -39,5 +47,5 @@ export function useDocumentOnce( // eslint-disable-next-line react-hooks/exhaustive-deps [], ); - return useOnce(reference ?? undefined, getData, isDocRefEqual); + return useOnce(reference ?? undefined, getData, isDocRefEqual, suspense); } diff --git a/src/firestore/useQueryDataOnce.ts b/src/firestore/useQueryDataOnce.ts index 142a5dff..4bbcbc4e 100644 --- a/src/firestore/useQueryDataOnce.ts +++ b/src/firestore/useQueryDataOnce.ts @@ -11,25 +11,34 @@ export type UseQueryDataOnceResult = * Options to configure the subscription */ export interface UseQueryDataOnceOptions { + /** + * @default "default" + */ source?: Source; + snapshotOptions?: SnapshotOptions; + + /** + * @default false + */ + suspense?: boolean; } /** * Returns the data of a Firestore Query. Does not update the data once initially fetched * @template Value Type of the collection data * @param query Firestore query that will be fetched - * @param options Options to configure how the query is fetched + * @param [options] Options to configure how the query is fetched * @returns Query data, loading state, and error * value: Query data; `undefined` if query is currently being fetched, or an error occurred - * loading: `true` while fetching the query; `false` if the query was fetched successfully or an error occurred - * error: `undefined` if no error occurred + * loading: `true` while fetching the query; `false` if the query was fetched successfully or an error occurred; Always `false` with `supsense=true` + * error: `undefined` if no error occurred; Always `undefined` with `supsense=true` */ export function useQueryDataOnce( query: Query | undefined | null, options?: UseQueryDataOnceOptions, ): UseQueryDataOnceResult { - const { source = "default", snapshotOptions = {} } = options ?? {}; + const { source = "default", snapshotOptions = {}, suspense = false } = options ?? {}; const getData = useCallback(async (stableQuery: Query) => { const snap = await getDocsFromSource(stableQuery, source); @@ -38,5 +47,5 @@ export function useQueryDataOnce( // TODO: add options as dependency // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return useOnce(query ?? undefined, getData, isQueryEqual); + return useOnce(query ?? undefined, getData, isQueryEqual, suspense); } diff --git a/src/firestore/useQueryOnce.ts b/src/firestore/useQueryOnce.ts index 87ff8455..934630ff 100644 --- a/src/firestore/useQueryOnce.ts +++ b/src/firestore/useQueryOnce.ts @@ -14,7 +14,15 @@ export type UseQueryOnceResult = Valu * Options to configure how the query is fetched */ export interface UseQueryOnceOptions { + /** + * @default "default" + */ source?: Source; + + /** + * @default false + */ + suspense?: boolean; } /** @@ -24,14 +32,14 @@ export interface UseQueryOnceOptions { * @param options Options to configure how the query is fetched * @returns QuerySnapshot, loading state, and error * value: QuerySnapshot; `undefined` if query is currently being fetched, or an error occurred - * loading: `true` while fetching the query; `false` if the query was fetched successfully or an error occurred - * error: `undefined` if no error occurred + * loading: `true` while fetching the query; `false` if the query was fetched successfully or an error occurred; Always `false` with `supsense=true` + * error: `undefined` if no error occurred; Always `undefined` with `supsense=true` */ export function useQueryOnce( query: Query | undefined | null, options?: UseQueryOnceOptions, ): UseQueryOnceResult { - const { source = "default" } = options ?? {}; + const { source = "default", suspense = false } = options ?? {}; const getData = useCallback( async (stableQuery: Query) => getDocsFromSource(stableQuery, source), @@ -40,5 +48,5 @@ export function useQueryOnce( // eslint-disable-next-line react-hooks/exhaustive-deps [], ); - return useOnce(query ?? undefined, getData, isQueryEqual); + return useOnce(query ?? undefined, getData, isQueryEqual, suspense); } diff --git a/src/internal/useOnce.spec.ts b/src/internal/useOnce.spec.ts index e684868d..ed59f28f 100644 --- a/src/internal/useOnce.spec.ts +++ b/src/internal/useOnce.spec.ts @@ -1,128 +1,30 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { newPromise, newSymbol } from "../__testfixtures__"; +import { expect, it, vi } from "vitest"; +import { newSymbol } from "../__testfixtures__"; import { useOnce } from "./useOnce"; -import { it, expect, beforeEach, describe, vi } from "vitest"; -const result1 = newSymbol("Result 1"); -const result2 = newSymbol("Result 2"); -const error = newSymbol("Error"); +const ref = newSymbol("Ref"); -const refA1 = newSymbol("Ref A1"); -const refA2 = newSymbol("Ref A2"); +vi.mock("./useStableValue.js", () => ({ + useStableValue: (value: unknown) => value, +})); -const refB1 = newSymbol("Ref B1"); -const refB2 = newSymbol("Ref B2"); +vi.mock("./useOnceSuspense.js", () => ({ + useOnceSuspense: () => ["suspense-result", false, undefined] as const, +})); -const getData = vi.fn(); -const isEqual = (a: unknown, b: unknown) => - [a, b].every((x) => [refA1, refA2].includes(x)) || [a, b].every((x) => [refB1, refB2].includes(x)); +vi.mock("./useOnceNoSuspense.js", () => ({ + useOnceNoSuspense: () => ["non-suspense-result", false, undefined] as const, +})); -beforeEach(() => { - vi.resetAllMocks(); -}); - -describe("initial state", () => { - it("defined reference", () => { - getData.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => useOnce(refA1, getData, isEqual)); - expect(result.current).toStrictEqual([undefined, true, undefined]); - }); +it("uses suspense result", async () => { + const { result } = renderHook(() => useOnce(ref, vi.fn(), () => true, true)); - it("undefined reference", () => { - const { result } = renderHook(() => useOnce(undefined, getData, isEqual)); - expect(result.current).toStrictEqual([undefined, false, undefined]); - }); + await waitFor(() => expect(result.current).toStrictEqual(["suspense-result", false, undefined] as const)); }); -describe("initial load", () => { - it("should return success result", async () => { - const { promise, resolve } = newPromise(); - getData.mockReturnValue(promise); - - const { result } = renderHook(() => useOnce(refA1, getData, isEqual)); - expect(result.current).toStrictEqual([undefined, true, undefined]); - resolve(result1); - await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined])); - }); - - it("should return error result", async () => { - const { promise, reject } = newPromise(); - getData.mockReturnValue(promise); - - const { result } = renderHook(() => useOnce(refA1, getData, isEqual)); - expect(result.current).toStrictEqual([undefined, true, undefined]); - reject(error); - await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error])); - }); -}); - -describe("when ref changes", () => { - describe("to equal ref", () => { - it("should not update success result", async () => { - getData.mockResolvedValueOnce(result1); - - const { result, rerender } = renderHook(({ ref }) => useOnce(ref, getData, isEqual), { - initialProps: { ref: refA1 }, - }); - - expect(result.current).toStrictEqual([undefined, true, undefined]); - await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined])); - expect(getData).toHaveBeenCalledTimes(1); - - rerender({ ref: refA2 }); - expect(result.current).toStrictEqual([result1, false, undefined]); - expect(getData).toHaveBeenCalledTimes(1); - }); - - it("should not update error result", async () => { - getData.mockRejectedValueOnce(error); - - const { result, rerender } = renderHook(({ ref }) => useOnce(ref, getData, isEqual), { - initialProps: { ref: refA1 }, - }); - - expect(result.current).toStrictEqual([undefined, true, undefined]); - await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error])); - expect(getData).toHaveBeenCalledTimes(1); - - rerender({ ref: refA2 }); - expect(result.current).toStrictEqual([undefined, false, error]); - expect(getData).toHaveBeenCalledTimes(1); - }); - }); - - describe("to unequal ref", () => { - it("should update success result", async () => { - getData.mockResolvedValueOnce(result1).mockResolvedValueOnce(result2); - - const { result, rerender } = renderHook(({ ref }) => useOnce(ref, getData, isEqual), { - initialProps: { ref: refA1 }, - }); - - expect(result.current).toStrictEqual([undefined, true, undefined]); - expect(getData).toHaveBeenCalledTimes(1); - await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined])); - - rerender({ ref: refB1 }); - expect(getData).toHaveBeenCalledTimes(2); - await waitFor(() => expect(result.current).toStrictEqual([result2, false, undefined])); - }); - - it("should update error result", async () => { - getData.mockRejectedValueOnce(error).mockResolvedValueOnce(result2); - - const { result, rerender } = renderHook(({ ref }) => useOnce(ref, getData, isEqual), { - initialProps: { ref: refA1 }, - }); - - expect(result.current).toStrictEqual([undefined, true, undefined]); - expect(getData).toHaveBeenCalledTimes(1); - await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error])); +it("uses non-suspense result", async () => { + const { result } = renderHook(() => useOnce(ref, vi.fn(), () => true, false)); - rerender({ ref: refB1 }); - expect(result.current).toStrictEqual([undefined, true, undefined]); - expect(getData).toHaveBeenCalledTimes(2); - await waitFor(() => expect(result.current).toStrictEqual([result2, false, undefined])); - }); - }); + await waitFor(() => expect(result.current).toStrictEqual(["non-suspense-result", false, undefined] as const)); }); diff --git a/src/internal/useOnce.ts b/src/internal/useOnce.ts index 9b84eade..dd4a0cf9 100644 --- a/src/internal/useOnce.ts +++ b/src/internal/useOnce.ts @@ -1,7 +1,6 @@ -import { useEffect, useMemo } from "react"; -import { ValueHookResult } from "../common/index.js"; -import { useIsMounted } from "./useIsMounted.js"; -import { LoadingState, useLoadingValue } from "./useLoadingValue.js"; +import type { ValueHookResult } from "../common/index.js"; +import { useOnceNoSuspense } from "./useOnceNoSuspense.js"; +import { useOnceSuspense } from "./useOnceSuspense.js"; import { useStableValue } from "./useStableValue.js"; /** @@ -11,43 +10,12 @@ export function useOnce( reference: Reference | undefined, getData: (ref: Reference) => Promise, isEqual: (a: Reference | undefined, b: Reference | undefined) => boolean, + suspense: boolean, ): ValueHookResult { - const isMounted = useIsMounted(); - const { value, setValue, loading, setLoading, error, setError } = useLoadingValue( - reference === undefined ? undefined : LoadingState, - ); - const stableRef = useStableValue(reference ?? undefined, isEqual); - useEffect(() => { - (async () => { - if (stableRef === undefined) { - setValue(); - } else { - setLoading(); - - try { - const data = await getData(stableRef); - - if (!isMounted.current) { - return; - } - - setValue(data); - } catch (e) { - if (!isMounted.current) { - return; - } - - // We assume this is always a Error - setError(e as Error); - } - } - })(); - - // TODO: double check dependencies - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stableRef]); + const suspenseResult = useOnceSuspense(stableRef, getData, isEqual, suspense); + const noSuspenseResult = useOnceNoSuspense(stableRef, getData, !suspense); - return useMemo(() => [value, loading, error], [value, loading, error]); + return suspense ? suspenseResult! : noSuspenseResult!; } diff --git a/src/internal/useOnceNoSuspense.spec.ts b/src/internal/useOnceNoSuspense.spec.ts new file mode 100644 index 00000000..8425ddad --- /dev/null +++ b/src/internal/useOnceNoSuspense.spec.ts @@ -0,0 +1,109 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { newPromise, newSymbol } from "../__testfixtures__"; +import { useOnceNoSuspense } from "./useOnceNoSuspense"; + +function createMockData() { + const result1 = newSymbol("Result 1"); + const result2 = newSymbol("Result 2"); + const error = newSymbol("Error"); + + const refA1 = newSymbol("Ref A1"); + const refA2 = newSymbol("Ref A2"); + + const refB1 = newSymbol("Ref B1"); + const refB2 = newSymbol("Ref B2"); + + const getData = vi.fn(); + const isEqual = (a: unknown, b: unknown) => + [a, b].every((x) => [refA1, refA2].includes(x)) || [a, b].every((x) => [refB1, refB2].includes(x)); + + return { + result1, + result2, + error, + refA1, + refA2, + refB1, + refB2, + isEqual, + getData, + }; +} + +describe("initial state", () => { + it("defined reference", () => { + const { refA1, getData } = createMockData(); + getData.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useOnceNoSuspense(refA1, getData)); + expect(result.current).toStrictEqual([undefined, true, undefined]); + }); + + it("undefined reference", () => { + const { getData } = createMockData(); + const { result } = renderHook(() => useOnceNoSuspense(undefined, getData)); + expect(result.current).toStrictEqual([undefined, false, undefined]); + }); +}); + +describe("initial load", () => { + it("should return success result", async () => { + const { result1, refA1, getData } = createMockData(); + const { promise, resolve } = newPromise(); + getData.mockReturnValue(promise); + + const { result } = renderHook(() => useOnceNoSuspense(refA1, getData)); + expect(result.current).toStrictEqual([undefined, true, undefined]); + resolve(result1); + await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined])); + }); + + it("should return error result", async () => { + const { error, refA1, getData } = createMockData(); + const { promise, reject } = newPromise(); + getData.mockReturnValue(promise); + + const { result } = renderHook(() => useOnceNoSuspense(refA1, getData)); + expect(result.current).toStrictEqual([undefined, true, undefined]); + reject(error); + await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error])); + }); +}); + +describe("when ref changes", () => { + it("should update success result", async () => { + const { result1, result2, refA1, refB1, getData } = createMockData(); + getData.mockResolvedValueOnce(result1).mockResolvedValueOnce(result2); + + const { result, rerender } = renderHook(({ ref }) => useOnceNoSuspense(ref, getData), { + initialProps: { ref: refA1 }, + }); + + expect(result.current).toStrictEqual([undefined, true, undefined]); + expect(getData).toHaveBeenCalledTimes(1); + await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined])); + + rerender({ ref: refB1 }); + expect(getData).toHaveBeenCalledTimes(2); + await waitFor(() => expect(result.current).toStrictEqual([result2, false, undefined])); + }); + + it("should update error result", async () => { + const { result2, error, refA1, refB1, getData } = createMockData(); + getData.mockRejectedValueOnce(error).mockResolvedValueOnce(result2); + + const { result, rerender } = renderHook(({ ref }) => useOnceNoSuspense(ref, getData), { + initialProps: { ref: refA1 }, + }); + + expect(result.current).toStrictEqual([undefined, true, undefined]); + expect(getData).toHaveBeenCalledTimes(1); + await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error])); + + rerender({ ref: refB1 }); + expect(result.current).toStrictEqual([undefined, true, undefined]); + expect(getData).toHaveBeenCalledTimes(2); + await waitFor(() => expect(result.current).toStrictEqual([result2, false, undefined])); + }); +}); diff --git a/src/internal/useOnceNoSuspense.ts b/src/internal/useOnceNoSuspense.ts new file mode 100644 index 00000000..06b2e401 --- /dev/null +++ b/src/internal/useOnceNoSuspense.ts @@ -0,0 +1,52 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { useEffect, useMemo } from "react"; +import { ValueHookResult } from "../common/index.js"; +import { useIsMounted } from "./useIsMounted.js"; +import { LoadingState, useLoadingValue } from "./useLoadingValue.js"; + +export function useOnceNoSuspense( + stableRef: Reference | undefined, + getData: (ref: Reference) => Promise, + enabled = true, +): ValueHookResult | undefined { + const isMounted = useIsMounted(); + const { value, setValue, loading, setLoading, error, setError } = useLoadingValue( + stableRef === undefined ? undefined : LoadingState, + ); + useEffect(() => { + if (!enabled) { + return; + } + + (async () => { + if (stableRef === undefined) { + setValue(); + } else { + setLoading(); + + try { + const data = await getData(stableRef); + + if (!isMounted.current) { + return; + } + + setValue(data); + } catch (e) { + if (!isMounted.current) { + return; + } + + // We assume this is always a Error + setError(e as Error); + } + } + })(); + + // TODO: double-check dependencies + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, stableRef]); + + const result = useMemo>(() => [value, loading, error], [value, loading, error]); + return enabled ? result : undefined; +} diff --git a/src/internal/useOnceSuspense.spec.tsx b/src/internal/useOnceSuspense.spec.tsx new file mode 100644 index 00000000..1030b73a --- /dev/null +++ b/src/internal/useOnceSuspense.spec.tsx @@ -0,0 +1,104 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { render, renderHook, waitFor } from "@testing-library/react"; +import React, { Suspense } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { newSymbol } from "../__testfixtures__"; +import { useOnceSuspense } from "./useOnceSuspense"; + +function createMockData() { + const result1 = newSymbol("Result 1"); + const result2 = newSymbol("Result 2"); + const error = new Error("Something went wrong"); + + const refA1 = newSymbol("Ref A1"); + const refA2 = newSymbol("Ref A2"); + + const refB1 = newSymbol("Ref B1"); + const refB2 = newSymbol("Ref B2"); + + const isEqual = (a: unknown, b: unknown) => + [a, b].every((x) => [refA1, refA2].includes(x)) || [a, b].every((x) => [refB1, refB2].includes(x)); + + const getData = vi.fn(); + + return { + result1, + result2, + error, + refA1, + refA2, + refB1, + refB2, + isEqual, + getData, + }; +} + +beforeEach(() => { + globalThis._firehookWrappedPromises?.clear(); +}); + +describe("success state", () => { + it("defined reference", async () => { + const { result1, refA1, isEqual, getData } = createMockData(); + + getData.mockResolvedValue(result1); + const { result } = renderHook(() => useOnceSuspense(refA1, getData, isEqual)); + + await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined])); + }); + + it("undefined reference", async () => { + const { isEqual, getData } = createMockData(); + const { result } = renderHook(() => useOnceSuspense(undefined, getData, isEqual)); + + await waitFor(() => expect(result.current).toStrictEqual([undefined, false, undefined])); + }); +}); + +it("throws error", () => { + const { error, refA1, isEqual } = createMockData(); + + const getData = () => { + throw error; + }; + expect(() => { + renderHook(() => useOnceSuspense(refA1, getData, isEqual)); + }).toThrow(error); +}); + +it("within ``", async () => { + const { refA1, isEqual, getData } = createMockData(); + + getData.mockResolvedValue("Success"); + const Component = () => { + const [data] = useOnceSuspense(refA1, getData, isEqual)!; + return
{data}
; + }; + + const { getByTestId } = render( + + + , + ); + + await waitFor(() => expect(getByTestId("component")).toBeDefined()); + expect(getByTestId("component").textContent).toBe("Success"); +}); + +it("when ref changes", async () => { + const { result1, result2, refA1, refB1, isEqual, getData } = createMockData(); + getData.mockResolvedValueOnce(result1).mockResolvedValueOnce(result2); + + const { result, rerender } = renderHook(({ ref }) => useOnceSuspense(ref, getData, isEqual), { + initialProps: { ref: refA1 }, + }); + + await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined])); + expect(getData).toHaveBeenCalledTimes(1); + + rerender({ ref: refB1 }); + + await waitFor(() => expect(result.current).toStrictEqual([result2, false, undefined])); + expect(getData).toHaveBeenCalledTimes(2); +}); diff --git a/src/internal/useOnceSuspense.ts b/src/internal/useOnceSuspense.ts new file mode 100644 index 00000000..a52af9fa --- /dev/null +++ b/src/internal/useOnceSuspense.ts @@ -0,0 +1,57 @@ +/* eslint-disable jsdoc/require-returns */ +/* eslint-disable jsdoc/require-param */ +import { useMemo } from "react"; +import { WrappedPromise, wrapPromise } from "./wrapPromise.js"; +import { ValueHookResult } from "../common/types.js"; + +/** + * key: `Reference` + * value: `WrappedPromise` + */ +// @ts-expect-error Property is missing on `globalThis` +const wrappedPromises: Map> = globalThis._rfh_promises ?? +new Map>(); + +// @ts-expect-error Property is missing on `globalThis` +if (!globalThis._rfh_promises) { + // @ts-expect-error Property is missing on `globalThis` + globalThis._rfh_promises = wrappedPromises; +} + +const undefinedPromise = wrapPromise(Promise.resolve(undefined)); + +/** + * @internal + */ +export function useOnceSuspense( + stableRef: Reference | undefined, + getData: (ref: Reference) => Promise, + isEqual: (a: Reference | undefined, b: Reference | undefined) => boolean, + enabled = true, +): ValueHookResult | undefined { + const read = useMemo(() => { + if (stableRef === undefined) { + return undefinedPromise; + } + + for (const [ref, promise] of wrappedPromises) { + if (isEqual(ref as Reference, stableRef)) { + return promise; + } + } + + const promise = getData(stableRef); + const wrappedPromise = wrapPromise(promise); + wrappedPromises.set(stableRef, wrappedPromise); + return wrappedPromise; + + // TODO: add options as dependency + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stableRef]); + + if (!enabled) { + return undefined; + } + + return [read(), false, undefined] as ValueHookResult; +} diff --git a/src/internal/wrapPromise.spec.ts b/src/internal/wrapPromise.spec.ts new file mode 100644 index 00000000..fe24edba --- /dev/null +++ b/src/internal/wrapPromise.spec.ts @@ -0,0 +1,24 @@ +import { it, expect, vi } from "vitest"; +import { wrapPromise } from "./wrapPromise.js"; + +vi.useFakeTimers(); + +it("should return value if resolved", async () => { + const promise = Promise.resolve("success"); + const read = wrapPromise(promise); + await vi.runAllTimersAsync(); + expect(read()).toBe("success"); +}); + +it("should throw error if rejected", async () => { + const promise = Promise.reject("error"); + const read = wrapPromise(promise); + await vi.runAllTimersAsync(); + expect(() => read()).toThrow("error"); +}); + +it("should throw Promise if pending", async () => { + const promise = Promise.resolve(); + const read = wrapPromise(promise); + expect(() => read()).toThrow(Promise); +}); diff --git a/src/internal/wrapPromise.ts b/src/internal/wrapPromise.ts new file mode 100644 index 00000000..0e262970 --- /dev/null +++ b/src/internal/wrapPromise.ts @@ -0,0 +1,35 @@ +/* eslint-disable jsdoc/require-returns */ +/* eslint-disable jsdoc/require-param */ +type Status = "pending" | "success" | "error"; + +export type WrappedPromise = () => T; + +/** + * @internal + */ +export function wrapPromise(promise: PromiseLike): WrappedPromise { + let status: Status = "pending"; + let response: T; + + const suspender = promise.then( + (result) => { + status = "success"; + response = result; + }, + (error) => { + status = "error"; + response = error; + }, + ); + + return () => { + switch (status) { + case "pending": + throw suspender; + case "error": + throw response; + case "success": + return response; + } + }; +} diff --git a/src/messaging/useMessagingToken.ts b/src/messaging/useMessagingToken.ts index 965bd106..d147b1b7 100644 --- a/src/messaging/useMessagingToken.ts +++ b/src/messaging/useMessagingToken.ts @@ -9,6 +9,11 @@ export type UseMessagingTokenResult = ValueHookResult; */ export interface UseMessagingTokenOptions { getTokenOptions?: GetTokenOptions; + + /** + * @default false + */ + suspense?: boolean; } /** @@ -21,9 +26,12 @@ export interface UseMessagingTokenOptions { * error: `undefined` if no error occurred */ export function useMessagingToken(messaging: Messaging, options?: UseMessagingTokenOptions): UseMessagingTokenResult { + const { getTokenOptions, suspense = false } = options ?? {}; + return useOnce( messaging, - (m) => getToken(m, options?.getTokenOptions), + (m) => getToken(m, getTokenOptions), () => true, + suspense, ); } diff --git a/src/storage/useBlob.ts b/src/storage/useBlob.ts index 9f23a5dc..3e70aa38 100644 --- a/src/storage/useBlob.ts +++ b/src/storage/useBlob.ts @@ -6,22 +6,70 @@ import { isStorageRefEqual } from "./internal.js"; export type UseBlobResult = ValueHookResult; +/** + * Options to configure how the object is fetched + */ +export interface UseBlobOptions { + /** + * If set, the maximum allowed size in bytes to retrieve + */ + maxDownloadSizeBytes?: number; + + /** + * @default false + */ + suspense?: boolean; +} + +/** + * Returns the data of a Google Cloud Storage object as a Blob + * + * This hook is not available in Node + * @param reference Reference to a Google Cloud Storage object + * @param [options] Options to configure how the object is fetched + * @returns Data, loading state, and error + * value: Object data as a Blob; `undefined` if data of the object is currently being downloaded, or an error occurred + * loading: `true` while downloading the data of the object; `false` if the data was downloaded successfully or an error occurred + * error: `undefined` if no error occurred + */ +export function useBlob(reference: StorageReference | undefined | null, options?: UseBlobOptions): UseBlobResult; /** * Returns the data of a Google Cloud Storage object as a Blob * * This hook is not available in Node * @param reference Reference to a Google Cloud Storage object - * @param maxDownloadSizeBytes If set, the maximum allowed size in bytes to retrieve + * @param [maxDownloadSizeBytes] If set, the maximum allowed size in bytes to retrieve * @returns Data, loading state, and error * value: Object data as a Blob; `undefined` if data of the object is currently being downloaded, or an error occurred * loading: `true` while downloading the data of the object; `false` if the data was downloaded successfully or an error occurred * error: `undefined` if no error occurred + * @deprecated Pass an `maxDownloadSizeBytes` as parameter of an object instead of directly. */ -export function useBlob(reference: StorageReference | undefined | null, maxDownloadSizeBytes?: number): UseBlobResult { +export function useBlob(reference: StorageReference | undefined | null, maxDownloadSizeBytes?: number): UseBlobResult; +/** + * Returns the data of a Google Cloud Storage object as a Blob + * + * This hook is not available in Node + * @param reference Reference to a Google Cloud Storage object + * @param [optionsOrMaxDownloadSizeBytes] Options to configure how the object is fetched, or the maximum allowed size in bytes to retrieve + * @returns Data, loading state, and error + * value: Object data as a Blob; `undefined` if data of the object is currently being downloaded, or an error occurred + * loading: `true` while downloading the data of the object; `false` if the data was downloaded successfully or an error occurred + * error: `undefined` if no error occurred + */ +export function useBlob( + reference: StorageReference | undefined | null, + optionsOrMaxDownloadSizeBytes?: UseBlobOptions | number, +): UseBlobResult { + const { maxDownloadSizeBytes, suspense = false } = + typeof optionsOrMaxDownloadSizeBytes === "number" + ? { maxDownloadSizeBytes: optionsOrMaxDownloadSizeBytes } + : optionsOrMaxDownloadSizeBytes ?? {}; + const fetchBlob = useCallback( async (ref: StorageReference) => getBlob(ref, maxDownloadSizeBytes), [maxDownloadSizeBytes], ); - return useOnce(reference ?? undefined, fetchBlob, isStorageRefEqual); + return useOnce(reference ?? undefined, fetchBlob, isStorageRefEqual, suspense); } diff --git a/src/storage/useBytes.ts b/src/storage/useBytes.ts index b7c065eb..d3508f83 100644 --- a/src/storage/useBytes.ts +++ b/src/storage/useBytes.ts @@ -6,20 +6,63 @@ import { isStorageRefEqual } from "./internal.js"; export type UseBytesResult = ValueHookResult; +/** + * Options to configure how the object is fetched + */ +export interface UseBytesOptions { + /** + * If set, the maximum allowed size in bytes to retrieve + */ + maxDownloadSizeBytes?: number; + + /** + * @default false + */ + suspense?: boolean; +} + +/** + * Returns the data of a Google Cloud Storage object + * @param reference Reference to a Google Cloud Storage object + * @param [options] Options to configure how the object is fetched + * @returns Data, loading state, and error + * value: Object data; `undefined` if data of the object is currently being downloaded, or an error occurred + * loading: `true` while downloading the data of the object; `false` if the data was downloaded successfully or an error occurred + * error: `undefined` if no error occurred + */ +export function useBytes(reference: StorageReference | undefined | null, options?: UseBytesOptions): UseBytesResult; /** * Returns the data of a Google Cloud Storage object * @param reference Reference to a Google Cloud Storage object - * @param maxDownloadSizeBytes If set, the maximum allowed size in bytes to retrieve + * @param maxDownloadSizeBytes The maximum allowed size in bytes to retrieve * @returns Data, loading state, and error * value: Object data; `undefined` if data of the object is currently being downloaded, or an error occurred * loading: `true` while downloading the data of the object; `false` if the data was downloaded successfully or an error occurred * error: `undefined` if no error occurred */ -export function useBytes(reference: StorageReference | undefined | null, maxDownloadSizeBytes?: number): UseBytesResult { +export function useBytes(reference: StorageReference | undefined | null, maxDownloadSizeBytes?: number): UseBytesResult; +/** + * Returns the data of a Google Cloud Storage object + * @param reference Reference to a Google Cloud Storage object + * @param optionsOrMaxDownloadSizeBytes Options to configure how the object is fetched, or the maximum allowed size in bytes to retrieve + * @returns Data, loading state, and error + * value: Object data; `undefined` if data of the object is currently being downloaded, or an error occurred + * loading: `true` while downloading the data of the object; `false` if the data was downloaded successfully or an error occurred + * error: `undefined` if no error occurred + */ +export function useBytes( + reference: StorageReference | undefined | null, + optionsOrMaxDownloadSizeBytes?: UseBytesOptions | number, +): UseBytesResult { + const { maxDownloadSizeBytes, suspense = false } = + typeof optionsOrMaxDownloadSizeBytes === "number" + ? { maxDownloadSizeBytes: optionsOrMaxDownloadSizeBytes } + : optionsOrMaxDownloadSizeBytes ?? {}; + const fetchBytes = useCallback( async (ref: StorageReference) => getBytes(ref, maxDownloadSizeBytes), [maxDownloadSizeBytes], ); - return useOnce(reference ?? undefined, fetchBytes, isStorageRefEqual); + return useOnce(reference ?? undefined, fetchBytes, isStorageRefEqual, suspense); } diff --git a/src/storage/useDownloadURL.ts b/src/storage/useDownloadURL.ts index 5b714a9c..272fbd0c 100644 --- a/src/storage/useDownloadURL.ts +++ b/src/storage/useDownloadURL.ts @@ -5,14 +5,29 @@ import { isStorageRefEqual } from "./internal.js"; export type UseDownloadURLResult = ValueHookResult; +/** + * Options to configure how the download url is fetched + */ +export interface UseDownloadURLOptions { + /** + * @default false + */ + suspense?: boolean; +} + /** * Returns the download URL of a Google Cloud Storage object * @param reference Reference to a Google Cloud Storage object + * @param [options] Options to configure how the download URL is fetched * @returns Download URL, loading state, and error * value: Download URL; `undefined` if download URL is currently being fetched, or an error occurred * loading: `true` while fetching the download URL; `false` if the download URL was fetched successfully or an error occurred * error: `undefined` if no error occurred */ -export function useDownloadURL(reference: StorageReference | undefined | null): UseDownloadURLResult { - return useOnce(reference ?? undefined, getDownloadURL, isStorageRefEqual); +export function useDownloadURL( + reference: StorageReference | undefined | null, + options?: UseDownloadURLOptions, +): UseDownloadURLResult { + const { suspense = false } = options ?? {}; + return useOnce(reference ?? undefined, getDownloadURL, isStorageRefEqual, suspense); } diff --git a/src/storage/useMetadata.ts b/src/storage/useMetadata.ts index d422dfbc..e56900c5 100644 --- a/src/storage/useMetadata.ts +++ b/src/storage/useMetadata.ts @@ -5,14 +5,29 @@ import { isStorageRefEqual } from "./internal.js"; export type UseMetadataResult = ValueHookResult; +/** + * Options to configure how metadata is fetched + */ +export interface UseMetadataOptions { + /** + * @default false + */ + suspense?: boolean; +} + /** * Returns the metadata of a Google Cloud Storage object * @param reference Reference to a Google Cloud Storage object + * @param [options] Options to configure how metadata is fetched * @returns Metadata, loading state, and error * value: Metadata; `undefined` if metadata is currently being fetched, or an error occurred * loading: `true` while fetching the metadata; `false` if the metadata was fetched successfully or an error occurred * error: `undefined` if no error occurred */ -export function useMetadata(reference: StorageReference | undefined | null): UseMetadataResult { - return useOnce(reference ?? undefined, getMetadata, isStorageRefEqual); +export function useMetadata( + reference: StorageReference | undefined | null, + options?: UseMetadataOptions, +): UseMetadataResult { + const { suspense = false } = options ?? {}; + return useOnce(reference ?? undefined, getMetadata, isStorageRefEqual, suspense); } diff --git a/src/storage/useStream.ts b/src/storage/useStream.ts index 55479807..0c8dab34 100644 --- a/src/storage/useStream.ts +++ b/src/storage/useStream.ts @@ -6,22 +6,45 @@ import { isStorageRefEqual } from "./internal.js"; export type UseStreamResult = ValueHookResult; +/** + * Options to configure how the object is fetched + */ +export interface UseStreamOptions { + /** + * The maximum allowed size in bytes to retrieve + */ + maxDownloadSizeBytes?: number; + + /** + * @default false + */ + suspense?: boolean; +} + /** * Returns the data of a Google Cloud Storage object as a stream * * This hook is only available in Node * @param reference Reference to a Google Cloud Storage object - * @param maxDownloadSizeBytes If set, the maximum allowed size in bytes to retrieve + * @param [optionsOrMaxDownloadSizeBytes] If set, the maximum allowed size in bytes to retrieve * @returns Data, loading state, and error * value: Object data as stream; `undefined` if data of the object is currently being downloaded, or an error occurred * loading: `true` while downloading the data of the object; `false` if the data was downloaded successfully or an error occurred * error: `undefined` if no error occurred */ -export function useStream(reference: StorageReference | undefined | null, maxDownloadSizeBytes?: number): UseStreamResult { +export function useStream( + reference: StorageReference | undefined | null, + optionsOrMaxDownloadSizeBytes?: UseStreamOptions, +): UseStreamResult { + const { maxDownloadSizeBytes, suspense = false } = + typeof optionsOrMaxDownloadSizeBytes === "number" + ? { maxDownloadSizeBytes: optionsOrMaxDownloadSizeBytes } + : optionsOrMaxDownloadSizeBytes ?? {}; + const fetchBlob = useCallback( async (ref: StorageReference) => getStream(ref, maxDownloadSizeBytes), [maxDownloadSizeBytes], ); - return useOnce(reference ?? undefined, fetchBlob, isStorageRefEqual); + return useOnce(reference ?? undefined, fetchBlob, isStorageRefEqual, suspense); }