useFirestoreCollectionWithAtom fixes

- creating too many queries when they're equal
- creating quries for new pages when we've loaded all rows already
- fixed suspending when loading next page
- broke up big useEffect and memoized and checked firestore query for equality
This commit is contained in:
Sidney Alcantara
2022-05-27 14:40:21 +10:00
parent 60a40c6354
commit c85098ae6a
3 changed files with 188 additions and 69 deletions

View File

@@ -21,6 +21,7 @@ import {
DeleteCollectionDocFunction,
} from "@src/types/table";
import { updateRowData } from "@src/utils/table";
import { COLLECTION_PAGE_SIZE } from "@src/config/db";
/** Root atom from which others are derived */
export const tableIdAtom = atom("");
@@ -66,8 +67,28 @@ export const tableColumnsReducer = (
export const tableFiltersAtom = atom<TableFilter[]>([]);
/** Orders applied to the local view */
export const tableOrdersAtom = atom<TableOrder[]>([]);
/** Latest page in the infinite scroll */
export const tablePageAtom = atom(0);
/**
* Set the page for the table query. Stops updating if weve loaded all rows.
*/
export const tablePageAtom = atom(
0,
(get, set, update: number | ((p: number) => number)) => {
// If loading more, dont request another page
const tableLoadingMore = get(tableLoadingMoreAtom);
if (tableLoadingMore) return;
// Check that we havent loaded all rows
const tableRowsDb = get(tableRowsDbAtom);
const currentPage = get(tablePageAtom);
if (tableRowsDb.length < (currentPage + 1) * COLLECTION_PAGE_SIZE) return;
set(
tablePageAtom,
typeof update === "number" ? update : update(currentPage)
);
}
);
type TableRowsLocalAction =
/** Overwrite all rows */

View File

@@ -1,9 +1,12 @@
import { useEffect } from "react";
import { useState, useEffect } from "react";
import useMemoValue from "use-memo-value";
import { useAtom, PrimitiveAtom, useSetAtom } from "jotai";
import { Scope } from "jotai/core/atom";
import { set } from "lodash-es";
import {
Firestore,
query,
queryEqual,
collection,
collectionGroup as queryCollectionGroup,
limit as queryLimit,
@@ -14,9 +17,9 @@ import {
setDoc,
doc,
deleteDoc,
deleteField,
CollectionReference,
Query,
deleteField,
} from "firebase/firestore";
import { useErrorHandler } from "react-error-boundary";
@@ -41,8 +44,10 @@ interface IUseFirestoreCollectionWithAtomOptions<T> {
filters?: TableFilter[];
/** Attach orders to the query */
orders?: TableOrder[];
/** Limit query */
limit?: number;
/** Set query page */
page?: number;
/** Set query page size */
pageSize?: number;
/** Called when an error occurs. Make sure to wrap in useCallback! If not provided, errors trigger the nearest ErrorBoundary. */
onError?: (error: FirestoreError) => void;
/** Optionally disable Suspense */
@@ -51,6 +56,8 @@ interface IUseFirestoreCollectionWithAtomOptions<T> {
updateDocAtom?: PrimitiveAtom<UpdateCollectionDocFunction<T> | undefined>;
/** Set this atoms value to a function that deletes a document in the collection. Must pass the full path. Uses same scope as `dataScope`. */
deleteDocAtom?: PrimitiveAtom<DeleteCollectionDocFunction | undefined>;
/** Update this atom when were loading the next page. Uses same scope as `dataScope`. */
loadingMoreAtom?: PrimitiveAtom<boolean>;
}
/**
@@ -69,75 +76,91 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
path: string | undefined,
options?: IUseFirestoreCollectionWithAtomOptions<T>
) {
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
const setDataAtom = useSetAtom(dataAtom, dataScope);
const setUpdateDocAtom = useSetAtom(
options?.updateDocAtom || (dataAtom as any),
dataScope
);
const setDeleteDocAtom = useSetAtom(
options?.deleteDocAtom || (dataAtom as any),
dataScope
);
const handleError = useErrorHandler();
// Destructure options so they can be used as useEffect dependencies
const {
pathSegments,
collectionGroup,
filters,
orders,
limit = COLLECTION_PAGE_SIZE,
page = 0,
pageSize = COLLECTION_PAGE_SIZE,
onError,
disableSuspense,
updateDocAtom,
deleteDocAtom,
loadingMoreAtom,
} = options || {};
useEffect(() => {
if (!path || (Array.isArray(pathSegments) && pathSegments.some((x) => !x)))
return;
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
const setDataAtom = useSetAtom(dataAtom, dataScope);
const handleError = useErrorHandler();
let suspended = false;
// Create set functions that point to optional atoms,
// or use dataAtom if not provided. Make sure to check that the corresponding
// option was provided before calling these functions!
const setUpdateDocAtom = useSetAtom(
updateDocAtom || (dataAtom as any),
dataScope
);
const setDeleteDocAtom = useSetAtom(
deleteDocAtom || (dataAtom as any),
dataScope
);
const setLoadingMoreAtom = useSetAtom(
loadingMoreAtom || (dataAtom as any),
dataScope
);
// Store if were at the last page to prevent a new query from being created
const [isLastPage, setIsLastPage] = useState(false);
// Create the query and memoize using Firestores queryEqual
const memoizedQuery = useMemoValue(
getQuery<T>(
firebaseDb,
path,
pathSegments,
collectionGroup,
page,
pageSize,
filters,
orders
),
(next, prev) =>
isLastPage || queryEqual(next?.query as any, prev?.query as any)
);
useEffect(() => {
// If path is invalid and no memoizedQuery was created, dont continue
if (!memoizedQuery) return;
// Suspend data atom until we get the first snapshot
if (!disableSuspense) {
// Dont suspend if were getting the next page
let suspended = false;
if (!disableSuspense && memoizedQuery.page === 0) {
setDataAtom(new Promise(() => {}) as unknown as T[]);
suspended = true;
}
// Set loadingMoreAtom if provided and getting the next page
else if (memoizedQuery.page > 0 && loadingMoreAtom) {
setLoadingMoreAtom(true);
}
// Create a collection or collection group reference to query data
const collectionRef = collectionGroup
? (queryCollectionGroup(
firebaseDb,
[path, ...((pathSegments as string[]) || [])].join("/")
) as Query<T>)
: (collection(
firebaseDb,
path,
...((pathSegments as string[]) || [])
) as CollectionReference<T>);
// Create the query with filters and orders
const _query = query<T>(
collectionRef,
queryLimit(limit),
...(filters?.map((filter) =>
where(filter.key, filter.operator, filter.value)
) || []),
...(orders?.map((order) => orderBy(order.key, order.direction)) || [])
);
// Create a listener for the query
const unsubscribe = onSnapshot(
_query,
(querySnapshot) => {
memoizedQuery.query,
(snapshot) => {
try {
// Extract doc data from query and add `_rowy_ref` fields
const docs = querySnapshot.docs.map((doc) => ({
const docs = snapshot.docs.map((doc) => ({
...doc.data(),
_rowy_ref: doc.ref,
}));
setDataAtom(docs);
// If the snapshot doesnt fill the page, its the last page
if (docs.length < memoizedQuery.limit) setIsLastPage(true);
// Mark loadingMore as done
if (loadingMoreAtom) setLoadingMoreAtom(false);
} catch (error) {
if (onError) onError(error as FirestoreError);
else handleError(error);
@@ -145,12 +168,41 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
suspended = false;
},
(error) => {
if (suspended) setDataAtom([]);
if (suspended) {
setDataAtom([]);
suspended = false;
}
if (loadingMoreAtom) setLoadingMoreAtom(false);
if (onError) onError(error);
else handleError(error);
}
);
// When the listener will change, unsubscribe
return () => {
unsubscribe();
if (loadingMoreAtom) setLoadingMoreAtom(false);
};
}, [
firebaseDb,
memoizedQuery,
disableSuspense,
setDataAtom,
onError,
handleError,
loadingMoreAtom,
setLoadingMoreAtom,
]);
// Create variable for validity of query to pass to useEffect dependencies
// below, and prevent it being called when page, filters, or orders is updated
const queryValid = Boolean(memoizedQuery);
// Set updateDocAtom and deleteDocAtom values if they exist
useEffect(() => {
// If path is invalid and no collectionRef was created,
// dont set update and delete atoms
if (!queryValid) return;
// If `options?.updateDocAtom` was passed,
// set the atoms value to a function that updates a doc in the collection
if (updateDocAtom) {
@@ -169,7 +221,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
);
}
// If `options?.deleteDocAtom` was passed,
// If `deleteDocAtom` was passed,
// set the atoms value to a function that deletes a doc in the collection
if (deleteDocAtom) {
setDeleteDocAtom(
@@ -178,31 +230,72 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
}
return () => {
unsubscribe();
// If `options?.updateDocAtom` was passed,
// If `updateDocAtom` was passed,
// reset the atoms value to prevent writes
if (updateDocAtom) setUpdateDocAtom(undefined);
// If `options?.deleteDoc` was passed,
// If `deleteDoc` was passed,
// reset the atoms value to prevent deletes
if (deleteDocAtom) setDeleteDocAtom(undefined);
};
}, [
firebaseDb,
path,
pathSegments,
collectionGroup,
filters,
orders,
limit,
onError,
setDataAtom,
disableSuspense,
handleError,
queryValid,
updateDocAtom,
setUpdateDocAtom,
deleteDocAtom,
setDeleteDocAtom,
loadingMoreAtom,
]);
}
export default useFirestoreCollectionWithAtom;
/**
* Create the Firestore query with page, filters, and orders constraints.
* Put code in a function so the results can be compared by useMemoValue.
*/
const getQuery = <T>(
firebaseDb: Firestore,
path: string | undefined,
pathSegments: IUseFirestoreCollectionWithAtomOptions<T>["pathSegments"],
collectionGroup: IUseFirestoreCollectionWithAtomOptions<T>["collectionGroup"],
page: number,
pageSize: number,
filters: IUseFirestoreCollectionWithAtomOptions<T>["filters"],
orders: IUseFirestoreCollectionWithAtomOptions<T>["orders"]
) => {
if (!path || (Array.isArray(pathSegments) && pathSegments.some((x) => !x)))
return null;
let collectionRef: Query<T>;
if (collectionGroup) {
collectionRef = queryCollectionGroup(
firebaseDb,
[path, ...((pathSegments as string[]) || [])].join("/")
) as Query<T>;
} else {
collectionRef = collection(
firebaseDb,
path,
...((pathSegments as string[]) || [])
) as CollectionReference<T>;
}
if (!collectionRef) return null;
const limit = (page + 1) * pageSize;
return {
query: query<T>(
collectionRef,
queryLimit((page + 1) * pageSize),
...(filters?.map((filter) =>
where(filter.key, filter.operator, filter.value)
) || []),
...(orders?.map((order) => orderBy(order.key, order.direction)) || [])
),
page,
limit,
};
};

View File

@@ -1,4 +1,4 @@
import { memo, useMemo, useEffect } from "react";
import { memo, useMemo, useEffect, useCallback } from "react";
import { useAtom, useSetAtom } from "jotai";
import { find } from "lodash-es";
@@ -20,13 +20,13 @@ import {
tableRowsDbAtom,
_updateRowDbAtom,
_deleteRowDbAtom,
tableLoadingMoreAtom,
auditChangeAtom,
} from "@src/atoms/tableScope";
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom";
import { TABLE_SCHEMAS, TABLE_GROUP_SCHEMAS } from "@src/config/dbPaths";
import { COLLECTION_PAGE_SIZE } from "@src/config/db";
import type { FirestoreError } from "firebase/firestore";
import { useSnackbar } from "notistack";
@@ -76,6 +76,11 @@ const TableSourceFirestore = memo(function TableSourceFirestore() {
// and handle some errors with snackbars
const { enqueueSnackbar } = useSnackbar();
const elevateError = useErrorHandler();
const handleErrorCallback = useCallback(
(error: FirestoreError) =>
handleFirestoreError(error, enqueueSnackbar, elevateError),
[enqueueSnackbar, elevateError]
);
useFirestoreCollectionWithAtom(
tableRowsDbAtom,
tableScope,
@@ -83,12 +88,12 @@ const TableSourceFirestore = memo(function TableSourceFirestore() {
{
filters,
orders,
limit: COLLECTION_PAGE_SIZE * (page + 1),
page,
collectionGroup: isCollectionGroup,
onError: (error) =>
handleFirestoreError(error, enqueueSnackbar, elevateError),
onError: handleErrorCallback,
updateDocAtom: _updateRowDbAtom,
deleteDocAtom: _deleteRowDbAtom,
loadingMoreAtom: tableLoadingMoreAtom,
}
);