@ttt-productions/query-core 0.14.5 → 0.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/cache-helpers.d.ts +17 -0
  2. package/dist/cache-helpers.d.ts.map +1 -1
  3. package/dist/cache-helpers.js +48 -0
  4. package/dist/cache-helpers.js.map +1 -1
  5. package/dist/firestore/types.d.ts +10 -4
  6. package/dist/firestore/types.d.ts.map +1 -1
  7. package/dist/firestore/types.js.map +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/react/firestore/subscription-status.d.ts +21 -0
  13. package/dist/react/firestore/subscription-status.d.ts.map +1 -0
  14. package/dist/react/firestore/subscription-status.js +29 -0
  15. package/dist/react/firestore/subscription-status.js.map +1 -0
  16. package/dist/react/firestore/use-firestore-collection.d.ts.map +1 -1
  17. package/dist/react/firestore/use-firestore-collection.js +10 -13
  18. package/dist/react/firestore/use-firestore-collection.js.map +1 -1
  19. package/dist/react/firestore/use-firestore-doc.d.ts.map +1 -1
  20. package/dist/react/firestore/use-firestore-doc.js +10 -13
  21. package/dist/react/firestore/use-firestore-doc.js.map +1 -1
  22. package/dist/react/firestore/use-firestore-live-infinite.d.ts +31 -14
  23. package/dist/react/firestore/use-firestore-live-infinite.d.ts.map +1 -1
  24. package/dist/react/firestore/use-firestore-live-infinite.js +192 -45
  25. package/dist/react/firestore/use-firestore-live-infinite.js.map +1 -1
  26. package/dist/react/firestore/use-firestore-paginated.d.ts +27 -24
  27. package/dist/react/firestore/use-firestore-paginated.d.ts.map +1 -1
  28. package/dist/react/firestore/use-firestore-paginated.js +102 -92
  29. package/dist/react/firestore/use-firestore-paginated.js.map +1 -1
  30. package/package.json +1 -1
@@ -1,122 +1,132 @@
1
1
  'use client';
2
- import { useState, useCallback, useMemo } from 'react';
2
+ import { useState, useCallback, useEffect, useRef } from 'react';
3
3
  import { useQuery, useQueryClient } from '@tanstack/react-query';
4
4
  import { collection, query, getDocs, limit, startAfter, } from 'firebase/firestore';
5
5
  import { useFirestoreDb } from './context.js';
6
+ import { paginatedPageKey } from '../../cache-helpers.js';
6
7
  const DEFAULT_PAGE_SIZE = 10;
7
- const MAX_CACHED_CURSORS = 20;
8
8
  /**
9
- * Page-based pagination for Firestore collections.
10
- * Supports navigating to specific pages (1, 2, 3...) rather than infinite scroll.
11
- * * @example
9
+ * Page-based pagination for Firestore collections (Previous / Next, one page at a time).
10
+ *
11
+ * Each page is ONE cache entry at `[...queryKey, 'page', n]` holding its rows, its last
12
+ * row as the next page's cursor, and whether a further page exists — read with a one-row
13
+ * look-ahead, so `hasNextPage` is false exactly when nothing follows the displayed page.
14
+ * Page n reads page n-1's cursor from the cache (loading the chain first if it is
15
+ * missing) and records it as `after`. A displayed page whose `after` no longer matches
16
+ * page n-1's cached cursor (page n-1 was re-read and its last row moved) is re-read before
17
+ * it is shown, so every instance, remount, and refetch of a key agrees on where a page
18
+ * starts. The page number belongs to the query identity (path, key, constraints, page
19
+ * size): a new identity renders `initialPage` in its first render.
20
+ *
21
+ * `queryKey` must encode everything that changes the read — path, filters, ordering, and
22
+ * page size — because the cache entries are shared by key.
23
+ *
24
+ * @example
12
25
  * ```tsx
13
- * const {
14
- * data,
15
- * page,
16
- * setPage,
17
- * hasNextPage,
18
- * hasPrevPage,
19
- * isLoading,
20
- * } = useFirestorePaginated<Task>({
21
- * collectionPath: 'tasks',
22
- * queryKey: ['tasks', 'list'],
23
- * constraints: [where('status', '==', 'active'), orderBy('createdAt', 'desc')],
24
- * pageSize: 10,
26
+ * const { data, page, nextPage, prevPage, hasNextPage, hasPrevPage } = useFirestorePaginated<Task>({
27
+ * collectionPath: 'tasks',
28
+ * queryKey: ['tasks', 'active', { pageSize: 10 }],
29
+ * constraints: [where('status', '==', 'active'), orderBy('createdAt', 'desc')],
30
+ * pageSize: 10,
25
31
  * });
26
- * * // Render pagination controls
27
- * <button onClick={() => setPage(page - 1)} disabled={!hasPrevPage}>Previous</button>
28
- * <span>Page {page}</span>
29
- * <button onClick={() => setPage(page + 1)} disabled={!hasNextPage}>Next</button>
30
32
  * ```
31
33
  */
