@ttt-productions/query-core 0.14.5 → 0.14.7

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 (38) 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 +3 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -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/dist/react/search/use-firestore-search.d.ts +3 -1
  31. package/dist/react/search/use-firestore-search.d.ts.map +1 -1
  32. package/dist/react/search/use-firestore-search.js +50 -5
  33. package/dist/react/search/use-firestore-search.js.map +1 -1
  34. package/dist/search/rule.d.ts +10 -0
  35. package/dist/search/rule.d.ts.map +1 -0
  36. package/dist/search/rule.js +14 -0
  37. package/dist/search/rule.js.map +1 -0
  38. 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"]}
@@ -9,7 +9,9 @@ import type { WithId } from '../../firestore/types.js';
9
9
  * Features:
10
10
  * - Debounced input (default 300ms)
11
11
  * - Case-insensitive search
12
- * - Minimum 3 character requirement
12
+ * - Searches only a text `isSearchableText` accepts (`FIRESTORE_SEARCH_MIN_LENGTH`, trimmed)
13
+ * - No answer while the debounce lags the typed text: `data` is undefined and `isLoading` is true
14
+ * (idle instead when the typed text is too short or the search is disabled)
13
15
  * - Prefix matching with '\uf8ff' suffix
14
16
  * - Configurable result limit
15
17
  *
@@ -1 +1 @@
1
- {"version":3,"file":"use-firestore-search.d.ts","sourceRoot":"","sources":["../../../src/react/search/use-firestore-search.ts"],"names":[],"mappings":"AAGA,OAAO,EAAY,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACtE,OAAO,EAOL,KAAK,YAAY,EAElB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AAMvD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,EACxE,cAAc,EACd,WAAW,EACX,SAAS,EACT,KAAqB,EACrB,OAAc,EACd,MAAM,EACN,UAAgC,EAChC,SAA8B,EAC9B,eAAoB,GACrB,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,KAAK,CAAC,CAiDhE"}
1
+ {"version":3,"file":"use-firestore-search.d.ts","sourceRoot":"","sources":["../../../src/react/search/use-firestore-search.ts"],"names":[],"mappings":"AAGA,OAAO,EAAY,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACtE,OAAO,EAOL,KAAK,YAAY,EAElB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AAOvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,EACxE,cAAc,EACd,WAAW,EACX,SAAS,EACT,KAAqB,EACrB,OAAc,EACd,MAAM,EACN,UAAgC,EAChC,SAA8B,EAC9B,eAAoB,GACrB,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,KAAK,CAAC,CAwDhE"}
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
4
4
  import { collection, getDocs, query, where, orderBy, limit as firestoreLimit, } from 'firebase/firestore';
5
5
  import { useFirestoreDb } from '../firestore/context.js';
6
6
  import { keys } from '../../keys.js';
7
+ import { isSearchableText, normalizeSearchText } from '../../search/rule.js';
7
8
  const DEFAULT_LIMIT = 5;
8
9
  const DEFAULT_DEBOUNCE_MS = 300;
9
10
  const DEFAULT_STALE_TIME = 60 * 1000; // 1 minute
@@ -14,7 +15,9 @@ const DEFAULT_STALE_TIME = 60 * 1000; // 1 minute
14
15
  * Features:
15
16
  * - Debounced input (default 300ms)
16
17
  * - Case-insensitive search
17
- * - Minimum 3 character requirement
18
+ * - Searches only a text `isSearchableText` accepts (`FIRESTORE_SEARCH_MIN_LENGTH`, trimmed)
19
+ * - No answer while the debounce lags the typed text: `data` is undefined and `isLoading` is true
20
+ * (idle instead when the typed text is too short or the search is disabled)
18
21
  * - Prefix matching with '\uf8ff' suffix
19
22
  * - Configurable result limit
20
23
  *
@@ -44,10 +47,9 @@ export function useFirestoreSearch({ collectionPath, searchField, queryText, lim
44
47
  }, debounceMs);
45
48
  return () => clearTimeout(timer);
46
49
  }, [queryText, debounceMs]);
