diff --git a/docs/firestore.md b/docs/firestore.md index c8087342..b49aa8d8 100644 --- a/docs/firestore.md +++ b/docs/firestore.md @@ -103,7 +103,7 @@ Returns: Returns and updates a QuerySnapshot of multiple Firestore queries ```javascript -const [querySnap, loading, error] = useQueries(queries, options); +const results = useQueries(queries, options); ``` Params: @@ -123,7 +123,7 @@ Returns: Returns and updates a the document data of multiple Firestore queries ```javascript -const [querySnap, loading, error] = useQueriesData(query, options); +const results = useQueriesData(query, options); ``` Params: @@ -138,6 +138,46 @@ Returns: - `loading` :`true` while fetching the query; `false` if the query was fetched successfully or an error occurred - `error`: `undefined` if no error occurred +## useQueriesDataOnce + +Returns the data of multiple Firestore queries + +```javascript +const results = useQueriesDataOnce(queries, options); +``` + +Params: + +- `queries`: Firestore queries that will be fetched +- `options`: Options to configure how the queries are fetched + +Returns: + +- Array with tuple for each query:: + - `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 + +## useQueriesOnce + +Returns the QuerySnapshots of multiple Firestore queries + +```javascript +const results = useQueriesOnce(queries, options); +``` + +Params: + +- `queries`: Firestore queries that will be fetched +- `options`: Options to configure how the queries are fetched + +Returns: + +- Array with tuple for each query:: + - `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 + ## useQuery Returns and updates a QuerySnapshot of a Firestore Query diff --git a/src/database/useObjectOnce.ts b/src/database/useObjectOnce.ts index 7490d538..42c56327 100644 --- a/src/database/useObjectOnce.ts +++ b/src/database/useObjectOnce.ts @@ -1,7 +1,7 @@ import { DataSnapshot, get, Query } from "firebase/database"; import { useCallback } from "react"; import type { ValueHookResult } from "../common/index.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { isQueryEqual } from "./internal.js"; export type UseObjectOnceResult = ValueHookResult; @@ -16,5 +16,5 @@ export type UseObjectOnceResult = ValueHookResult; */ export function useObjectOnce(query: Query | undefined | null): UseObjectOnceResult { const getData = useCallback((stableQuery: Query) => get(stableQuery), []); - return useOnce(query ?? undefined, getData, isQueryEqual); + return useGet(query ?? undefined, getData, isQueryEqual); } diff --git a/src/database/useObjectValueOnce.ts b/src/database/useObjectValueOnce.ts index be26545f..513bcf78 100644 --- a/src/database/useObjectValueOnce.ts +++ b/src/database/useObjectValueOnce.ts @@ -1,7 +1,7 @@ import { DataSnapshot, get, Query } from "firebase/database"; import { useCallback } from "react"; import type { ValueHookResult } from "../common/index.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { defaultConverter, isQueryEqual } from "./internal.js"; export type UseObjectValueOnceResult = ValueHookResult; @@ -36,5 +36,5 @@ export function useObjectValueOnce( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return useOnce(query ?? undefined, getData, isQueryEqual); + return useGet(query ?? undefined, getData, isQueryEqual); } diff --git a/src/firestore/index.ts b/src/firestore/index.ts index 4e757b45..dc4d0362 100644 --- a/src/firestore/index.ts +++ b/src/firestore/index.ts @@ -6,6 +6,7 @@ export * from "./useDocumentDataOnce.js"; export * from "./useDocumentOnce.js"; export * from "./useQueries.js"; export * from "./useQueriesData.js"; +export * from "./useQueriesDataOnce.js"; export * from "./useQuery.js"; export * from "./useQueryData.js"; export * from "./useQueryDataOnce.js"; diff --git a/src/firestore/useCountFromServer.ts b/src/firestore/useCountFromServer.ts index 900cf072..9f9e3815 100644 --- a/src/firestore/useCountFromServer.ts +++ b/src/firestore/useCountFromServer.ts @@ -1,6 +1,6 @@ import { FirestoreError, Query, getCountFromServer } from "firebase/firestore"; import type { ValueHookResult } from "../common/types.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { isQueryEqual } from "./internal.js"; export type UseCountFromServerResult = ValueHookResult; @@ -23,5 +23,5 @@ async function getData(stableQuery: Query): Promise { * error: `undefined` if no error occurred */ export function useCountFromServer(query: Query | undefined | null): UseCountFromServerResult { - return useOnce(query ?? undefined, getData, isQueryEqual); + return useGet(query ?? undefined, getData, isQueryEqual); } diff --git a/src/firestore/useDocumentDataOnce.ts b/src/firestore/useDocumentDataOnce.ts index 35dd1465..7500eaa9 100644 --- a/src/firestore/useDocumentDataOnce.ts +++ b/src/firestore/useDocumentDataOnce.ts @@ -1,7 +1,7 @@ import { DocumentData, DocumentReference, FirestoreError, SnapshotOptions } from "firebase/firestore"; import { useCallback } from "react"; import type { ValueHookResult } from "../common/types.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { getDocFromSource, isDocRefEqual } from "./internal.js"; import type { Source } from "./types.js"; @@ -40,5 +40,5 @@ export function useDocumentDataOnce( [serverTimestamps, source], ); - return useOnce(reference ?? undefined, getData, isDocRefEqual); + return useGet(reference ?? undefined, getData, isDocRefEqual); } diff --git a/src/firestore/useDocumentOnce.ts b/src/firestore/useDocumentOnce.ts index 23c11434..dce39f78 100644 --- a/src/firestore/useDocumentOnce.ts +++ b/src/firestore/useDocumentOnce.ts @@ -1,7 +1,7 @@ import { DocumentData, DocumentReference, DocumentSnapshot, FirestoreError } from "firebase/firestore"; import { useCallback } from "react"; import type { ValueHookResult } from "../common/types.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { getDocFromSource, isDocRefEqual } from "./internal.js"; import type { Source } from "./types.js"; @@ -35,5 +35,5 @@ export function useDocumentOnce( const getData = useCallback((stableRef: DocumentReference) => getDocFromSource(stableRef, source), [source]); - return useOnce(reference ?? undefined, getData, isDocRefEqual); + return useGet(reference ?? undefined, getData, isDocRefEqual); } diff --git a/src/firestore/useQueriesDataOnce.ts b/src/firestore/useQueriesDataOnce.ts new file mode 100644 index 00000000..1e9f87d3 --- /dev/null +++ b/src/firestore/useQueriesDataOnce.ts @@ -0,0 +1,47 @@ +import { DocumentData, FirestoreError, Query, SnapshotOptions } from "firebase/firestore"; +import { useCallback } from "react"; +import type { ValueHookResult } from "../common/types.js"; +import { useMultiGet } from "../internal/useMultiGet.js"; +import { getDocsFromSource, isQueryEqual } from "./internal.js"; +import type { Source } from "./types.js"; + +export type UseQueriesDataOnceResult = ReadonlyArray> = { + [Index in keyof Values]: ValueHookResult; +} & { length: Values["length"] }; + +/** + * Options to configure the subscription + */ +export interface UseQueriesDataOnceOptions { + source?: Source; + snapshotOptions?: SnapshotOptions; +} + +/** + * Returns the data of multiple Firestore queries. Does not update the data once initially fetched + * @template Values Tuple of types of the collection data + * @param queries Firestore queries that will be fetched + * @param options Options to configure how the queries are fetched + * @returns Array with tuple for each query: + * 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 + */ +export function useQueriesDataOnce = ReadonlyArray>( + queries: { [Index in keyof Values]: Query }, + options?: UseQueriesDataOnceOptions, +): UseQueriesDataOnceResult { + const { source = "default", snapshotOptions } = options ?? {}; + const { serverTimestamps } = snapshotOptions ?? {}; + + const getData = useCallback( + async (stableQuery: Query) => { + const snap = await getDocsFromSource(stableQuery, source); + return snap.docs.map((doc) => doc.data({ serverTimestamps })); + }, + [source, serverTimestamps], + ); + + // @ts-expect-error `useMultiGet` assumes a single value type + return useMultiGet(queries, getData, isQueryEqual); +} diff --git a/src/firestore/useQueriesOnce.ts b/src/firestore/useQueriesOnce.ts new file mode 100644 index 00000000..473a76f1 --- /dev/null +++ b/src/firestore/useQueriesOnce.ts @@ -0,0 +1,43 @@ +import { DocumentData, FirestoreError, Query, SnapshotOptions } from "firebase/firestore"; +import { useCallback } from "react"; +import type { ValueHookResult } from "../common/types.js"; +import { useMultiGet } from "../internal/useMultiGet.js"; +import { getDocsFromSource, isQueryEqual } from "./internal.js"; +import type { Source } from "./types.js"; + +export type UseQueriesOnceResult = ReadonlyArray> = { + [Index in keyof Values]: ValueHookResult; +} & { length: Values["length"] }; + +/** + * Options to configure the subscription + */ +export interface UseQueriesOnceOptions { + source?: Source; + snapshotOptions?: SnapshotOptions; +} + +/** + * Returns the QuerySnapshot of multiple Firestore queries. Does not update the data once initially fetched + * @template Values Tuple of types of the collection data + * @param queries Firestore queries that will be fetched + * @param options Options to configure how the queries are fetched + * @returns Array with tuple for each query: + * 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 + */ +export function useQueriesOnce = ReadonlyArray>( + queries: { [Index in keyof Values]: Query }, + options?: UseQueriesOnceOptions, +): UseQueriesOnceResult { + const { source = "default" } = options ?? {}; + + const getData = useCallback( + async (stableQuery: Query) => getDocsFromSource(stableQuery, source), + [source], + ); + + // @ts-expect-error `useMultiGet` assumes a single value type + return useMultiGet(queries, getData, isQueryEqual); +} diff --git a/src/firestore/useQueryDataOnce.ts b/src/firestore/useQueryDataOnce.ts index 83e58405..0bc65275 100644 --- a/src/firestore/useQueryDataOnce.ts +++ b/src/firestore/useQueryDataOnce.ts @@ -1,7 +1,7 @@ import { DocumentData, FirestoreError, Query, SnapshotOptions } from "firebase/firestore"; import { useCallback } from "react"; import type { ValueHookResult } from "../common/types.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { getDocsFromSource, isQueryEqual } from "./internal.js"; import type { Source } from "./types.js"; @@ -40,5 +40,5 @@ export function useQueryDataOnce( [serverTimestamps, source], ); - return useOnce(query ?? undefined, getData, isQueryEqual); + return useGet(query ?? undefined, getData, isQueryEqual); } diff --git a/src/firestore/useQueryOnce.ts b/src/firestore/useQueryOnce.ts index 6eaa00d5..3b795a4f 100644 --- a/src/firestore/useQueryOnce.ts +++ b/src/firestore/useQueryOnce.ts @@ -1,7 +1,7 @@ import { DocumentData, FirestoreError, Query, QuerySnapshot } from "firebase/firestore"; import { useCallback } from "react"; import type { ValueHookResult } from "../common/types.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { getDocsFromSource, isQueryEqual } from "./internal.js"; import type { Source } from "./types.js"; @@ -18,7 +18,7 @@ export interface UseQueryOnceOptions { } /** - * Returns the QuerySnapshot of a Firestore Query. Does not update the QuerySnapshot once initially fetched + * Returns the QuerySnapshot of a Firestore query. Does not update the QuerySnapshot 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 @@ -35,5 +35,5 @@ export function useQueryOnce( const getData = useCallback(async (stableQuery: Query) => getDocsFromSource(stableQuery, source), [source]); - return useOnce(query ?? undefined, getData, isQueryEqual); + return useGet(query ?? undefined, getData, isQueryEqual); } diff --git a/src/internal/useOnce.spec.ts b/src/internal/useGet.spec.ts similarity index 90% rename from src/internal/useOnce.spec.ts rename to src/internal/useGet.spec.ts index e684868d..a2d77643 100644 --- a/src/internal/useOnce.spec.ts +++ b/src/internal/useGet.spec.ts @@ -1,6 +1,6 @@ import { renderHook, waitFor } from "@testing-library/react"; import { newPromise, newSymbol } from "../__testfixtures__"; -import { useOnce } from "./useOnce"; +import { useGet } from "./useGet"; import { it, expect, beforeEach, describe, vi } from "vitest"; const result1 = newSymbol("Result 1"); @@ -24,12 +24,12 @@ beforeEach(() => { describe("initial state", () => { it("defined reference", () => { getData.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => useOnce(refA1, getData, isEqual)); + const { result } = renderHook(() => useGet(refA1, getData, isEqual)); expect(result.current).toStrictEqual([undefined, true, undefined]); }); it("undefined reference", () => { - const { result } = renderHook(() => useOnce(undefined, getData, isEqual)); + const { result } = renderHook(() => useGet(undefined, getData, isEqual)); expect(result.current).toStrictEqual([undefined, false, undefined]); }); }); @@ -39,7 +39,7 @@ describe("initial load", () => { const { promise, resolve } = newPromise(); getData.mockReturnValue(promise); - const { result } = renderHook(() => useOnce(refA1, getData, isEqual)); + const { result } = renderHook(() => useGet(refA1, getData, isEqual)); expect(result.current).toStrictEqual([undefined, true, undefined]); resolve(result1); await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined])); @@ -49,7 +49,7 @@ describe("initial load", () => { const { promise, reject } = newPromise(); getData.mockReturnValue(promise); - const { result } = renderHook(() => useOnce(refA1, getData, isEqual)); + const { result } = renderHook(() => useGet(refA1, getData, isEqual)); expect(result.current).toStrictEqual([undefined, true, undefined]); reject(error); await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error])); @@ -61,7 +61,7 @@ describe("when ref changes", () => { it("should not update success result", async () => { getData.mockResolvedValueOnce(result1); - const { result, rerender } = renderHook(({ ref }) => useOnce(ref, getData, isEqual), { + const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), { initialProps: { ref: refA1 }, }); @@ -77,7 +77,7 @@ describe("when ref changes", () => { it("should not update error result", async () => { getData.mockRejectedValueOnce(error); - const { result, rerender } = renderHook(({ ref }) => useOnce(ref, getData, isEqual), { + const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), { initialProps: { ref: refA1 }, }); @@ -95,7 +95,7 @@ describe("when ref changes", () => { it("should update success result", async () => { getData.mockResolvedValueOnce(result1).mockResolvedValueOnce(result2); - const { result, rerender } = renderHook(({ ref }) => useOnce(ref, getData, isEqual), { + const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), { initialProps: { ref: refA1 }, }); @@ -111,7 +111,7 @@ describe("when ref changes", () => { it("should update error result", async () => { getData.mockRejectedValueOnce(error).mockResolvedValueOnce(result2); - const { result, rerender } = renderHook(({ ref }) => useOnce(ref, getData, isEqual), { + const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), { initialProps: { ref: refA1 }, }); diff --git a/src/internal/useOnce.ts b/src/internal/useGet.ts similarity index 96% rename from src/internal/useOnce.ts rename to src/internal/useGet.ts index 3d2d3a33..a5003271 100644 --- a/src/internal/useOnce.ts +++ b/src/internal/useGet.ts @@ -7,7 +7,7 @@ import { useStableValue } from "./useStableValue.js"; /** * @internal */ -export function useOnce( +export function useGet( reference: Reference | undefined, getData: (ref: Reference) => Promise, isEqual: (a: Reference | undefined, b: Reference | undefined) => boolean, diff --git a/src/internal/useMultiGet.ts b/src/internal/useMultiGet.ts new file mode 100644 index 00000000..36e66306 --- /dev/null +++ b/src/internal/useMultiGet.ts @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useRef } from "react"; +import { ValueHookResult } from "../common/index.js"; +import { useIsMounted } from "./useIsMounted.js"; +import { useMultiLoadingValue } from "./useMultiLoadingValue.js"; + +/** + * @internal + */ +export function useMultiGet( + references: ReadonlyArray, + getData: (ref: Reference) => Promise, + isEqual: (a: Reference | undefined, b: Reference | undefined) => boolean, +): ValueHookResult[] { + const isMounted = useIsMounted(); + + const { states, setError, setLoading, setValue } = useMultiLoadingValue(references.length); + const prevReferences = useRef([]); + + useEffect(() => { + // shorten `prevReferences` size if number of references was reduced + prevReferences.current = prevReferences.current.slice(0, references.length); + + // fetch to new references + const changedReferences = references + .map((ref, refIndex) => [ref, refIndex] as const) + .filter(([ref, refIndex]) => !isEqual(ref, prevReferences.current[refIndex])); + + for (const [ref, refIndex] of changedReferences) { + (async () => { + prevReferences.current[refIndex] = ref; + setLoading(refIndex); + + try { + const data = await getData(ref); + if (!isMounted.current) { + return; + } + + setValue(refIndex, data); + } catch (e) { + if (!isMounted.current) { + return; + } + + // We assume this is always a Error + setError(refIndex, e as Error); + } + })(); + } + + // TODO: double check dependencies + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [references]); + + return useMemo( + () => states.map((state) => [state.value, state.loading, state.error] as ValueHookResult), + [states], + ); +} diff --git a/src/messaging/useMessagingToken.ts b/src/messaging/useMessagingToken.ts index 965bd106..1f5619c6 100644 --- a/src/messaging/useMessagingToken.ts +++ b/src/messaging/useMessagingToken.ts @@ -1,6 +1,6 @@ import { Messaging, getToken, GetTokenOptions } from "firebase/messaging"; import { ValueHookResult } from "../common/index.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; export type UseMessagingTokenResult = ValueHookResult; @@ -21,7 +21,7 @@ export interface UseMessagingTokenOptions { * error: `undefined` if no error occurred */ export function useMessagingToken(messaging: Messaging, options?: UseMessagingTokenOptions): UseMessagingTokenResult { - return useOnce( + return useGet( messaging, (m) => getToken(m, options?.getTokenOptions), () => true, diff --git a/src/storage/useBlob.ts b/src/storage/useBlob.ts index 9f23a5dc..8623d56c 100644 --- a/src/storage/useBlob.ts +++ b/src/storage/useBlob.ts @@ -1,7 +1,7 @@ import { getBlob, StorageError, StorageReference } from "firebase/storage"; import { useCallback } from "react"; import { ValueHookResult } from "../common/index.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { isStorageRefEqual } from "./internal.js"; export type UseBlobResult = ValueHookResult; @@ -23,5 +23,5 @@ export function useBlob(reference: StorageReference | undefined | null, maxDownl [maxDownloadSizeBytes], ); - return useOnce(reference ?? undefined, fetchBlob, isStorageRefEqual); + return useGet(reference ?? undefined, fetchBlob, isStorageRefEqual); } diff --git a/src/storage/useBytes.ts b/src/storage/useBytes.ts index b7c065eb..98d582fc 100644 --- a/src/storage/useBytes.ts +++ b/src/storage/useBytes.ts @@ -1,7 +1,7 @@ import { getBytes, StorageError, StorageReference } from "firebase/storage"; import { useCallback } from "react"; import { ValueHookResult } from "../common/index.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { isStorageRefEqual } from "./internal.js"; export type UseBytesResult = ValueHookResult; @@ -21,5 +21,5 @@ export function useBytes(reference: StorageReference | undefined | null, maxDown [maxDownloadSizeBytes], ); - return useOnce(reference ?? undefined, fetchBytes, isStorageRefEqual); + return useGet(reference ?? undefined, fetchBytes, isStorageRefEqual); } diff --git a/src/storage/useDownloadURL.ts b/src/storage/useDownloadURL.ts index 5b714a9c..38f42dec 100644 --- a/src/storage/useDownloadURL.ts +++ b/src/storage/useDownloadURL.ts @@ -1,6 +1,6 @@ import { getDownloadURL, StorageError, StorageReference } from "firebase/storage"; import { ValueHookResult } from "../common/index.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { isStorageRefEqual } from "./internal.js"; export type UseDownloadURLResult = ValueHookResult; @@ -14,5 +14,5 @@ export type UseDownloadURLResult = ValueHookResult; * error: `undefined` if no error occurred */ export function useDownloadURL(reference: StorageReference | undefined | null): UseDownloadURLResult { - return useOnce(reference ?? undefined, getDownloadURL, isStorageRefEqual); + return useGet(reference ?? undefined, getDownloadURL, isStorageRefEqual); } diff --git a/src/storage/useMetadata.ts b/src/storage/useMetadata.ts index d422dfbc..8f0946e5 100644 --- a/src/storage/useMetadata.ts +++ b/src/storage/useMetadata.ts @@ -1,6 +1,6 @@ import { FullMetadata, getMetadata, StorageError, StorageReference } from "firebase/storage"; import type { ValueHookResult } from "../common/index.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { isStorageRefEqual } from "./internal.js"; export type UseMetadataResult = ValueHookResult; @@ -14,5 +14,5 @@ export type UseMetadataResult = ValueHookResult; * error: `undefined` if no error occurred */ export function useMetadata(reference: StorageReference | undefined | null): UseMetadataResult { - return useOnce(reference ?? undefined, getMetadata, isStorageRefEqual); + return useGet(reference ?? undefined, getMetadata, isStorageRefEqual); } diff --git a/src/storage/useStream.ts b/src/storage/useStream.ts index 55479807..3eb4cb29 100644 --- a/src/storage/useStream.ts +++ b/src/storage/useStream.ts @@ -1,7 +1,7 @@ import { getStream, StorageError, StorageReference } from "firebase/storage"; import { useCallback } from "react"; import type { ValueHookResult } from "../common/index.js"; -import { useOnce } from "../internal/useOnce.js"; +import { useGet } from "../internal/useGet.js"; import { isStorageRefEqual } from "./internal.js"; export type UseStreamResult = ValueHookResult; @@ -23,5 +23,5 @@ export function useStream(reference: StorageReference | undefined | null, maxDow [maxDownloadSizeBytes], ); - return useOnce(reference ?? undefined, fetchBlob, isStorageRefEqual); + return useGet(reference ?? undefined, fetchBlob, isStorageRefEqual); }