@ttt-productions/query-core 0.14.4 → 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.
- package/dist/cache-helpers.d.ts +17 -0
- package/dist/cache-helpers.d.ts.map +1 -1
- package/dist/cache-helpers.js +48 -0
- package/dist/cache-helpers.js.map +1 -1
- package/dist/firestore/types.d.ts +31 -4
- package/dist/firestore/types.d.ts.map +1 -1
- package/dist/firestore/types.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react/firestore/doc-subscription-registry.d.ts +7 -1
- package/dist/react/firestore/doc-subscription-registry.d.ts.map +1 -1
- package/dist/react/firestore/doc-subscription-registry.js +24 -6
- package/dist/react/firestore/doc-subscription-registry.js.map +1 -1
- package/dist/react/firestore/subscription-status.d.ts +21 -0
- package/dist/react/firestore/subscription-status.d.ts.map +1 -0
- package/dist/react/firestore/subscription-status.js +29 -0
- package/dist/react/firestore/subscription-status.js.map +1 -0
- package/dist/react/firestore/use-firestore-collection.d.ts.map +1 -1
- package/dist/react/firestore/use-firestore-collection.js +10 -13
- package/dist/react/firestore/use-firestore-collection.js.map +1 -1
- package/dist/react/firestore/use-firestore-doc.d.ts.map +1 -1
- package/dist/react/firestore/use-firestore-doc.js +10 -13
- package/dist/react/firestore/use-firestore-doc.js.map +1 -1
- package/dist/react/firestore/use-firestore-live-infinite.d.ts +31 -14
- package/dist/react/firestore/use-firestore-live-infinite.d.ts.map +1 -1
- package/dist/react/firestore/use-firestore-live-infinite.js +192 -45
- package/dist/react/firestore/use-firestore-live-infinite.js.map +1 -1
- package/dist/react/firestore/use-firestore-paginated.d.ts +27 -24
- package/dist/react/firestore/use-firestore-paginated.d.ts.map +1 -1
- package/dist/react/firestore/use-firestore-paginated.js +102 -92
- package/dist/react/firestore/use-firestore-paginated.js.map +1 -1
- package/dist/react/firestore/useBatchFirestoreDocs.d.ts +12 -0
- package/dist/react/firestore/useBatchFirestoreDocs.d.ts.map +1 -1
- package/dist/react/firestore/useBatchFirestoreDocs.js +80 -26
- package/dist/react/firestore/useBatchFirestoreDocs.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/package.json +3 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-firestore-collection.js","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-collection.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useEffect, useState } from 'react';\nimport { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';\nimport {\n collection,\n query,\n getDocs,\n onSnapshot,\n type DocumentData,\n} from 'firebase/firestore';\nimport { useFirestoreDb } from './context.js';\nimport { RESUBSCRIBE_DELAYS_MS, isPermissionDeniedError } from './resubscribe.js';\nimport type {\n FirestoreCollectionOptions,\n FirestoreSourceState,\n WithId,\n WithSourceState,\n} from '../../firestore/types.js';\n\n/**\n * Fetch all documents from a Firestore collection with optional realtime updates.\n * Use this for small collections where you need all documents at once.\n * For large collections, use useFirestoreInfinite or useFirestorePaginated.\n * * @example\n * ```tsx\n * // Fetch all channels in an entity\n * const { data: channels } = useFirestoreCollection<Channel>({\n * collectionPath: `entities/${entityId}/channels`,\n * queryKey: ['channels', entityId],\n * constraints: [orderBy('createdAt', 'asc')],\n * });\n * * // With realtime updates\n * const { data: members } = useFirestoreCollection<Member>({\n * collectionPath: `entities/${entityId}/members`,\n * queryKey: ['members', entityId],\n * subscribe: true,\n * });\n * ```\n */\nexport function useFirestoreCollection<T extends DocumentData = DocumentData>({\n collectionPath,\n queryKey,\n constraints = [],\n enabled = true,\n subscribe = false,\n staleTime,\n gcTime,\n select,\n}: FirestoreCollectionOptions<T>): WithSourceState<UseQueryResult<WithId<T>[], Error>> {\n const db = useFirestoreDb();\n const queryClient = useQueryClient();\n\n // STABILIZE DEPENDENCIES:\n // We stringify these because [where('a', '==', 'b')] !== [where('a', '==', 'b')] in JS.\n const constraintsMemo = JSON.stringify(constraints);\n const queryKeyMemo = JSON.stringify(queryKey);\n\n // Listener errors can't surface through useQuery (the queryFn is disabled while\n // subscribed), so they're tracked here and merged into the returned result.\n const [subscriptionError, setSubscriptionError] = useState<Error | null>(null);\n // Metadata-derived source state — reset whenever the subscription identity changes.\n const [sourceState, setSourceState] = useState<FirestoreSourceState>('connecting');\n\n useEffect(() => {\n if (!subscribe || !enabled) return;\n\n setSubscriptionError(null);\n setSourceState('connecting');\n\n // Bounded resubscribe: a transient `permission-denied` (e.g. a failed mid-session\n // App Check token refresh) is retried on the RESUBSCRIBE_DELAYS_MS ladder before the\n // error is surfaced. `attempt` resets on any healthy snapshot; `disposed`/timers are\n // cleaned up on unmount so a pending retry never fires after teardown.\n let currentUnsubscribe: (() => void) | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let attempt = 0;\n let disposed = false;\n\n const start = () => {\n if (disposed) return;\n const collectionRef = collection(db, collectionPath);\n const q = constraints.length > 0\n ? query(collectionRef, ...constraints)\n : collectionRef;\n\n currentUnsubscribe = onSnapshot(\n q,\n { includeMetadataChanges: true },\n (snapshot) => {\n attempt = 0;\n setSubscriptionError(null);\n const items = 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 queryClient.setQueryData(queryKey, items);\n const fromCache = snapshot.metadata?.fromCache ?? false;\n setSourceState((prev) => (fromCache ? (prev === 'connecting' ? 'connecting' : 'offline') : 'live'));\n },\n (error) => {\n // Heal a transient permission-denied by resubscribing on the backoff ladder,\n // keeping the last-known data visible. Only surface the error once the ladder\n // is exhausted; any other error surfaces immediately, as before.\n if (isPermissionDeniedError(error) && attempt < RESUBSCRIBE_DELAYS_MS.length) {\n currentUnsubscribe?.();\n currentUnsubscribe = null;\n const delay = RESUBSCRIBE_DELAYS_MS[attempt];\n attempt += 1;\n setSourceState('connecting');\n retryTimer = setTimeout(start, delay);\n return;\n }\n console.error('[useFirestoreCollection] Subscription error:', error);\n setSubscriptionError(error);\n setSourceState('error');\n queryClient.setQueryData(queryKey, undefined);\n }\n );\n };\n\n start();\n\n return () => {\n disposed = true;\n if (retryTimer) clearTimeout(retryTimer);\n currentUnsubscribe?.();\n };\n // queryKey & constraints are tracked via their stringified forms above; select is intentionally\n // excluded so an inline caller function doesn't force a re-subscribe on every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [db, collectionPath, queryKeyMemo, constraintsMemo, enabled, subscribe, queryClient]);\n\n const queryResult = useQuery({\n queryKey,\n queryFn: async (): Promise<WithId<T>[]> => {\n const collectionRef = collection(db, collectionPath);\n const q = constraints.length > 0\n ? query(collectionRef, ...constraints)\n : collectionRef;\n\n const snapshot = await getDocs(q);\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: enabled && !subscribe,\n staleTime: subscribe ? Infinity : staleTime,\n gcTime,\n });\n\n // Merge a listener error into the result so subscribed consumers see a truthful\n // isError/error/status instead of a forever-pending disabled query.\n if (subscribe && subscriptionError) {\n return {\n ...queryResult,\n data: undefined,\n error: subscriptionError,\n isError: true,\n isLoadingError: true,\n isSuccess: false,\n isPending: false,\n isLoading: false,\n status: 'error',\n sourceState,\n } as WithSourceState<UseQueryResult<WithId<T>[], Error>>;\n }\n\n return { ...queryResult, sourceState } as WithSourceState<UseQueryResult<WithId<T>[], Error>>;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"use-firestore-collection.js","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-collection.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useEffect } from 'react';\nimport { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';\nimport {\n collection,\n query,\n getDocs,\n onSnapshot,\n type DocumentData,\n} from 'firebase/firestore';\nimport { useFirestoreDb } from './context.js';\nimport { RESUBSCRIBE_DELAYS_MS, isPermissionDeniedError } from './resubscribe.js';\nimport { useSubscriptionStatus } from './subscription-status.js';\nimport type {\n FirestoreCollectionOptions,\n WithId,\n WithSourceState,\n} from '../../firestore/types.js';\n\n/**\n * Fetch all documents from a Firestore collection with optional realtime updates.\n * Use this for small collections where you need all documents at once.\n * For large collections, use useFirestoreInfinite or useFirestorePaginated.\n * * @example\n * ```tsx\n * // Fetch all channels in an entity\n * const { data: channels } = useFirestoreCollection<Channel>({\n * collectionPath: `entities/${entityId}/channels`,\n * queryKey: ['channels', entityId],\n * constraints: [orderBy('createdAt', 'asc')],\n * });\n * * // With realtime updates\n * const { data: members } = useFirestoreCollection<Member>({\n * collectionPath: `entities/${entityId}/members`,\n * queryKey: ['members', entityId],\n * subscribe: true,\n * });\n * ```\n */\nexport function useFirestoreCollection<T extends DocumentData = DocumentData>({\n collectionPath,\n queryKey,\n constraints = [],\n enabled = true,\n subscribe = false,\n staleTime,\n gcTime,\n select,\n}: FirestoreCollectionOptions<T>): WithSourceState<UseQueryResult<WithId<T>[], Error>> {\n const db = useFirestoreDb();\n const queryClient = useQueryClient();\n\n // STABILIZE DEPENDENCIES:\n // We stringify these because [where('a', '==', 'b')] !== [where('a', '==', 'b')] in JS.\n const constraintsMemo = JSON.stringify(constraints);\n const queryKeyMemo = JSON.stringify(queryKey);\n\n // Listener errors can't surface through useQuery (the queryFn is disabled while\n // subscribed), so they're tracked per subscription identity and merged into the result.\n const {\n error: subscriptionError,\n sourceState,\n reset: resetStatus,\n update: updateStatus,\n } = useSubscriptionStatus(\n JSON.stringify([collectionPath, queryKeyMemo, constraintsMemo, enabled, subscribe]),\n );\n\n useEffect(() => {\n if (!subscribe || !enabled) return;\n\n resetStatus();\n\n // Bounded resubscribe: a transient `permission-denied` (e.g. a failed mid-session\n // App Check token refresh) is retried on the RESUBSCRIBE_DELAYS_MS ladder before the\n // error is surfaced. `attempt` resets on any healthy snapshot; `disposed`/timers are\n // cleaned up on unmount so a pending retry never fires after teardown.\n let currentUnsubscribe: (() => void) | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let attempt = 0;\n let disposed = false;\n\n const start = () => {\n if (disposed) return;\n const collectionRef = collection(db, collectionPath);\n const q = constraints.length > 0\n ? query(collectionRef, ...constraints)\n : collectionRef;\n\n currentUnsubscribe = onSnapshot(\n q,\n { includeMetadataChanges: true },\n (snapshot) => {\n attempt = 0;\n updateStatus({ error: null });\n const items = 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 queryClient.setQueryData(queryKey, items);\n const fromCache = snapshot.metadata?.fromCache ?? false;\n updateStatus((prev) => ({ sourceState: fromCache ? (prev.sourceState === 'connecting' ? 'connecting' : 'offline') : 'live' }));\n },\n (error) => {\n // Heal a transient permission-denied by resubscribing on the backoff ladder,\n // keeping the last-known data visible. Only surface the error once the ladder\n // is exhausted; any other error surfaces immediately, as before.\n if (isPermissionDeniedError(error) && attempt < RESUBSCRIBE_DELAYS_MS.length) {\n currentUnsubscribe?.();\n currentUnsubscribe = null;\n const delay = RESUBSCRIBE_DELAYS_MS[attempt];\n attempt += 1;\n updateStatus({ sourceState: 'connecting' });\n retryTimer = setTimeout(start, delay);\n return;\n }\n console.error('[useFirestoreCollection] Subscription error:', error);\n updateStatus({ error, sourceState: 'error' });\n queryClient.setQueryData(queryKey, undefined);\n }\n );\n };\n\n start();\n\n return () => {\n disposed = true;\n if (retryTimer) clearTimeout(retryTimer);\n currentUnsubscribe?.();\n };\n // queryKey & constraints are tracked via their stringified forms above; select is intentionally\n // excluded so an inline caller function doesn't force a re-subscribe on every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [db, collectionPath, queryKeyMemo, constraintsMemo, enabled, subscribe, queryClient, resetStatus, updateStatus]);\n\n const queryResult = useQuery({\n queryKey,\n queryFn: async (): Promise<WithId<T>[]> => {\n const collectionRef = collection(db, collectionPath);\n const q = constraints.length > 0\n ? query(collectionRef, ...constraints)\n : collectionRef;\n\n const snapshot = await getDocs(q);\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: enabled && !subscribe,\n staleTime: subscribe ? Infinity : staleTime,\n gcTime,\n });\n\n // Merge a listener error into the result so subscribed consumers see a truthful\n // isError/error/status instead of a forever-pending disabled query.\n if (subscribe && subscriptionError) {\n return {\n ...queryResult,\n data: undefined,\n error: subscriptionError,\n isError: true,\n isLoadingError: true,\n isSuccess: false,\n isPending: false,\n isLoading: false,\n status: 'error',\n sourceState,\n } as WithSourceState<UseQueryResult<WithId<T>[], Error>>;\n }\n\n return { ...queryResult, sourceState } as WithSourceState<UseQueryResult<WithId<T>[], Error>>;\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-firestore-doc.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-doc.ts"],"names":[],"mappings":"AAGA,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACtF,OAAO,EAA2B,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"use-firestore-doc.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-doc.ts"],"names":[],"mappings":"AAGA,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACtF,OAAO,EAA2B,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIhF,OAAO,KAAK,EACV,mBAAmB,EACnB,MAAM,EACN,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,EACrE,OAAO,EACP,QAAQ,EACR,OAAc,EACd,SAAiB,EACjB,SAAS,EACT,MAAM,EACN,MAAM,GACP,EAAE,mBAAmB,CAAC,CAAC,CAAC,GAAG,eAAe,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,CAAC,CAyHnF"}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useEffect
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
3
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
4
|
import { doc, getDoc, onSnapshot } from 'firebase/firestore';
|
|
5
5
|
import { useFirestoreDb } from './context.js';
|
|
6
6
|
import { RESUBSCRIBE_DELAYS_MS, isPermissionDeniedError } from './resubscribe.js';
|
|
7
|
+
import { useSubscriptionStatus } from './subscription-status.js';
|
|
7
8
|
/**
|
|
8
9
|
* Fetch a single Firestore document with optional realtime updates.
|
|
9
10
|
* * @example
|
|
@@ -32,16 +33,13 @@ export function useFirestoreDoc({ docPath, queryKey, enabled = true, subscribe =
|
|
|
32
33
|
const queryClient = useQueryClient();
|
|
33
34
|
const queryKeyMemo = JSON.stringify(queryKey);
|
|
34
35
|
// Listener errors can't surface through useQuery (the queryFn is disabled while
|
|
35
|
-
// subscribed), so they're tracked
|
|
36
|
-
const
|
|
37
|
-
// Metadata-derived source state — reset whenever the subscription identity changes.
|
|
38
|
-
const [sourceState, setSourceState] = useState('connecting');
|
|
36
|
+
// subscribed), so they're tracked per subscription identity and merged into the result.
|
|
37
|
+
const { error: subscriptionError, sourceState, reset: resetStatus, update: updateStatus, } = useSubscriptionStatus(JSON.stringify([docPath, queryKeyMemo, enabled, subscribe]));
|
|
39
38
|
// Set up realtime subscription
|
|
40
39
|
useEffect(() => {
|
|
41
40
|
if (!subscribe || !enabled)
|
|
42
41
|
return;
|
|
43
|
-
|
|
44
|
-
setSourceState('connecting');
|
|
42
|
+
resetStatus();
|
|
45
43
|
// Bounded resubscribe: a transient `permission-denied` (e.g. a failed mid-session
|
|
46
44
|
// App Check token refresh) is retried on the RESUBSCRIBE_DELAYS_MS ladder before the
|
|
47
45
|
// error is surfaced. `attempt` resets on any healthy snapshot; `disposed`/timers are
|
|
@@ -56,7 +54,7 @@ export function useFirestoreDoc({ docPath, queryKey, enabled = true, subscribe =
|
|
|
56
54
|
const docRef = doc(db, docPath);
|
|
57
55
|
currentUnsubscribe = onSnapshot(docRef, { includeMetadataChanges: true }, (snapshot) => {
|
|
58
56
|
attempt = 0;
|
|
59
|
-
|
|
57
|
+
updateStatus({ error: null });
|
|
60
58
|
if (snapshot.exists()) {
|
|
61
59
|
const rawData = snapshot.data();
|
|
62
60
|
// Include id in data passed to select, so it can be renamed/transformed
|
|
@@ -68,7 +66,7 @@ export function useFirestoreDoc({ docPath, queryKey, enabled = true, subscribe =
|
|
|
68
66
|
queryClient.setQueryData(queryKey, null);
|
|
69
67
|
}
|
|
70
68
|
const fromCache = snapshot.metadata?.fromCache ?? false;
|
|
71
|
-
|
|
69
|
+
updateStatus((prev) => ({ sourceState: fromCache ? (prev.sourceState === 'connecting' ? 'connecting' : 'offline') : 'live' }));
|
|
72
70
|
}, (error) => {
|
|
73
71
|
// Heal a transient permission-denied by resubscribing on the backoff ladder,
|
|
74
72
|
// keeping the last-known data visible. Only surface the error once the ladder
|
|
@@ -78,13 +76,12 @@ export function useFirestoreDoc({ docPath, queryKey, enabled = true, subscribe =
|
|
|
78
76
|
currentUnsubscribe = null;
|
|
79
77
|
const delay = RESUBSCRIBE_DELAYS_MS[attempt];
|
|
80
78
|
attempt += 1;
|
|
81
|
-
|
|
79
|
+
updateStatus({ sourceState: 'connecting' });
|
|
82
80
|
retryTimer = setTimeout(start, delay);
|
|
83
81
|
return;
|
|
84
82
|
}
|
|
85
83
|
console.error('[useFirestoreDoc] Subscription error:', error);
|
|
86
|
-
|
|
87
|
-
setSourceState('error');
|
|
84
|
+
updateStatus({ error, sourceState: 'error' });
|
|
88
85
|
queryClient.setQueryData(queryKey, undefined);
|
|
89
86
|
});
|
|
90
87
|
};
|
|
@@ -98,7 +95,7 @@ export function useFirestoreDoc({ docPath, queryKey, enabled = true, subscribe =
|
|
|
98
95
|
// Note: select intentionally excluded to prevent re-subscribing on every render if the caller
|
|
99
96
|
// passes an inline function. queryKey is tracked via queryKeyMemo above.
|
|
100
97
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
-
}, [db, docPath, queryKeyMemo, enabled, subscribe, queryClient]);
|
|
98
|
+
}, [db, docPath, queryKeyMemo, enabled, subscribe, queryClient, resetStatus, updateStatus]);
|
|
102
99
|
const query = useQuery({
|
|
103
100
|
queryKey,
|
|
104
101
|
queryFn: async () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-firestore-doc.js","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-doc.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useEffect, useState } from 'react';\nimport { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';\nimport { doc, getDoc, onSnapshot, type DocumentData } from 'firebase/firestore';\nimport { useFirestoreDb } from './context.js';\nimport { RESUBSCRIBE_DELAYS_MS, isPermissionDeniedError } from './resubscribe.js';\nimport type {\n FirestoreDocOptions,\n FirestoreSourceState,\n WithId,\n WithSourceState,\n} from '../../firestore/types.js';\n\n/**\n * Fetch a single Firestore document with optional realtime updates.\n * * @example\n * ```tsx\n * // Simple fetch\n * const { data: user } = useFirestoreDoc<User>({\n * docPath: `users/${userId}`,\n * queryKey: ['user', userId],\n * });\n * * // With realtime updates\n * const { data: entity } = useFirestoreDoc<Entity>({\n * docPath: `entities/${entityId}`,\n * queryKey: ['entity', entityId],\n * subscribe: true,\n * });\n * * // Conditional fetch\n * const { data: profile } = useFirestoreDoc<Profile>({\n * docPath: `profiles/${userId}`,\n * queryKey: ['profile', userId],\n * enabled: !!userId,\n * });\n * ```\n */\nexport function useFirestoreDoc<T extends DocumentData = DocumentData>({\n docPath,\n queryKey,\n enabled = true,\n subscribe = false,\n staleTime,\n gcTime,\n select,\n}: FirestoreDocOptions<T>): WithSourceState<UseQueryResult<WithId<T> | null, Error>> {\n const db = useFirestoreDb();\n const queryClient = useQueryClient();\n const queryKeyMemo = JSON.stringify(queryKey);\n\n // Listener errors can't surface through useQuery (the queryFn is disabled while\n // subscribed), so they're tracked here and merged into the returned result.\n const [subscriptionError, setSubscriptionError] = useState<Error | null>(null);\n // Metadata-derived source state — reset whenever the subscription identity changes.\n const [sourceState, setSourceState] = useState<FirestoreSourceState>('connecting');\n\n // Set up realtime subscription\n useEffect(() => {\n if (!subscribe || !enabled) return;\n\n setSubscriptionError(null);\n setSourceState('connecting');\n\n // Bounded resubscribe: a transient `permission-denied` (e.g. a failed mid-session\n // App Check token refresh) is retried on the RESUBSCRIBE_DELAYS_MS ladder before the\n // error is surfaced. `attempt` resets on any healthy snapshot; `disposed`/timers are\n // cleaned up on unmount so a pending retry never fires after teardown.\n let currentUnsubscribe: (() => void) | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let attempt = 0;\n let disposed = false;\n\n const start = () => {\n if (disposed) return;\n const docRef = doc(db, docPath);\n currentUnsubscribe = onSnapshot(\n docRef,\n { includeMetadataChanges: true },\n (snapshot) => {\n attempt = 0;\n setSubscriptionError(null);\n if (snapshot.exists()) {\n const rawData = snapshot.data();\n // Include id in data passed to select, so it can be renamed/transformed\n const dataWithId = { id: snapshot.id, ...rawData };\n const data = select ? select(dataWithId) : dataWithId;\n queryClient.setQueryData(queryKey, data as WithId<T>);\n } else {\n queryClient.setQueryData(queryKey, null);\n }\n const fromCache = snapshot.metadata?.fromCache ?? false;\n setSourceState((prev) => (fromCache ? (prev === 'connecting' ? 'connecting' : 'offline') : 'live'));\n },\n (error) => {\n // Heal a transient permission-denied by resubscribing on the backoff ladder,\n // keeping the last-known data visible. Only surface the error once the ladder\n // is exhausted; any other error surfaces immediately, as before.\n if (isPermissionDeniedError(error) && attempt < RESUBSCRIBE_DELAYS_MS.length) {\n currentUnsubscribe?.();\n currentUnsubscribe = null;\n const delay = RESUBSCRIBE_DELAYS_MS[attempt];\n attempt += 1;\n setSourceState('connecting');\n retryTimer = setTimeout(start, delay);\n return;\n }\n console.error('[useFirestoreDoc] Subscription error:', error);\n setSubscriptionError(error);\n setSourceState('error');\n queryClient.setQueryData(queryKey, undefined);\n }\n );\n };\n\n start();\n\n return () => {\n disposed = true;\n if (retryTimer) clearTimeout(retryTimer);\n currentUnsubscribe?.();\n };\n // Note: select intentionally excluded to prevent re-subscribing on every render if the caller\n // passes an inline function. queryKey is tracked via queryKeyMemo above.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [db, docPath, queryKeyMemo, enabled, subscribe, queryClient]);\n\n const query = useQuery({\n queryKey,\n queryFn: async (): Promise<WithId<T> | null> => {\n const docRef = doc(db, docPath);\n const snapshot = await getDoc(docRef);\n\n if (!snapshot.exists()) {\n return null;\n }\n\n const rawData = snapshot.data();\n // Include id in data passed to select, so it can be renamed/transformed\n const dataWithId = { id: snapshot.id, ...rawData };\n const data = select ? select(dataWithId) : dataWithId;\n return data as WithId<T>;\n },\n enabled: enabled && !subscribe, // Don't fetch if subscribing (snapshot handles it)\n staleTime: subscribe ? Infinity : staleTime, // Realtime data is always fresh\n gcTime,\n });\n\n // Merge a listener error into the result so subscribed consumers see a truthful\n // isError/error/status instead of a forever-pending disabled query.\n if (subscribe && subscriptionError) {\n return {\n ...query,\n data: undefined,\n error: subscriptionError,\n isError: true,\n isLoadingError: true,\n isSuccess: false,\n isPending: false,\n isLoading: false,\n status: 'error',\n sourceState,\n } as WithSourceState<UseQueryResult<WithId<T> | null, Error>>;\n }\n\n return { ...query, sourceState } as WithSourceState<UseQueryResult<WithId<T> | null, Error>>;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"use-firestore-doc.js","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-doc.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useEffect } from 'react';\nimport { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';\nimport { doc, getDoc, onSnapshot, type DocumentData } from 'firebase/firestore';\nimport { useFirestoreDb } from './context.js';\nimport { RESUBSCRIBE_DELAYS_MS, isPermissionDeniedError } from './resubscribe.js';\nimport { useSubscriptionStatus } from './subscription-status.js';\nimport type {\n FirestoreDocOptions,\n WithId,\n WithSourceState,\n} from '../../firestore/types.js';\n\n/**\n * Fetch a single Firestore document with optional realtime updates.\n * * @example\n * ```tsx\n * // Simple fetch\n * const { data: user } = useFirestoreDoc<User>({\n * docPath: `users/${userId}`,\n * queryKey: ['user', userId],\n * });\n * * // With realtime updates\n * const { data: entity } = useFirestoreDoc<Entity>({\n * docPath: `entities/${entityId}`,\n * queryKey: ['entity', entityId],\n * subscribe: true,\n * });\n * * // Conditional fetch\n * const { data: profile } = useFirestoreDoc<Profile>({\n * docPath: `profiles/${userId}`,\n * queryKey: ['profile', userId],\n * enabled: !!userId,\n * });\n * ```\n */\nexport function useFirestoreDoc<T extends DocumentData = DocumentData>({\n docPath,\n queryKey,\n enabled = true,\n subscribe = false,\n staleTime,\n gcTime,\n select,\n}: FirestoreDocOptions<T>): WithSourceState<UseQueryResult<WithId<T> | null, Error>> {\n const db = useFirestoreDb();\n const queryClient = useQueryClient();\n const queryKeyMemo = JSON.stringify(queryKey);\n\n // Listener errors can't surface through useQuery (the queryFn is disabled while\n // subscribed), so they're tracked per subscription identity and merged into the result.\n const {\n error: subscriptionError,\n sourceState,\n reset: resetStatus,\n update: updateStatus,\n } = useSubscriptionStatus(JSON.stringify([docPath, queryKeyMemo, enabled, subscribe]));\n\n // Set up realtime subscription\n useEffect(() => {\n if (!subscribe || !enabled) return;\n\n resetStatus();\n\n // Bounded resubscribe: a transient `permission-denied` (e.g. a failed mid-session\n // App Check token refresh) is retried on the RESUBSCRIBE_DELAYS_MS ladder before the\n // error is surfaced. `attempt` resets on any healthy snapshot; `disposed`/timers are\n // cleaned up on unmount so a pending retry never fires after teardown.\n let currentUnsubscribe: (() => void) | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let attempt = 0;\n let disposed = false;\n\n const start = () => {\n if (disposed) return;\n const docRef = doc(db, docPath);\n currentUnsubscribe = onSnapshot(\n docRef,\n { includeMetadataChanges: true },\n (snapshot) => {\n attempt = 0;\n updateStatus({ error: null });\n if (snapshot.exists()) {\n const rawData = snapshot.data();\n // Include id in data passed to select, so it can be renamed/transformed\n const dataWithId = { id: snapshot.id, ...rawData };\n const data = select ? select(dataWithId) : dataWithId;\n queryClient.setQueryData(queryKey, data as WithId<T>);\n } else {\n queryClient.setQueryData(queryKey, null);\n }\n const fromCache = snapshot.metadata?.fromCache ?? false;\n updateStatus((prev) => ({ sourceState: fromCache ? (prev.sourceState === 'connecting' ? 'connecting' : 'offline') : 'live' }));\n },\n (error) => {\n // Heal a transient permission-denied by resubscribing on the backoff ladder,\n // keeping the last-known data visible. Only surface the error once the ladder\n // is exhausted; any other error surfaces immediately, as before.\n if (isPermissionDeniedError(error) && attempt < RESUBSCRIBE_DELAYS_MS.length) {\n currentUnsubscribe?.();\n currentUnsubscribe = null;\n const delay = RESUBSCRIBE_DELAYS_MS[attempt];\n attempt += 1;\n updateStatus({ sourceState: 'connecting' });\n retryTimer = setTimeout(start, delay);\n return;\n }\n console.error('[useFirestoreDoc] Subscription error:', error);\n updateStatus({ error, sourceState: 'error' });\n queryClient.setQueryData(queryKey, undefined);\n }\n );\n };\n\n start();\n\n return () => {\n disposed = true;\n if (retryTimer) clearTimeout(retryTimer);\n currentUnsubscribe?.();\n };\n // Note: select intentionally excluded to prevent re-subscribing on every render if the caller\n // passes an inline function. queryKey is tracked via queryKeyMemo above.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [db, docPath, queryKeyMemo, enabled, subscribe, queryClient, resetStatus, updateStatus]);\n\n const query = useQuery({\n queryKey,\n queryFn: async (): Promise<WithId<T> | null> => {\n const docRef = doc(db, docPath);\n const snapshot = await getDoc(docRef);\n\n if (!snapshot.exists()) {\n return null;\n }\n\n const rawData = snapshot.data();\n // Include id in data passed to select, so it can be renamed/transformed\n const dataWithId = { id: snapshot.id, ...rawData };\n const data = select ? select(dataWithId) : dataWithId;\n return data as WithId<T>;\n },\n enabled: enabled && !subscribe, // Don't fetch if subscribing (snapshot handles it)\n staleTime: subscribe ? Infinity : staleTime, // Realtime data is always fresh\n gcTime,\n });\n\n // Merge a listener error into the result so subscribed consumers see a truthful\n // isError/error/status instead of a forever-pending disabled query.\n if (subscribe && subscriptionError) {\n return {\n ...query,\n data: undefined,\n error: subscriptionError,\n isError: true,\n isLoadingError: true,\n isSuccess: false,\n isPending: false,\n isLoading: false,\n status: 'error',\n sourceState,\n } as WithSourceState<UseQueryResult<WithId<T> | null, Error>>;\n }\n\n return { ...query, sourceState } as WithSourceState<UseQueryResult<WithId<T> | null, Error>>;\n}\n"]}
|
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
import { type DocumentData } from 'firebase/firestore';
|
|
2
|
-
import type { FirestoreLiveInfiniteOptions } from '../../firestore/types.js';
|
|
2
|
+
import type { FirestoreLiveInfiniteOptions, FirestoreSourceState } from '../../firestore/types.js';
|
|
3
3
|
export interface FirestoreLiveInfiniteResult<T> {
|
|
4
|
-
/**
|
|
4
|
+
/** The current subscription identity's rows — live set plus loaded older pages, sorted per `sort`. */
|
|
5
5
|
items: T[];
|
|
6
|
-
/** True until the
|
|
6
|
+
/** True until the current identity's first snapshot arrives, unless its listener has failed. */
|
|
7
7
|
isInitialLoading: boolean;
|
|
8
|
-
/**
|
|
8
|
+
/** Liveness of the current identity's listener; `connecting` from that identity's first render. */
|
|
9
|
+
sourceState: FirestoreSourceState;
|
|
10
|
+
/** The listener's failure for the current identity; `null` while connecting, live, or offline. */
|
|
11
|
+
error: Error | null;
|
|
12
|
+
/** Load the next older page. Never rejects — a failed read lands in `olderError`. */
|
|
9
13
|
fetchOlder: () => Promise<void>;
|
|
10
|
-
/** Whether
|
|
14
|
+
/** Whether older rows exist that are not loaded yet (a one-doc look-ahead, never a full-page guess). */
|
|
11
15
|
hasOlder: boolean;
|
|
12
|
-
/** Whether an older page is
|
|
16
|
+
/** Whether an older page is being read (including a `retry` of one). */
|
|
13
17
|
isFetchingOlder: boolean;
|
|
18
|
+
/** The last older-page read failed; `null` once an older read succeeds. */
|
|
19
|
+
olderError: Error | null;
|
|
20
|
+
/** Re-run what failed: resubscribe a failed listener, re-read a failed older page. */
|
|
21
|
+
retry: () => void;
|
|
14
22
|
}
|
|
15
23
|
/**
|
|
16
|
-
* A live newest
|
|
17
|
-
* ordered list.
|
|
18
|
-
* descending, limited to `pageSize`); older pages load on demand via `getDocs`,
|
|
19
|
-
* each seeded with `startAfter(previousOldestDoc)` — the first older page starts
|
|
20
|
-
* after the live window's oldest document, so there is no gap and no re-read of
|
|
21
|
-
* pages already in memory.
|
|
24
|
+
* A live list (newest rows realtime) with older rows paged in on demand, merged into
|
|
25
|
+
* one ordered list.
|
|
22
26
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
27
|
+
* Until the first server-confirmed snapshot that has rows, the listener reads the newest
|
|
28
|
+
* `pageSize` rows plus one look-ahead row. That snapshot fixes an anchor — the oldest row
|
|
29
|
+
* of the window — and from then on the listener covers anchor → newest with no limit, so
|
|
30
|
+
* it grows only with rows that arrive while the list is open, and every older page starts
|
|
31
|
+
* after the anchor. Nothing can fall between the live set and the older pages, however
|
|
32
|
+
* many rows arrive. Older pages carry the same one-row look-ahead, so `hasOlder` is false
|
|
33
|
+
* exactly when nothing older exists.
|
|
34
|
+
*
|
|
35
|
+
* Everything read is tagged to its subscription identity (path, key, ordering,
|
|
36
|
+
* constraints, page size, enabled): a new identity renders empty and connecting in its
|
|
37
|
+
* first render, and a retired identity's late callback or older page never lands.
|
|
38
|
+
*
|
|
39
|
+
* A `permission-denied` listener error resubscribes on the `RESUBSCRIBE_DELAYS_MS` ladder
|
|
40
|
+
* before surfacing; any other listener error surfaces at once. Rows already shown are
|
|
41
|
+
* kept beside the error. `db` comes from `<FirestoreProvider>`.
|
|
25
42
|
*/
|
|
26
43
|
export declare function useFirestoreLiveInfinite<T = DocumentData & {
|
|
27
44
|
id: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-firestore-live-infinite.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-live-infinite.ts"],"names":[],"mappings":"AAIA,OAAO,
|
|
1
|
+
{"version":3,"file":"use-firestore-live-infinite.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/use-firestore-live-infinite.ts"],"names":[],"mappings":"AAIA,OAAO,EASL,KAAK,YAAY,EAElB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,4BAA4B,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAInG,MAAM,WAAW,2BAA2B,CAAC,CAAC;IAC5C,sGAAsG;IACtG,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,gGAAgG;IAChG,gBAAgB,EAAE,OAAO,CAAC;IAC1B,mGAAmG;IACnG,WAAW,EAAE,oBAAoB,CAAC;IAClC,kGAAkG;IAClG,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,qFAAqF;IACrF,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,wGAAwG;IACxG,QAAQ,EAAE,OAAO,CAAC;IAClB,wEAAwE;IACxE,eAAe,EAAE,OAAO,CAAC;IACzB,2EAA2E;IAC3E,UAAU,EAAE,KAAK,GAAG,IAAI,CAAC;IACzB,sFAAsF;IACtF,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AA2BD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,GAAG,YAAY,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,EAC1E,cAAc,EACd,QAAQ,EACR,YAAY,EACZ,WAAgB,EAChB,QAAQ,EAAE,iBAAsB,EAChC,OAAc,EACd,MAAM,EACN,YAAY,EACZ,IAAY,GACb,EAAE,4BAA4B,CAAC,CAAC,CAAC,GAAG,2BAA2B,CAAC,CAAC,CAAC,CA4PlE"}
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import * as React from 'react';
|
|
3
3
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
4
|
-
import { collection, query, orderBy, limit, startAfter, getDocs, onSnapshot, } from 'firebase/firestore';
|
|
4
|
+
import { collection, query, orderBy, limit, startAfter, endAt, getDocs, onSnapshot, } from 'firebase/firestore';
|
|
5
5
|
import { useFirestoreDb } from './context.js';
|
|
6
|
+
import { RESUBSCRIBE_DELAYS_MS, isPermissionDeniedError } from './resubscribe.js';
|
|
6
7
|
const MAX_PAGE_SIZE = 100;
|
|
8
|
+
function freshWindow(identity) {
|
|
9
|
+
return {
|
|
10
|
+
identity,
|
|
11
|
+
ready: false,
|
|
12
|
+
entries: [],
|
|
13
|
+
anchor: null,
|
|
14
|
+
olderExists: false,
|
|
15
|
+
sourceState: 'connecting',
|
|
16
|
+
error: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
7
19
|
/**
|
|
8
|
-
* A live newest
|
|
9
|
-
* ordered list.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
20
|
+
* A live list (newest rows realtime) with older rows paged in on demand, merged into
|
|
21
|
+
* one ordered list.
|
|
22
|
+
*
|
|
23
|
+
* Until the first server-confirmed snapshot that has rows, the listener reads the newest
|
|
24
|
+
* `pageSize` rows plus one look-ahead row. That snapshot fixes an anchor — the oldest row
|
|
25
|
+
* of the window — and from then on the listener covers anchor → newest with no limit, so
|
|
26
|
+
* it grows only with rows that arrive while the list is open, and every older page starts
|
|
27
|
+
* after the anchor. Nothing can fall between the live set and the older pages, however
|
|
28
|
+
* many rows arrive. Older pages carry the same one-row look-ahead, so `hasOlder` is false
|
|
29
|
+
* exactly when nothing older exists.
|
|
14
30
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
31
|
+
* Everything read is tagged to its subscription identity (path, key, ordering,
|
|
32
|
+
* constraints, page size, enabled): a new identity renders empty and connecting in its
|
|
33
|
+
* first render, and a retired identity's late callback or older page never lands.
|
|
34
|
+
*
|
|
35
|
+
* A `permission-denied` listener error resubscribes on the `RESUBSCRIBE_DELAYS_MS` ladder
|
|
36
|
+
* before surfacing; any other listener error surfaces at once. Rows already shown are
|
|
37
|
+
* kept beside the error. `db` comes from `<FirestoreProvider>`.
|
|
17
38
|
*/
|
|
18
39
|
export function useFirestoreLiveInfinite({ collectionPath, queryKey, orderByField, constraints = [], pageSize: requestedPageSize = 20, enabled = true, select, getSortValue, sort = 'asc', }) {
|
|
19
40
|
const db = useFirestoreDb();
|
|
@@ -32,77 +53,203 @@ export function useFirestoreLiveInfinite({ collectionPath, queryKey, orderByFiel
|
|
|
32
53
|
: dataWithId[orderByField];
|
|
33
54
|
return { docId: snap.id, sortValue, item };
|
|
34
55
|
}, [orderByField]);
|
|
35
|
-
//
|
|
36
|
-
// queryKey, which the caller varies per collection/thread).
|
|
56
|
+
// Constraint objects are fresh every render; their serialized form is the identity.
|
|
37
57
|
const queryKeyMemo = JSON.stringify(queryKey);
|
|
38
58
|
const constraintsMemo = JSON.stringify(constraints);
|
|
39
|
-
const
|
|
59
|
+
const identity = JSON.stringify([
|
|
60
|
+
collectionPath,
|
|
61
|
+
queryKeyMemo,
|
|
62
|
+
orderByField,
|
|
63
|
+
constraintsMemo,
|
|
64
|
+
pageSize,
|
|
65
|
+
enabled,
|
|
66
|
+
]);
|
|
67
|
+
const [windowState, setWindowState] = React.useState(() => freshWindow(identity));
|
|
68
|
+
const windowRef = React.useRef(windowState);
|
|
69
|
+
windowRef.current = windowState;
|
|
70
|
+
const [retryNonce, setRetryNonce] = React.useState(0);
|
|
71
|
+
const current = windowState.identity === identity ? windowState : freshWindow(identity);
|
|
40
72
|
React.useEffect(() => {
|
|
41
73
|
if (!enabled) {
|
|
42
|
-
|
|
74
|
+
// Retire the enabled identity's rows, so re-enabling starts afresh rather than resuming.
|
|
75
|
+
setWindowState(freshWindow(identity));
|
|
43
76
|
return;
|
|
44
77
|
}
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
78
|
+
// A retry of the same identity resumes from its anchor and keeps its rows.
|
|
79
|
+
const prior = windowRef.current;
|
|
80
|
+
const resumed = prior.identity === identity;
|
|
81
|
+
setWindowState((prev) => prev.identity === identity
|
|
82
|
+
? { ...prev, error: null, sourceState: 'connecting' }
|
|
83
|
+
: freshWindow(identity));
|
|
84
|
+
let disposed = false;
|
|
85
|
+
let unsubscribe = null;
|
|
86
|
+
let retryTimer = null;
|
|
87
|
+
let attempt = 0;
|
|
88
|
+
let currentAnchor = resumed ? prior.anchor : null;
|
|
89
|
+
const update = (fn) => {
|
|
90
|
+
if (disposed)
|
|
91
|
+
return;
|
|
92
|
+
setWindowState((prev) => (prev.identity === identity ? fn(prev) : prev));
|
|
93
|
+
};
|
|
94
|
+
const listen = (anchor) => {
|
|
95
|
+
if (disposed)
|
|
96
|
+
return;
|
|
97
|
+
currentAnchor = anchor;
|
|
98
|
+
const colRef = collection(db, collectionPath);
|
|
99
|
+
const q = anchor
|
|
100
|
+
? query(colRef, ...constraints, orderBy(orderByField, 'desc'), endAt(anchor))
|
|
101
|
+
: query(colRef, ...constraints, orderBy(orderByField, 'desc'), limit(pageSize + 1));
|
|
102
|
+
let serverConfirmed = false;
|
|
103
|
+
unsubscribe = onSnapshot(q, { includeMetadataChanges: true }, (snap) => {
|
|
104
|
+
if (disposed)
|
|
105
|
+
return;
|
|
106
|
+
attempt = 0;
|
|
107
|
+
const docs = snap.docs;
|
|
108
|
+
const fromCache = snap.metadata?.fromCache ?? false;
|
|
109
|
+
const nextSource = (prev) => {
|
|
110
|
+
if (!fromCache)
|
|
111
|
+
return 'live';
|
|
112
|
+
if (!serverConfirmed)
|
|
113
|
+
return prev === 'error' ? 'connecting' : prev;
|
|
114
|
+
return 'offline';
|
|
115
|
+
};
|
|
116
|
+
if (!fromCache)
|
|
117
|
+
serverConfirmed = true;
|
|
118
|
+
if (anchor) {
|
|
119
|
+
update((prev) => ({
|
|
120
|
+
...prev,
|
|
121
|
+
ready: true,
|
|
122
|
+
entries: docs.map(toEntry),
|
|
123
|
+
sourceState: nextSource(prev.sourceState),
|
|
124
|
+
error: null,
|
|
125
|
+
}));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const windowDocs = docs.slice(0, pageSize);
|
|
129
|
+
if (fromCache || windowDocs.length === 0) {
|
|
130
|
+
update((prev) => ({
|
|
131
|
+
...prev,
|
|
132
|
+
ready: true,
|
|
133
|
+
entries: windowDocs.map(toEntry),
|
|
134
|
+
sourceState: nextSource(prev.sourceState),
|
|
135
|
+
error: null,
|
|
136
|
+
}));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const newAnchor = windowDocs[windowDocs.length - 1];
|
|
140
|
+
update((prev) => ({
|
|
141
|
+
...prev,
|
|
142
|
+
ready: true,
|
|
143
|
+
entries: windowDocs.map(toEntry),
|
|
144
|
+
anchor: newAnchor,
|
|
145
|
+
olderExists: docs.length > pageSize,
|
|
146
|
+
sourceState: 'live',
|
|
147
|
+
error: null,
|
|
148
|
+
}));
|
|
149
|
+
unsubscribe?.();
|
|
150
|
+
unsubscribe = null;
|
|
151
|
+
listen(newAnchor);
|
|
152
|
+
}, (error) => {
|
|
153
|
+
if (disposed)
|
|
154
|
+
return;
|
|
155
|
+
unsubscribe?.();
|
|
156
|
+
unsubscribe = null;
|
|
157
|
+
if (isPermissionDeniedError(error) && attempt < RESUBSCRIBE_DELAYS_MS.length) {
|
|
158
|
+
const delay = RESUBSCRIBE_DELAYS_MS[attempt];
|
|
159
|
+
attempt += 1;
|
|
160
|
+
update((prev) => ({ ...prev, sourceState: 'connecting' }));
|
|
161
|
+
retryTimer = setTimeout(() => listen(currentAnchor), delay);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
console.error('[useFirestoreLiveInfinite] Subscription error:', error);
|
|
165
|
+
update((prev) => ({ ...prev, sourceState: 'error', error }));
|
|
53
166
|
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
167
|
+
};
|
|
168
|
+
listen(currentAnchor);
|
|
169
|
+
return () => {
|
|
170
|
+
disposed = true;
|
|
171
|
+
if (retryTimer)
|
|
172
|
+
clearTimeout(retryTimer);
|
|
173
|
+
unsubscribe?.();
|
|
174
|
+
};
|
|
175
|
+
// `identity` carries collectionPath, queryKey, orderByField, constraints, pageSize, and
|
|
176
|
+
// enabled; constraints are read from the render that produced it.
|
|
57
177
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
-
}, [db,
|
|
178
|
+
}, [db, identity, retryNonce, toEntry]);
|
|
179
|
+
const anchor = enabled ? current.anchor : null;
|
|
180
|
+
const olderEnabled = anchor !== null && current.olderExists;
|
|
59
181
|
const older = useInfiniteQuery({
|
|
60
|
-
queryKey: [...queryKey, 'older'],
|
|
61
|
-
enabled:
|
|
182
|
+
queryKey: [...queryKey, 'older', identity, anchor?.id ?? null],
|
|
183
|
+
enabled: olderEnabled,
|
|
62
184
|
initialPageParam: undefined,
|
|
63
185
|
queryFn: async ({ pageParam }) => {
|
|
64
|
-
const cursor = pageParam ??
|
|
186
|
+
const cursor = pageParam ?? anchor;
|
|
65
187
|
if (!cursor) {
|
|
66
|
-
return {
|
|
67
|
-
entries: [],
|
|
68
|
-
nextCursor: undefined,
|
|
69
|
-
};
|
|
188
|
+
return { entries: [], nextCursor: undefined };
|
|
70
189
|
}
|
|
71
190
|
const colRef = collection(db, collectionPath);
|
|
72
|
-
const q = query(colRef, ...constraints, orderBy(orderByField, 'desc'), startAfter(cursor), limit(pageSize));
|
|
191
|
+
const q = query(colRef, ...constraints, orderBy(orderByField, 'desc'), startAfter(cursor), limit(pageSize + 1));
|
|
73
192
|
const snap = await getDocs(q);
|
|
74
193
|
const docs = snap.docs;
|
|
194
|
+
const page = docs.slice(0, pageSize);
|
|
75
195
|
return {
|
|
76
|
-
entries:
|
|
77
|
-
nextCursor: docs.length
|
|
196
|
+
entries: page.map(toEntry),
|
|
197
|
+
nextCursor: docs.length > pageSize ? page[page.length - 1] : undefined,
|
|
78
198
|
};
|
|
79
199
|
},
|
|
80
200
|
getNextPageParam: (last) => last.nextCursor,
|
|
81
201
|
});
|
|
82
|
-
const { fetchNextPage } = older;
|
|
202
|
+
const { data: olderData, isFetching: olderFetching, isError: olderIsError, error: olderErr, hasNextPage: olderHasNext, fetchNextPage, refetch: refetchOlder, } = older;
|
|
203
|
+
const olderPages = olderEnabled ? olderData?.pages : undefined;
|
|
204
|
+
const hasOlder = olderEnabled && (olderPages ? Boolean(olderHasNext) : true);
|
|
205
|
+
const olderError = olderEnabled && olderIsError ? olderErr : null;
|
|
83
206
|
const fetchOlder = React.useCallback(async () => {
|
|
84
|
-
|
|
85
|
-
|
|
207
|
+
if (!olderEnabled || olderFetching)
|
|
208
|
+
return;
|
|
209
|
+
if (!olderPages) {
|
|
210
|
+
await refetchOlder();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (olderHasNext)
|
|
214
|
+
await fetchNextPage();
|
|
215
|
+
}, [olderEnabled, olderFetching, olderPages, olderHasNext, refetchOlder, fetchNextPage]);
|
|
216
|
+
const listenerFailed = enabled && current.error !== null;
|
|
217
|
+
const retry = React.useCallback(() => {
|
|
218
|
+
if (listenerFailed) {
|
|
219
|
+
setWindowState((prev) => prev.identity === identity ? { ...prev, error: null, sourceState: 'connecting' } : prev);
|
|
220
|
+
setRetryNonce((n) => n + 1);
|
|
221
|
+
}
|
|
222
|
+
if (olderError && !olderFetching) {
|
|
223
|
+
if (olderPages)
|
|
224
|
+
void fetchNextPage();
|
|
225
|
+
else
|
|
226
|
+
void refetchOlder();
|
|
227
|
+
}
|
|
228
|
+
}, [listenerFailed, identity, olderError, olderFetching, olderPages, fetchNextPage, refetchOlder]);
|
|
86
229
|
const items = React.useMemo(() => {
|
|
87
230
|
if (!enabled)
|
|
88
231
|
return [];
|
|
89
|
-
const olderEntries = (
|
|
90
|
-
//
|
|
232
|
+
const olderEntries = (olderPages ?? []).flatMap((p) => p.entries);
|
|
233
|
+
// Live entries win on overlap (they are the freshest copies).
|
|
91
234
|
const merged = new Map();
|
|
92
|
-
for (const entry of [...
|
|
235
|
+
for (const entry of [...current.entries, ...olderEntries]) {
|
|
93
236
|
if (!merged.has(entry.docId))
|
|
94
237
|
merged.set(entry.docId, entry);
|
|
95
238
|
}
|
|
96
239
|
const arr = Array.from(merged.values());
|
|
97
240
|
arr.sort((a, b) => (sort === 'asc' ? a.sortValue - b.sortValue : b.sortValue - a.sortValue));
|
|
98
241
|
return arr.map((e) => e.item);
|
|
99
|
-
}, [enabled,
|
|
242
|
+
}, [enabled, olderPages, current.entries, sort]);
|
|
100
243
|
return {
|
|
101
244
|
items,
|
|
102
|
-
isInitialLoading: enabled ? !
|
|
245
|
+
isInitialLoading: enabled ? !current.ready && current.error === null : false,
|
|
246
|
+
sourceState: current.sourceState,
|
|
247
|
+
error: enabled ? current.error : null,
|
|
103
248
|
fetchOlder,
|
|
104
|
-
hasOlder
|
|
105
|
-
isFetchingOlder:
|
|
249
|
+
hasOlder,
|
|
250
|
+
isFetchingOlder: olderEnabled && olderFetching,
|
|
251
|
+
olderError,
|
|
252
|
+
retry,
|
|
106
253
|
};
|
|
107
254
|
}
|
|
108
255
|
//# sourceMappingURL=use-firestore-live-infinite.js.map
|