32
- export function useFirestorePaginated({ collectionPath, queryKey, constraints = [], pageSize = DEFAULT_PAGE_SIZE, initialPage = 1, enabled = true, staleTime, gcTime, select, }) {
34
+ export function useFirestorePaginated({ collectionPath, queryKey, constraints = [], pageSize = DEFAULT_PAGE_SIZE, initialPage = 1, enabled = true, staleTime, gcTime, refetchInterval, select, }) {
33
35
  const db = useFirestoreDb();
34
36
  const queryClient = useQueryClient();
35
- const [page, setPageInternal] = useState(initialPage);
36
- const [cursors, setCursors] = useState(new Map());
37
- const [hasMore, setHasMore] = useState(true);
38
- // Create a stable query key that includes the page
39
- const pageQueryKey = useMemo(() => [...queryKey, 'page', page], [queryKey, page]);
40
- const queryResult = useQuery({
41
- queryKey: pageQueryKey,
42
- queryFn: async () => {
43
- const collectionRef = collection(db, collectionPath);
44
- // Get cursor for current page (if not page 1)
45
- const cursor = page > 1 ? cursors.get(page - 1) : undefined;
46
- // Build query
47
- const queryConstraints = [
48
- ...constraints,
49
- ...(cursor ? [startAfter(cursor)] : []),
50
- limit(pageSize),
51
- ];
52
- const q = query(collectionRef, ...queryConstraints);
53
- const snapshot = await getDocs(q);
54
- // Store the last doc as cursor for next page
55
- if (snapshot.docs.length > 0) {
56
- const lastDoc = snapshot.docs[snapshot.docs.length - 1];
57
- setCursors((prev) => {
58
- const next = new Map(prev);
59
- next.set(page, lastDoc);
60
- // Prune old cursors if too many (prevent memory leaks)
61
- if (next.size > MAX_CACHED_CURSORS) {
62
- const sorted = Array.from(next.keys()).sort((a, b) => a - b);
63
- const toDelete = sorted.slice(0, sorted.length - MAX_CACHED_CURSORS);
64
- toDelete.forEach((k) => next.delete(k));
65
- }
66
- return next;
67
- });
68
- }
69
- // Determine if there are more pages
70
- setHasMore(snapshot.docs.length === pageSize);
71
- return snapshot.docs.map((docSnap) => {
72
- const rawData = docSnap.data();
73
- // Include id in data passed to select, so it can be renamed/transformed
74
- const dataWithId = { id: docSnap.id, ...rawData };
75
- const data = select ? select(dataWithId) : dataWithId;
76
- return data;
37
+ const queryKeyMemo = JSON.stringify(queryKey);
38
+ const constraintsMemo = JSON.stringify(constraints);
39
+ const identity = JSON.stringify([collectionPath, queryKeyMemo, constraintsMemo, pageSize]);
40
+ const firstPage = Math.max(1, Math.floor(initialPage));
41
+ const [pageState, setPageState] = useState(() => ({ identity, page: firstPage }));
42
+ const page = pageState.identity === identity ? pageState.page : firstPage;
43
+ const selectRef = useRef(select);
44
+ selectRef.current = select;
45
+ const fetchPage = useCallback(async (n) => {
46
+ let cursor = null;
47
+ if (n > 1) {
48
+ const previous = await queryClient.ensureQueryData({
49
+ queryKey: paginatedPageKey(queryKey, n - 1),
50
+ queryFn: () => fetchPage(n - 1),
77
51
  });
78
- },
52
+ if (!previous.hasMore || !previous.cursor) {
53
+ return { items: [], cursor: null, hasMore: false, after: previous.cursor?.id ?? null };
54
+ }
55
+ cursor = previous.cursor;
56
+ }
57
+ const q = query(collection(db, collectionPath), ...constraints, ...(cursor ? [startAfter(cursor)] : []), limit(pageSize + 1));
58
+ const snapshot = await getDocs(q);
59
+ const pageDocs = snapshot.docs.slice(0, pageSize);
60
+ return {
61
+ items: pageDocs.map((docSnap) => {
62
+ const dataWithId = { id: docSnap.id, ...docSnap.data() };
63
+ const mapped = selectRef.current ? selectRef.current(dataWithId) : dataWithId;
64
+ return mapped;
65
+ }),
66
+ cursor: pageDocs[pageDocs.length - 1] ?? null,
67
+ hasMore: snapshot.docs.length > pageSize,
68
+ after: cursor?.id ?? null,
69
+ };
70
+ },
71
+ // queryKey and constraints are captured through their serialized identity.
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ [db, queryClient, identity, selectRef]);
74
+ const queryResult = useQuery({
75
+ queryKey: paginatedPageKey(queryKey, page),
76
+ queryFn: () => fetchPage(page),
79
77
  enabled,
80
78
  staleTime,
81
79
  gcTime,
80
+ refetchInterval,
82
81
  });
83
- const setPage = useCallback((newPage) => {
84
- if (newPage < 1)
85
- return;
86
- // Can only go forward one page at a time if cursor doesn't exist
87
- if (newPage > page + 1 && !cursors.has(newPage - 1)) {
88
- console.warn('[useFirestorePaginated] Cannot skip pages. Navigate sequentially.');
82
+ // The page before the displayed one, observed from the cache only (never fetched here).
83
+ // When it is re-read and its last row moves, the displayed entry no longer starts where the
84
+ // chain says it must: it is re-read before it is shown as current.
85
+ const previousPage = useQuery({
86
+ queryKey: paginatedPageKey(queryKey, Math.max(1, page - 1)),
87
+ queryFn: () => fetchPage(Math.max(1, page - 1)),
88
+ enabled: false,
89
+ });
90
+ const cachedEntry = queryResult.data;
91
+ const outOfChain = page > 1 &&
92
+ cachedEntry !== undefined &&
93
+ (previousPage.data === undefined || cachedEntry.after !== (previousPage.data.cursor?.id ?? null));
94
+ const { refetch, isFetching } = queryResult;
95
+ useEffect(() => {
96
+ if (enabled && outOfChain && !isFetching)
97
+ void refetch();
98
+ }, [enabled, outOfChain, isFetching, refetch]);
99
+ const entry = outOfChain ? undefined : cachedEntry;
100
+ const hasNextPage = Boolean(entry?.hasMore && entry.cursor);
101
+ const setPage = useCallback((target) => {
102
+ const next = Math.floor(target);
103
+ if (next < 1 || next === page)
89
104
  return;
105
+ if (next > page + 1) {
106
+ const before = queryClient.getQueryData(paginatedPageKey(queryKey, next - 1));
107
+ if (!before?.hasMore)
108
+ return;
90
109
  }
91
- setPageInternal(newPage);
92
- }, [page, cursors]);
93
- const nextPage = useCallback(() => {
94
- if (hasMore)
95
- setPage(page + 1);
96
- }, [page, hasMore, setPage]);
97
- const prevPage = useCallback(() => {
98
- if (page > 1)
99
- setPage(page - 1);
100
- }, [page, setPage]);
101
- const resetToFirstPage = useCallback(() => {
102
- setCursors(new Map());
103
- setPageInternal(1);
104
- setHasMore(true);
105
- // Invalidate all cached pages
106
- queryClient.invalidateQueries({ queryKey, exact: false });
107
- }, [queryClient, queryKey]);
110
+ if (next === page + 1 && !hasNextPage)
111
+ return;
112
+ setPageState({ identity, page: next });
113
+ },
114
+ // queryKey is captured through its serialized identity.
115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
116
+ [page, hasNextPage, identity, queryClient]);
117
+ const nextPage = useCallback(() => setPage(page + 1), [page, setPage]);
118
+ const prevPage = useCallback(() => setPage(page - 1), [page, setPage]);
108
119
  return {
109
120
  ...queryResult,
110
- // Pagination state
121
+ ...(outOfChain ? { isLoading: true, isPending: true, isSuccess: false, status: 'pending' } : {}),
122
+ data: entry?.items,
111
123
  page,
112
124
  pageSize,
113
- hasNextPage: hasMore,
125
+ hasNextPage,
114
126
  hasPrevPage: page > 1,
115
- // Navigation functions
116
127
  setPage,
117
128
  nextPage,
118
129
  prevPage,
119
- resetToFirstPage,
120
130
  };
121
131
  }
122
132
  //# sourceMappingURL=use-firestore-paginated.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-firestore-paginated.js","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-paginated.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useState, useCallback, useMemo } from 'react';\nimport { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';\nimport {\n collection,\n query,\n getDocs,\n limit,\n startAfter,\n type DocumentData,\n type DocumentSnapshot,\n} from 'firebase/firestore';\nimport { useFirestoreDb } from './context.js';\nimport type { FirestorePaginatedOptions, WithId } from '../../firestore/types.js';\n\nconst DEFAULT_PAGE_SIZE = 10;\nconst MAX_CACHED_CURSORS = 20;\n\n/**\n * Return type for useFirestorePaginated hook\n */\nexport type UseFirestorePaginatedResult<T> = UseQueryResult<WithId<T>[], Error> & {\n page: number;\n pageSize: number;\n hasNextPage: boolean;\n hasPrevPage: boolean;\n setPage: (page: number) => void;\n nextPage: () => void;\n prevPage: () => void;\n resetToFirstPage: () => void;\n};\n\n/**\n * Page-based pagination for Firestore collections.\n * Supports navigating to specific pages (1, 2, 3...) rather than infinite scroll.\n * * @example\n * ```tsx\n * const {\n * data,\n * page,\n * setPage,\n * hasNextPage,\n * hasPrevPage,\n * isLoading,\n * } = useFirestorePaginated<Task>({\n * collectionPath: 'tasks',\n * queryKey: ['tasks', 'list'],\n * constraints: [where('status', '==', 'active'), orderBy('createdAt', 'desc')],\n * pageSize: 10,\n * });\n * * // Render pagination controls\n * <button onClick={() => setPage(page - 1)} disabled={!hasPrevPage}>Previous</button>\n * <span>Page {page}</span>\n * <button onClick={() => setPage(page + 1)} disabled={!hasNextPage}>Next</button>\n * ```\n */\nexport function useFirestorePaginated<T extends DocumentData = DocumentData>({\n collectionPath,\n queryKey,\n constraints = [],\n pageSize = DEFAULT_PAGE_SIZE,\n initialPage = 1,\n enabled = true,\n staleTime,\n gcTime,\n select,\n}: FirestorePaginatedOptions<T>): UseFirestorePaginatedResult<T> {\n const db = useFirestoreDb();\n const queryClient = useQueryClient();\n\n const [page, setPageInternal] = useState(initialPage);\n const [cursors, setCursors] = useState<Map<number, DocumentSnapshot>>(new Map());\n const [hasMore, setHasMore] = useState(true);\n\n // Create a stable query key that includes the page\n const pageQueryKey = useMemo(\n () => [...queryKey, 'page', page],\n [queryKey, page]\n );\n\n const queryResult = useQuery({\n queryKey: pageQueryKey,\n queryFn: async (): Promise<WithId<T>[]> => {\n const collectionRef = collection(db, collectionPath);\n\n // Get cursor for current page (if not page 1)\n const cursor = page > 1 ? cursors.get(page - 1) : undefined;\n\n // Build query\n const queryConstraints = [\n ...constraints,\n ...(cursor ? [startAfter(cursor)] : []),\n limit(pageSize),\n ];\n\n const q = query(collectionRef, ...queryConstraints);\n const snapshot = await getDocs(q);\n\n // Store the last doc as cursor for next page\n if (snapshot.docs.length > 0) {\n const lastDoc = snapshot.docs[snapshot.docs.length - 1];\n setCursors((prev) => {\n const next = new Map(prev);\n next.set(page, lastDoc);\n\n // Prune old cursors if too many (prevent memory leaks)\n if (next.size > MAX_CACHED_CURSORS) {\n const sorted = Array.from(next.keys()).sort((a, b) => a - b);\n const toDelete = sorted.slice(0, sorted.length - MAX_CACHED_CURSORS);\n toDelete.forEach((k) => next.delete(k));\n }\n\n return next;\n });\n }\n\n // Determine if there are more pages\n setHasMore(snapshot.docs.length === pageSize);\n\n return snapshot.docs.map((docSnap) => {\n const rawData = docSnap.data();\n // Include id in data passed to select, so it can be renamed/transformed\n const dataWithId = { id: docSnap.id, ...rawData };\n const data = select ? select(dataWithId) : dataWithId;\n return data as WithId<T>;\n });\n },\n enabled,\n staleTime,\n gcTime,\n });\n\n const setPage = useCallback((newPage: number) => {\n if (newPage < 1) return;\n\n // Can only go forward one page at a time if cursor doesn't exist\n if (newPage > page + 1 && !cursors.has(newPage - 1)) {\n console.warn('[useFirestorePaginated] Cannot skip pages. Navigate sequentially.');\n return;\n }\n\n setPageInternal(newPage);\n }, [page, cursors]);\n\n const nextPage = useCallback(() => {\n if (hasMore) setPage(page + 1);\n }, [page, hasMore, setPage]);\n\n const prevPage = useCallback(() => {\n if (page > 1) setPage(page - 1);\n }, [page, setPage]);\n\n const resetToFirstPage = useCallback(() => {\n setCursors(new Map());\n setPageInternal(1);\n setHasMore(true);\n // Invalidate all cached pages\n queryClient.invalidateQueries({ queryKey, exact: false });\n }, [queryClient, queryKey]);\n\n return {\n ...queryResult,\n // Pagination state\n page,\n pageSize,\n hasNextPage: hasMore,\n hasPrevPage: page > 1,\n // Navigation functions\n setPage,\n nextPage,\n prevPage,\n resetToFirstPage,\n };\n}\n"]}
1
+ {"version":3,"file":"use-firestore-paginated.js","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-paginated.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useState, useCallback, useEffect, useRef } from 'react';\nimport { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';\nimport {\n collection,\n query,\n getDocs,\n limit,\n startAfter,\n type DocumentData,\n type DocumentSnapshot,\n} from 'firebase/firestore';\nimport { useFirestoreDb } from './context.js';\nimport { paginatedPageKey } from '../../cache-helpers.js';\nimport type { FirestorePaginatedOptions, PaginatedPage, WithId } from '../../firestore/types.js';\n\nconst DEFAULT_PAGE_SIZE = 10;\n\n/**\n * Return type for useFirestorePaginated hook. `data` is the displayed page's rows; the\n * page's cache entry (rows, cursor, has-more) lives at `paginatedPageKey(queryKey, page)`.\n */\nexport type UseFirestorePaginatedResult<T> = Omit<UseQueryResult<PaginatedPage<WithId<T>>, Error>, 'data'> & {\n data: WithId<T>[] | undefined;\n page: number;\n pageSize: number;\n hasNextPage: boolean;\n hasPrevPage: boolean;\n setPage: (page: number) => void;\n nextPage: () => void;\n prevPage: () => void;\n};\n\n/**\n * Page-based pagination for Firestore collections (Previous / Next, one page at a time).\n *\n * Each page is ONE cache entry at `[...queryKey, 'page', n]` holding its rows, its last\n * row as the next page's cursor, and whether a further page exists — read with a one-row\n * look-ahead, so `hasNextPage` is false exactly when nothing follows the displayed page.\n * Page n reads page n-1's cursor from the cache (loading the chain first if it is\n * missing) and records it as `after`. A displayed page whose `after` no longer matches\n * page n-1's cached cursor (page n-1 was re-read and its last row moved) is re-read before\n * it is shown, so every instance, remount, and refetch of a key agrees on where a page\n * starts. The page number belongs to the query identity (path, key, constraints, page\n * size): a new identity renders `initialPage` in its first render.\n *\n * `queryKey` must encode everything that changes the read — path, filters, ordering, and\n * page size — because the cache entries are shared by key.\n *\n * @example\n * ```tsx\n * const { data, page, nextPage, prevPage, hasNextPage, hasPrevPage } = useFirestorePaginated<Task>({\n * collectionPath: 'tasks',\n * queryKey: ['tasks', 'active', { pageSize: 10 }],\n * constraints: [where('status', '==', 'active'), orderBy('createdAt', 'desc')],\n * pageSize: 10,\n * });\n * ```\n */\nexport function useFirestorePaginated<T extends DocumentData = DocumentData>({\n collectionPath,\n queryKey,\n constraints = [],\n pageSize = DEFAULT_PAGE_SIZE,\n initialPage = 1,\n enabled = true,\n staleTime,\n gcTime,\n refetchInterval,\n select,\n}: FirestorePaginatedOptions<T>): UseFirestorePaginatedResult<T> {\n const db = useFirestoreDb();\n const queryClient = useQueryClient();\n\n const queryKeyMemo = JSON.stringify(queryKey);\n const constraintsMemo = JSON.stringify(constraints);\n const identity = JSON.stringify([collectionPath, queryKeyMemo, constraintsMemo, pageSize]);\n const firstPage = Math.max(1, Math.floor(initialPage));\n\n const [pageState, setPageState] = useState(() => ({ identity, page: firstPage }));\n const page = pageState.identity === identity ? pageState.page : firstPage;\n\n const selectRef = useRef(select);\n selectRef.current = select;\n\n const fetchPage = useCallback(\n async (n: number): Promise<PaginatedPage<WithId<T>>> => {\n let cursor: DocumentSnapshot | null = null;\n if (n > 1) {\n const previous = await queryClient.ensureQueryData<PaginatedPage<WithId<T>>>({\n queryKey: paginatedPageKey(queryKey, n - 1),\n queryFn: () => fetchPage(n - 1),\n });\n if (!previous.hasMore || !previous.cursor) {\n return { items: [], cursor: null, hasMore: false, after: previous.cursor?.id ?? null };\n }\n cursor = previous.cursor;\n }\n const q = query(\n collection(db, collectionPath),\n ...constraints,\n ...(cursor ? [startAfter(cursor)] : []),\n limit(pageSize + 1),\n );\n const snapshot = await getDocs(q);\n const pageDocs = snapshot.docs.slice(0, pageSize);\n return {\n items: pageDocs.map((docSnap) => {\n const dataWithId = { id: docSnap.id, ...docSnap.data() };\n const mapped = selectRef.current ? selectRef.current(dataWithId) : dataWithId;\n return mapped as WithId<T>;\n }),\n cursor: pageDocs[pageDocs.length - 1] ?? null,\n hasMore: snapshot.docs.length > pageSize,\n after: cursor?.id ?? null,\n };\n },\n // queryKey and constraints are captured through their serialized identity.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [db, queryClient, identity, selectRef],\n );\n\n const queryResult = useQuery({\n queryKey: paginatedPageKey(queryKey, page),\n queryFn: () => fetchPage(page),\n enabled,\n staleTime,\n gcTime,\n refetchInterval,\n });\n\n // The page before the displayed one, observed from the cache only (never fetched here).\n // When it is re-read and its last row moves, the displayed entry no longer starts where the\n // chain says it must: it is re-read before it is shown as current.\n const previousPage = useQuery({\n queryKey: paginatedPageKey(queryKey, Math.max(1, page - 1)),\n queryFn: () => fetchPage(Math.max(1, page - 1)),\n enabled: false,\n });\n const cachedEntry = queryResult.data;\n const outOfChain =\n page > 1 &&\n cachedEntry !== undefined &&\n (previousPage.data === undefined || cachedEntry.after !== (previousPage.data.cursor?.id ?? null));\n const { refetch, isFetching } = queryResult;\n useEffect(() => {\n if (enabled && outOfChain && !isFetching) void refetch();\n }, [enabled, outOfChain, isFetching, refetch]);\n\n const entry = outOfChain ? undefined : cachedEntry;\n const hasNextPage = Boolean(entry?.hasMore && entry.cursor);\n\n const setPage = useCallback(\n (target: number) => {\n const next = Math.floor(target);\n if (next < 1 || next === page) return;\n if (next > page + 1) {\n const before = queryClient.getQueryData<PaginatedPage<unknown>>(\n paginatedPageKey(queryKey, next - 1),\n );\n if (!before?.hasMore) return;\n }\n if (next === page + 1 && !hasNextPage) return;\n setPageState({ identity, page: next });\n },\n // queryKey is captured through its serialized identity.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [page, hasNextPage, identity, queryClient],\n );\n\n const nextPage = useCallback(() => setPage(page + 1), [page, setPage]);\n const prevPage = useCallback(() => setPage(page - 1), [page, setPage]);\n\n return {\n ...queryResult,\n ...(outOfChain ? { isLoading: true, isPending: true, isSuccess: false, status: 'pending' as const } : {}),\n data: entry?.items,\n page,\n pageSize,\n hasNextPage,\n hasPrevPage: page > 1,\n setPage,\n nextPage,\n prevPage,\n } as UseFirestorePaginatedResult<T>;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttt-productions/query-core",
3
- "version": "0.14.5",
3
+ "version": "0.14.6",
4
4
  "description": "Shared TanStack Query (React Query) client defaults, query key helpers, and Firestore integration hooks for TTT Productions apps",
5
5
  "repository": {
6
6
  "type": "git",