47
- // Normalize query text
48
- const normalizedQuery = debouncedQuery.toLowerCase().trim();
49
- const shouldSearch = normalizedQuery.length >= 3 && enabled;
50
- return useQuery({
50
+ const normalizedQuery = normalizeSearchText(debouncedQuery);
51
+ const shouldSearch = enabled && isSearchableText(debouncedQuery);
52
+ const searchQuery = useQuery({
51
53
  queryKey: keys.custom('search', collectionPath, searchField, normalizedQuery, JSON.stringify(equalityFilters)),
52
54
  queryFn: async () => {
53
55
  if (!shouldSearch) {
@@ -75,5 +77,48 @@ export function useFirestoreSearch({ collectionPath, searchField, queryText, lim
75
77
  enabled: shouldSearch,
76
78
  staleTime,
77
79
  });
80
+ // Until the debounce catches up, the query's key holds an earlier text, so its result (or its
81
+ // absence) is not an answer for the text typed now (FRONTEND-206): report no answer instead —
82
+ // still searching when the typed text will be searched, idle when it will not.
83
+ const typedQuery = normalizeSearchText(queryText);
84
+ if (typedQuery === normalizedQuery)
85
+ return searchQuery;
86
+ const willSearch = enabled && isSearchableText(queryText);
87
+ return withoutAnswer(searchQuery, willSearch);
88
+ }
89
+ /**
90
+ * The query result with every answer field replaced. The other fields stay getters onto the
91
+ * original result: TanStack tracks which fields a component reads to decide when it re-renders,
92
+ * and reading them all here (a spread) would opt every consumer out of that.
93
+ */
94
+ function withoutAnswer(result, searching) {
95
+ const answerFields = {
96
+ data: undefined,
97
+ error: null,
98
+ isError: false,
99
+ isLoadingError: false,
100
+ isRefetchError: false,
101
+ isSuccess: false,
102
+ isPending: true,
103
+ isPlaceholderData: false,
104
+ isFetched: false,
105
+ isFetchedAfterMount: false,
106
+ isRefetching: false,
107
+ isLoading: searching,
108
+ isFetching: searching,
109
+ status: 'pending',
110
+ fetchStatus: searching ? 'fetching' : 'idle',
111
+ dataUpdatedAt: 0,
112
+ errorUpdatedAt: 0,
113
+ };
114
+ const view = {};
115
+ for (const key of Object.keys(result)) {
116
+ Object.defineProperty(view, key, {
117
+ enumerable: true,
118
+ configurable: true,
119
+ get: key in answerFields ? () => answerFields[key] : () => result[key],
120
+ });
121
+ }
122
+ return view;
78
123
  }
79
124
  //# sourceMappingURL=use-firestore-search.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-firestore-search.js","sourceRoot":"","sources":["../../../src/react/search/use-firestore-search.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useState, useEffect } from 'react';\nimport { useQuery, type UseQueryResult } from '@tanstack/react-query';\nimport {\n collection,\n getDocs,\n query,\n where,\n orderBy,\n limit as firestoreLimit,\n type DocumentData,\n type QueryConstraint,\n} from 'firebase/firestore';\nimport { useFirestoreDb } from '../firestore/context.js';\nimport { keys } from '../../keys.js';\nimport type { FirestoreSearchOptions } from '../../search/types.js';\nimport type { WithId } from '../../firestore/types.js';\n\nconst DEFAULT_LIMIT = 5;\nconst DEFAULT_DEBOUNCE_MS = 300;\nconst DEFAULT_STALE_TIME = 60 * 1000; // 1 minute\n\n/**\n * Generic Firestore search hook with debouncing and case-insensitive prefix matching.\n * Automatically normalizes search text to lowercase and uses Firestore range queries.\n *\n * Features:\n * - Debounced input (default 300ms)\n * - Case-insensitive search\n * - Minimum 3 character requirement\n * - Prefix matching with '\\uf8ff' suffix\n * - Configurable result limit\n *\n * @example\n * ```typescript\n * const { data, isLoading } = useFirestoreSearch<UserProfile>({\n * collectionPath: 'userProfiles',\n * searchField: 'username',\n * queryText: searchValue,\n * });\n *\n * const { data } = useFirestoreSearch<Team>({\n * collectionPath: 'teams',\n * searchField: 'name_lowercase',\n * queryText: searchValue,\n * limit: 10,\n * });\n * ```\n */\nexport function useFirestoreSearch<T extends DocumentData = DocumentData>({\n collectionPath,\n searchField,\n queryText,\n limit = DEFAULT_LIMIT,\n enabled = true,\n select,\n debounceMs = DEFAULT_DEBOUNCE_MS,\n staleTime = DEFAULT_STALE_TIME,\n equalityFilters = [],\n}: FirestoreSearchOptions<T>): UseQueryResult<WithId<T>[], Error> {\n const db = useFirestoreDb();\n const [debouncedQuery, setDebouncedQuery] = useState(queryText);\n\n // Debounce the search query\n useEffect(() => {\n const timer = setTimeout(() => {\n setDebouncedQuery(queryText);\n }, debounceMs);\n\n return () => clearTimeout(timer);\n }, [queryText, debounceMs]);\n\n // Normalize query text\n const normalizedQuery = debouncedQuery.toLowerCase().trim();\n const shouldSearch = normalizedQuery.length >= 3 && enabled;\n\n return useQuery({\n queryKey: keys.custom('search', collectionPath, searchField, normalizedQuery, JSON.stringify(equalityFilters)),\n queryFn: async (): Promise<WithId<T>[]> => {\n if (!shouldSearch) {\n return [];\n }\n\n const collectionRef = collection(db, collectionPath);\n\n // Build range query for prefix matching\n // Using '\\uf8ff' as upper bound creates a range that matches all strings starting with the query\n const constraints: QueryConstraint[] = [\n ...equalityFilters.map((f) => where(f.field, '==', f.value)),\n where(searchField, '>=', normalizedQuery),\n where(searchField, '<=', normalizedQuery + '\\uf8ff'),\n orderBy(searchField, 'asc'),\n firestoreLimit(limit),\n ];\n\n const q = query(collectionRef, ...constraints);\n const snapshot = await getDocs(q);\n\n return snapshot.docs.map((docSnap) => {\n const rawData = docSnap.data();\n const dataWithId = { id: docSnap.id, ...rawData };\n const data = select ? select(dataWithId) : dataWithId;\n return data as WithId<T>;\n });\n },\n enabled: shouldSearch,\n staleTime,\n });\n}\n"]}
1
+ {"version":3,"file":"use-firestore-search.js","sourceRoot":"","sources":["../../../src/react/search/use-firestore-search.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useState, useEffect } from 'react';\nimport { useQuery, type UseQueryResult } from '@tanstack/react-query';\nimport {\n collection,\n getDocs,\n query,\n where,\n orderBy,\n limit as firestoreLimit,\n type DocumentData,\n type QueryConstraint,\n} from 'firebase/firestore';\nimport { useFirestoreDb } from '../firestore/context.js';\nimport { keys } from '../../keys.js';\nimport type { FirestoreSearchOptions } from '../../search/types.js';\nimport type { WithId } from '../../firestore/types.js';\nimport { isSearchableText, normalizeSearchText } from '../../search/rule.js';\n\nconst DEFAULT_LIMIT = 5;\nconst DEFAULT_DEBOUNCE_MS = 300;\nconst DEFAULT_STALE_TIME = 60 * 1000; // 1 minute\n\n/**\n * Generic Firestore search hook with debouncing and case-insensitive prefix matching.\n * Automatically normalizes search text to lowercase and uses Firestore range queries.\n *\n * Features:\n * - Debounced input (default 300ms)\n * - Case-insensitive search\n * - Searches only a text `isSearchableText` accepts (`FIRESTORE_SEARCH_MIN_LENGTH`, trimmed)\n * - No answer while the debounce lags the typed text: `data` is undefined and `isLoading` is true\n * (idle instead when the typed text is too short or the search is disabled)\n * - Prefix matching with '\\uf8ff' suffix\n * - Configurable result limit\n *\n * @example\n * ```typescript\n * const { data, isLoading } = useFirestoreSearch<UserProfile>({\n * collectionPath: 'userProfiles',\n * searchField: 'username',\n * queryText: searchValue,\n * });\n *\n * const { data } = useFirestoreSearch<Team>({\n * collectionPath: 'teams',\n * searchField: 'name_lowercase',\n * queryText: searchValue,\n * limit: 10,\n * });\n * ```\n */\nexport function useFirestoreSearch<T extends DocumentData = DocumentData>({\n collectionPath,\n searchField,\n queryText,\n limit = DEFAULT_LIMIT,\n enabled = true,\n select,\n debounceMs = DEFAULT_DEBOUNCE_MS,\n staleTime = DEFAULT_STALE_TIME,\n equalityFilters = [],\n}: FirestoreSearchOptions<T>): UseQueryResult<WithId<T>[], Error> {\n const db = useFirestoreDb();\n const [debouncedQuery, setDebouncedQuery] = useState(queryText);\n\n // Debounce the search query\n useEffect(() => {\n const timer = setTimeout(() => {\n setDebouncedQuery(queryText);\n }, debounceMs);\n\n return () => clearTimeout(timer);\n }, [queryText, debounceMs]);\n\n const normalizedQuery = normalizeSearchText(debouncedQuery);\n const shouldSearch = enabled && isSearchableText(debouncedQuery);\n\n const searchQuery = useQuery({\n queryKey: keys.custom('search', collectionPath, searchField, normalizedQuery, JSON.stringify(equalityFilters)),\n queryFn: async (): Promise<WithId<T>[]> => {\n if (!shouldSearch) {\n return [];\n }\n\n const collectionRef = collection(db, collectionPath);\n\n // Build range query for prefix matching\n // Using '\\uf8ff' as upper bound creates a range that matches all strings starting with the query\n const constraints: QueryConstraint[] = [\n ...equalityFilters.map((f) => where(f.field, '==', f.value)),\n where(searchField, '>=', normalizedQuery),\n where(searchField, '<=', normalizedQuery + '\\uf8ff'),\n orderBy(searchField, 'asc'),\n firestoreLimit(limit),\n ];\n\n const q = query(collectionRef, ...constraints);\n const snapshot = await getDocs(q);\n\n return snapshot.docs.map((docSnap) => {\n const rawData = docSnap.data();\n const dataWithId = { id: docSnap.id, ...rawData };\n const data = select ? select(dataWithId) : dataWithId;\n return data as WithId<T>;\n });\n },\n enabled: shouldSearch,\n staleTime,\n });\n\n // Until the debounce catches up, the query's key holds an earlier text, so its result (or its\n // absence) is not an answer for the text typed now (FRONTEND-206): report no answer instead —\n // still searching when the typed text will be searched, idle when it will not.\n const typedQuery = normalizeSearchText(queryText);\n if (typedQuery === normalizedQuery) return searchQuery;\n const willSearch = enabled && isSearchableText(queryText);\n return withoutAnswer(searchQuery, willSearch);\n}\n\n/**\n * The query result with every answer field replaced. The other fields stay getters onto the\n * original result: TanStack tracks which fields a component reads to decide when it re-renders,\n * and reading them all here (a spread) would opt every consumer out of that.\n */\nfunction withoutAnswer<R extends UseQueryResult<unknown, Error>>(result: R, searching: boolean): R {\n const answerFields: Record<string, unknown> = {\n data: undefined,\n error: null,\n isError: false,\n isLoadingError: false,\n isRefetchError: false,\n isSuccess: false,\n isPending: true,\n isPlaceholderData: false,\n isFetched: false,\n isFetchedAfterMount: false,\n isRefetching: false,\n isLoading: searching,\n isFetching: searching,\n status: 'pending',\n fetchStatus: searching ? 'fetching' : 'idle',\n dataUpdatedAt: 0,\n errorUpdatedAt: 0,\n };\n const view = {} as R;\n for (const key of Object.keys(result)) {\n Object.defineProperty(view, key, {\n enumerable: true,\n configurable: true,\n get: key in answerFields ? () => answerFields[key] : () => result[key as keyof R],\n });\n }\n return view;\n}\n\n"]}
@@ -0,0 +1,10 @@
1
+ /** The fewest characters, counted on the normalized text, that `useFirestoreSearch` searches. */
2
+ export declare const FIRESTORE_SEARCH_MIN_LENGTH = 3;
3
+ /**
4
+ * The text `useFirestoreSearch` reads for a typed text: lowercased, surrounding whitespace dropped.
5
+ * Two typed texts with the same normalized text are the same search.
6
+ */
7
+ export declare function normalizeSearchText(text: string): string;
8
+ /** Whether `useFirestoreSearch` searches this typed text (its normalized length reaches the minimum). */
9
+ export declare function isSearchableText(text: string): boolean;
10
+ //# sourceMappingURL=rule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rule.d.ts","sourceRoot":"","sources":["../../src/search/rule.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAE7C;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,yGAAyG;AACzG,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD"}
@@ -0,0 +1,14 @@
1
+ /** The fewest characters, counted on the normalized text, that `useFirestoreSearch` searches. */
2
+ export const FIRESTORE_SEARCH_MIN_LENGTH = 3;
3
+ /**
4
+ * The text `useFirestoreSearch` reads for a typed text: lowercased, surrounding whitespace dropped.
5
+ * Two typed texts with the same normalized text are the same search.
6
+ */
7
+ export function normalizeSearchText(text) {
8
+ return text.toLowerCase().trim();
9
+ }
10
+ /** Whether `useFirestoreSearch` searches this typed text (its normalized length reaches the minimum). */
11
+ export function isSearchableText(text) {
12
+ return normalizeSearchText(text).length >= FIRESTORE_SEARCH_MIN_LENGTH;
13
+ }
14
+ //# sourceMappingURL=rule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rule.js","sourceRoot":"","sources":["../../src/search/rule.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC;AAE7C;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAC;AACnC,CAAC;AAED,yGAAyG;AACzG,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,OAAO,mBAAmB,CAAC,IAAI,CAAC,CAAC,MAAM,IAAI,2BAA2B,CAAC;AACzE,CAAC","sourcesContent":["/** The fewest characters, counted on the normalized text, that `useFirestoreSearch` searches. */\nexport const FIRESTORE_SEARCH_MIN_LENGTH = 3;\n\n/**\n * The text `useFirestoreSearch` reads for a typed text: lowercased, surrounding whitespace dropped.\n * Two typed texts with the same normalized text are the same search.\n */\nexport function normalizeSearchText(text: string): string {\n return text.toLowerCase().trim();\n}\n\n/** Whether `useFirestoreSearch` searches this typed text (its normalized length reaches the minimum). */\nexport function isSearchableText(text: string): boolean {\n return normalizeSearchText(text).length >= FIRESTORE_SEARCH_MIN_LENGTH;\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.7",
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",