@ttt-productions/query-core 0.14.1 → 0.14.3

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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-doc-loader.js","sourceRoot":"","sources":["../../../src/react/firestore/batch-doc-loader.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport {\n collection,\n doc,\n documentId,\n getDoc,\n getDocs,\n query,\n where,\n type Firestore,\n} from 'firebase/firestore';\nimport type { QueryClient } from '@tanstack/react-query';\n\n/**\n * Coalescing document loader behind {@link useBatchFirestoreDocs}'s one-shot mode.\n *\n * Every id is its own observed React Query query, so without coalescing N ids would issue\n * N Firestore round-trips. The loader collects the ids enqueued in a single microtask and\n * dispatches them as `where(documentId(), 'in', chunk)` queries of at most 30 ids each\n * (`'batch'` transport), or as concurrent per-document gets (`'get'` transport — required\n * for collections whose rules gate reads on `resource.data`, where an unconstrained list\n * query is denied wholesale but per-document gets are evaluated per doc).\n *\n * Two properties are load-bearing:\n *\n * - **The loader resolves promises only — it NEVER writes the query cache.** TanStack's\n * per-query fetch identity is therefore the sole guard against a late batch response\n * overwriting a newer per-id result.\n * - **A dispatched batch is closed.** A later enqueue of the same id (a refetch after a\n * cancellation, say) always joins a NEW batch, never the in-flight one — so a cancelled\n * read can never be served by the request it was cancelled out of.\n *\n * Cancelling one per-id query detaches only that waiter; the shared request still completes\n * for its batch-mates.\n *\n * Loaders are scoped per `(QueryClient, Firestore instance, collectionPath)`. The Firestore\n * instance is part of the scope on purpose: two `db` instances sharing a collection path\n * must never share a request.\n */\n\n/** Firestore's hard cap on the number of values in an `in` filter. */\nexport const FIRESTORE_IN_LIMIT = 30;\n\n/** Maximum `in`-query requests in flight at once, per loader scope. */\nexport const MAX_CONCURRENT_BATCH_REQUESTS = 4;\n\n/** Maximum per-document gets in flight at once, per loader scope. */\nexport const MAX_CONCURRENT_DOC_GETS = 12;\n\nexport type LoadedDoc = Record<string, unknown> & { id: string };\n\ntype Waiter = {\n resolve: (value: LoadedDoc | null) => void;\n reject: (error: unknown) => void;\n};\n\nexport type DocLoader = {\n /** Resolve one id through the coalescing `in`-query batcher. */\n loadBatched(id: string, signal?: AbortSignal): Promise<LoadedDoc | null>;\n /** Resolve one id through its own `getDoc`, bounded by the get concurrency limit. */\n loadOne(id: string, signal?: AbortSignal): Promise<LoadedDoc | null>;\n};\n\n/** Runs at most `max` tasks concurrently; a slot is released when its task settles. */\nfunction createConcurrencyLimiter(max: number) {\n let active = 0;\n const waiting: (() => void)[] = [];\n\n const pump = () => {\n while (active < max && waiting.length > 0) {\n const start = waiting.shift();\n active += 1;\n start?.();\n }\n };\n\n const release = () => {\n active -= 1;\n pump();\n };\n\n return <R>(task: () => Promise<R>): Promise<R> =>\n new Promise<R>((resolve, reject) => {\n waiting.push(() => {\n task().then(\n (value) => {\n release();\n resolve(value);\n },\n (error) => {\n release();\n reject(error);\n },\n );\n });\n pump();\n });\n}\n\nfunction abortReason(signal: AbortSignal): unknown {\n return signal.reason ?? new Error('Document read was cancelled');\n}\n\nfunction createDocLoader(db: Firestore, collectionPath: string): DocLoader {\n const runBatchRequest = createConcurrencyLimiter(MAX_CONCURRENT_BATCH_REQUESTS);\n const runDocGet = createConcurrencyLimiter(MAX_CONCURRENT_DOC_GETS);\n\n // Ids enqueued in the CURRENT microtask. Nulled out at dispatch so the dispatched batch\n // is closed and a later enqueue opens a fresh one.\n let openBatch: Map<string, Waiter[]> | null = null;\n\n const runChunk = (chunk: string[], batch: Map<string, Waiter[]>) =>\n runBatchRequest(async () => {\n let found: Map<string, LoadedDoc> | null = null;\n let failure: unknown;\n try {\n const snapshot = await getDocs(\n query(collection(db, collectionPath), where(documentId(), 'in', chunk)),\n );\n found = new Map<string, LoadedDoc>();\n snapshot.forEach((snap) => {\n found!.set(snap.id, { id: snap.id, ...snap.data() } as LoadedDoc);\n });\n } catch (error) {\n failure = error;\n }\n\n for (const id of chunk) {\n const waiters = batch.get(id);\n batch.delete(id);\n if (!waiters) continue;\n for (const waiter of [...waiters]) {\n if (found) waiter.resolve(found.get(id) ?? null);\n else waiter.reject(failure);\n }\n }\n });\n\n const dispatch = () => {\n const batch = openBatch;\n openBatch = null;\n if (!batch || batch.size === 0) return;\n const ids = [...batch.keys()];\n for (let i = 0; i < ids.length; i += FIRESTORE_IN_LIMIT) {\n void runChunk(ids.slice(i, i + FIRESTORE_IN_LIMIT), batch);\n }\n };\n\n const loadBatched = (id: string, signal?: AbortSignal): Promise<LoadedDoc | null> =>\n new Promise<LoadedDoc | null>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortReason(signal));\n return;\n }\n\n if (!openBatch) {\n openBatch = new Map();\n queueMicrotask(dispatch);\n }\n\n const batch = openBatch;\n let slot = batch.get(id);\n if (!slot) {\n slot = [];\n batch.set(id, slot);\n }\n const waiters = slot;\n\n let settled = false;\n let onAbort: (() => void) | undefined;\n\n const settle = (run: () => void) => {\n if (settled) return;\n settled = true;\n const index = waiters.indexOf(waiter);\n if (index >= 0) waiters.splice(index, 1);\n // An id nobody is waiting on any more must not ride into a billed `in` query.\n if (waiters.length === 0 && batch.get(id) === waiters) batch.delete(id);\n if (onAbort) signal?.removeEventListener('abort', onAbort);\n run();\n };\n\n const waiter: Waiter = {\n resolve: (value) => settle(() => resolve(value)),\n reject: (error) => settle(() => reject(error)),\n };\n\n waiters.push(waiter);\n\n if (signal) {\n onAbort = () => waiter.reject(abortReason(signal));\n signal.addEventListener('abort', onAbort, { once: true });\n }\n });\n\n const loadOne = (id: string, signal?: AbortSignal): Promise<LoadedDoc | null> =>\n runDocGet(async () => {\n if (signal?.aborted) throw abortReason(signal);\n const snapshot = await getDoc(doc(db, collectionPath, id));\n return snapshot.exists() ? ({ id: snapshot.id, ...snapshot.data() } as LoadedDoc) : null;\n });\n\n return { loadBatched, loadOne };\n}\n\n// Per-QueryClient → per-Firestore-instance → per-collectionPath. WeakMaps so a discarded\n// client or db instance takes its loaders with it.\nconst loaderScopes = new WeakMap<QueryClient, WeakMap<Firestore, Map<string, DocLoader>>>();\n\nexport function getDocLoader(\n queryClient: QueryClient,\n db: Firestore,\n collectionPath: string,\n): DocLoader {\n let byDb = loaderScopes.get(queryClient);\n if (!byDb) {\n byDb = new WeakMap();\n loaderScopes.set(queryClient, byDb);\n }\n let byPath = byDb.get(db);\n if (!byPath) {\n byPath = new Map();\n byDb.set(db, byPath);\n }\n let loader = byPath.get(collectionPath);\n if (!loader) {\n loader = createDocLoader(db, collectionPath);\n byPath.set(collectionPath, loader);\n }\n return loader;\n}\n"]}
@@ -1,5 +1,14 @@
1
1
  import { type Firestore } from 'firebase/firestore';
2
2
  import type { QueryClient } from '@tanstack/react-query';
3
+ /**
4
+ * True while a shared listener owns `[queryKeyPrefix, id]` for this client.
5
+ *
6
+ * A one-shot read that was already in flight when a consumer entered subscribe mode would
7
+ * otherwise resolve AFTER the listener's first snapshot and overwrite it — and `onSnapshot`
8
+ * does not re-emit until the document actually changes, so the stale value would stick.
9
+ * The one-shot `queryFn` consults this after its read and yields to the listener's value.
10
+ */
11
+ export declare function isListenerOwned(queryClient: QueryClient, queryKeyPrefix: string, id: string): boolean;
3
12
  export interface SubscribeDocParams {
4
13
  queryClient: QueryClient;
5
14
  db: Firestore;
@@ -1 +1 @@
1
- {"version":3,"file":"doc-subscription-registry.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/doc-subscription-registry.ts"],"names":[],"mappings":"AAEA,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AA6CzD,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,EAAE,EAAE,SAAS,CAAC;IACd,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAC;IACvB,qEAAqE;IACrE,cAAc,EAAE,MAAM,CAAC;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,uFAAuF;IACvF,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gCAAgC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,IAAI,CA+CnE;AAED,sEAAsE;AACtE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,MAAM,CAEtE"}
1
+ {"version":3,"file":"doc-subscription-registry.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/doc-subscription-registry.ts"],"names":[],"mappings":"AAEA,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAwCzD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,MAAM,EACtB,EAAE,EAAE,MAAM,GACT,OAAO,CAET;AAWD,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,EAAE,EAAE,SAAS,CAAC;IACd,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAC;IACvB,qEAAqE;IACrE,cAAc,EAAE,MAAM,CAAC;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,uFAAuF;IACvF,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gCAAgC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,IAAI,CA+CnE;AAED,sEAAsE;AACtE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,MAAM,CAEtE"}
@@ -1 +1 @@
1
- {"version":3,"file":"doc-subscription-registry.js","sourceRoot":"","sources":["../../../src/react/firestore/doc-subscription-registry.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { doc, onSnapshot, type Firestore } from 'firebase/firestore';\nimport type { QueryClient } from '@tanstack/react-query';\n\n/**\n * Reference-counted, real-time document subscription registry.\n *\n * Backs the `subscribe` mode of {@link useBatchFirestoreDocs}. The problem it solves:\n * many components on a page resolve the SAME document id (e.g. the same author rendered\n * in a feed card, a comment, and a mention). Without sharing, each would open its own\n * `onSnapshot` listener. This registry keeps exactly ONE Firestore listener per\n * `(queryClient, queryKeyPrefix, id)` and fans its updates out to every subscriber, so N\n * components showing the same id cost one listener.\n *\n * The single listener writes the document straight into the React Query cache at\n * `[queryKeyPrefix, id]` — `{ id, ...data }` when the doc exists, or `null` when it does\n * not (negative caching, so a missing/lagging doc resolves the instant it appears instead\n * of staying blank until staleTime). A listener ERROR also negative-caches the id: a\n * rules-denied doc (hidden, or non-existent when the rule references `resource.data`) is\n * resolved-absent, not forever-loading — consumers see `data: null` plus the error instead\n * of an eternal spinner. The registry is keyed per `QueryClient` (a WeakMap), which in\n * practice means per browser tab, so each tab listens independently and stays consistent\n * without cross-tab plumbing.\n */\n\ntype Subscriber = {\n onUpdate: () => void;\n onError?: (error: Error) => void;\n};\n\ntype Entry = {\n unsubscribe: () => void;\n subscribers: Set<Subscriber>;\n};\n\n// Per-QueryClient registry. WeakMap so a discarded QueryClient (and its entries) is GC'd.\nconst registries = new WeakMap<QueryClient, Map<string, Entry>>();\n\nfunction getRegistry(queryClient: QueryClient): Map<string, Entry> {\n let registry = registries.get(queryClient);\n if (!registry) {\n registry = new Map();\n registries.set(queryClient, registry);\n }\n return registry;\n}\n\nexport interface SubscribeDocParams {\n queryClient: QueryClient;\n db: Firestore;\n /** Top-level/full collection path the doc lives in (e.g. 'publicUsers'). */\n collectionPath: string;\n /** Cache key prefix; the doc is cached at `[queryKeyPrefix, id]`. */\n queryKeyPrefix: string;\n id: string;\n /** Called whenever a snapshot (data or missing) lands, so the caller can re-render. */\n onUpdate: () => void;\n /** Called on listener error. */\n onError?: (error: Error) => void;\n}\n\n/**\n * Subscribe to a single document via a shared, reference-counted listener.\n * Returns an idempotent unsubscribe function; the underlying Firestore listener is\n * detached only when the last subscriber for that id unsubscribes.\n */\nexport function subscribeDoc(params: SubscribeDocParams): () => void {\n const { queryClient, db, collectionPath, queryKeyPrefix, id, onUpdate, onError } = params;\n const registry = getRegistry(queryClient);\n const key = `${queryKeyPrefix}\u0000${id}`;\n\n let entry = registry.get(key);\n if (!entry) {\n const subscribers = new Set<Subscriber>();\n const ref = doc(db, collectionPath, id);\n const unsubscribe = onSnapshot(\n ref,\n (snapshot) => {\n queryClient.setQueryData(\n [queryKeyPrefix, id],\n snapshot.exists() ? { id: snapshot.id, ...snapshot.data() } : null,\n { updatedAt: Date.now() },\n );\n // Copy before iterating: an onUpdate could (re-entrantly) unsubscribe.\n for (const sub of [...subscribers]) sub.onUpdate();\n },\n (error) => {\n // Negative-cache the id: a denied doc is resolved-absent. Without this,\n // subscribe-mode loading state (\"no cache entry yet\") would stay true\n // forever for ids whose listener can never land a snapshot.\n queryClient.setQueryData([queryKeyPrefix, id], null, { updatedAt: Date.now() });\n for (const sub of [...subscribers]) sub.onError?.(error);\n },\n );\n entry = { unsubscribe, subscribers };\n registry.set(key, entry);\n }\n\n const subscriber: Subscriber = { onUpdate, onError };\n entry.subscribers.add(subscriber);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = registry.get(key);\n if (!current) return;\n current.subscribers.delete(subscriber);\n if (current.subscribers.size === 0) {\n current.unsubscribe();\n registry.delete(key);\n }\n };\n}\n\n/** Test-only: number of active shared listeners for a QueryClient. */\nexport function __activeListenerCount(queryClient: QueryClient): number {\n return registries.get(queryClient)?.size ?? 0;\n}\n"]}
1
+ {"version":3,"file":"doc-subscription-registry.js","sourceRoot":"","sources":["../../../src/react/firestore/doc-subscription-registry.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { doc, onSnapshot, type Firestore } from 'firebase/firestore';\nimport type { QueryClient } from '@tanstack/react-query';\n\n/**\n * Reference-counted, real-time document subscription registry.\n *\n * Backs the `subscribe` mode of {@link useBatchFirestoreDocs}. The problem it solves:\n * many components on a page resolve the SAME document id (e.g. the same author rendered\n * in a feed card, a comment, and a mention). Without sharing, each would open its own\n * `onSnapshot` listener. This registry keeps exactly ONE Firestore listener per\n * `(queryClient, queryKeyPrefix, id)` and fans its updates out to every subscriber, so N\n * components showing the same id cost one listener.\n *\n * The single listener writes the document straight into the React Query cache at\n * `[queryKeyPrefix, id]` — `{ id, ...data }` when the doc exists, or `null` when it does\n * not (negative caching, so a missing/lagging doc resolves the instant it appears instead\n * of staying blank until staleTime). A listener ERROR also negative-caches the id: a\n * rules-denied doc (hidden, or non-existent when the rule references `resource.data`) is\n * resolved-absent, not forever-loading — consumers see `data: null` plus the error instead\n * of an eternal spinner. The registry is keyed per `QueryClient` (a WeakMap), which in\n * practice means per browser tab, so each tab listens independently and stays consistent\n * without cross-tab plumbing.\n */\n\ntype Subscriber = {\n onUpdate: () => void;\n onError?: (error: Error) => void;\n};\n\ntype Entry = {\n unsubscribe: () => void;\n subscribers: Set<Subscriber>;\n};\n\n// Per-QueryClient registry. WeakMap so a discarded QueryClient (and its entries) is GC'd.\nconst registries = new WeakMap<QueryClient, Map<string, Entry>>();\n\nfunction registryKey(queryKeyPrefix: string, id: string): string {\n return `${queryKeyPrefix} ${id}`;\n}\n\n/**\n * True while a shared listener owns `[queryKeyPrefix, id]` for this client.\n *\n * A one-shot read that was already in flight when a consumer entered subscribe mode would\n * otherwise resolve AFTER the listener's first snapshot and overwrite it — and `onSnapshot`\n * does not re-emit until the document actually changes, so the stale value would stick.\n * The one-shot `queryFn` consults this after its read and yields to the listener's value.\n */\nexport function isListenerOwned(\n queryClient: QueryClient,\n queryKeyPrefix: string,\n id: string,\n): boolean {\n return registries.get(queryClient)?.has(registryKey(queryKeyPrefix, id)) ?? false;\n}\n\nfunction getRegistry(queryClient: QueryClient): Map<string, Entry> {\n let registry = registries.get(queryClient);\n if (!registry) {\n registry = new Map();\n registries.set(queryClient, registry);\n }\n return registry;\n}\n\nexport interface SubscribeDocParams {\n queryClient: QueryClient;\n db: Firestore;\n /** Top-level/full collection path the doc lives in (e.g. 'publicUsers'). */\n collectionPath: string;\n /** Cache key prefix; the doc is cached at `[queryKeyPrefix, id]`. */\n queryKeyPrefix: string;\n id: string;\n /** Called whenever a snapshot (data or missing) lands, so the caller can re-render. */\n onUpdate: () => void;\n /** Called on listener error. */\n onError?: (error: Error) => void;\n}\n\n/**\n * Subscribe to a single document via a shared, reference-counted listener.\n * Returns an idempotent unsubscribe function; the underlying Firestore listener is\n * detached only when the last subscriber for that id unsubscribes.\n */\nexport function subscribeDoc(params: SubscribeDocParams): () => void {\n const { queryClient, db, collectionPath, queryKeyPrefix, id, onUpdate, onError } = params;\n const registry = getRegistry(queryClient);\n const key = registryKey(queryKeyPrefix, id);\n\n let entry = registry.get(key);\n if (!entry) {\n const subscribers = new Set<Subscriber>();\n const ref = doc(db, collectionPath, id);\n const unsubscribe = onSnapshot(\n ref,\n (snapshot) => {\n queryClient.setQueryData(\n [queryKeyPrefix, id],\n snapshot.exists() ? { id: snapshot.id, ...snapshot.data() } : null,\n { updatedAt: Date.now() },\n );\n // Copy before iterating: an onUpdate could (re-entrantly) unsubscribe.\n for (const sub of [...subscribers]) sub.onUpdate();\n },\n (error) => {\n // Negative-cache the id: a denied doc is resolved-absent. Without this,\n // subscribe-mode loading state (\"no cache entry yet\") would stay true\n // forever for ids whose listener can never land a snapshot.\n queryClient.setQueryData([queryKeyPrefix, id], null, { updatedAt: Date.now() });\n for (const sub of [...subscribers]) sub.onError?.(error);\n },\n );\n entry = { unsubscribe, subscribers };\n registry.set(key, entry);\n }\n\n const subscriber: Subscriber = { onUpdate, onError };\n entry.subscribers.add(subscriber);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = registry.get(key);\n if (!current) return;\n current.subscribers.delete(subscriber);\n if (current.subscribers.size === 0) {\n current.unsubscribe();\n registry.delete(key);\n }\n };\n}\n\n/** Test-only: number of active shared listeners for a QueryClient. */\nexport function __activeListenerCount(queryClient: QueryClient): number {\n return registries.get(queryClient)?.size ?? 0;\n}\n"]}
@@ -1,4 +1,6 @@
1
- import { type Firestore } from 'firebase/firestore';
1
+ import type { Firestore } from 'firebase/firestore';
2
+ /** Transport used to resolve an id in one-shot mode. */
3
+ export type BatchFirestoreDocsTransport = 'batch' | 'get';
2
4
  export type BatchFirestoreDocsOptions = {
3
5
  /**
4
6
  * Firestore instance to use for queries
@@ -14,12 +16,13 @@ export type BatchFirestoreDocsOptions = {
14
16
  ids: string[];
15
17
  /**
16
18
  * Query key prefix for individual document cache entries
17
- * Each document will be cached as [queryKeyPrefix, docId]
19
+ * Each document is cached as [queryKeyPrefix, docId]
18
20
  * @example 'publicUser' -> ['publicUser', 'user123']
19
21
  */
20
22
  queryKeyPrefix: string;
21
23
  /**
22
- * Time in milliseconds to consider data fresh (default: 30 minutes)
24
+ * Time in milliseconds a PRESENT document stays fresh (default: 30 minutes).
25
+ * An absent document uses `absentStaleTime` instead.
23
26
  */
24
27
  staleTime?: number;
25
28
  /**
@@ -32,46 +35,85 @@ export type BatchFirestoreDocsOptions = {
32
35
  enabled?: boolean;
33
36
  /**
34
37
  * Real-time mode (default: false). When true, each id is resolved via a shared,
35
- * reference-counted `onSnapshot` listener instead of a one-shot `getDocs` batch:
38
+ * reference-counted `onSnapshot` listener instead of a one-shot read:
36
39
  * - a missing doc caches `null` (negative caching) and resolves the instant it appears,
37
40
  * - identity edits propagate live, and every browser tab stays consistent,
38
- * - `staleTime` is irrelevant (listeners keep the cache fresh).
39
- * Initial read cost matches the batch path; listeners then stay open while mounted.
40
- * Use for small, frequently-rendered, change-sensitive identity docs (e.g. publicUsers).
41
+ * - `staleTime` is irrelevant (listeners keep the cache fresh) and `refetch()` is a no-op.
42
+ * Initial read cost matches the one-shot path; listeners then stay open while mounted.
43
+ * Use for small, frequently-rendered, change-sensitive docs owned by the signed-in user.
41
44
  */
42
45
  subscribe?: boolean;
46
+ /**
47
+ * One-shot transport (default: `'batch'`).
48
+ *
49
+ * - `'batch'` — ids enqueued in the same microtask are coalesced into
50
+ * `where(documentId(), 'in', …)` queries of at most 30 ids each. Fewest round-trips.
51
+ * - `'get'` — one `getDoc` per id, run concurrently. Required for a collection whose
52
+ * Firestore rules gate reads on `resource.data`: an unconstrained id-list query is
53
+ * denied WHOLESALE there, while per-document gets are evaluated per document, so one
54
+ * unreadable id errors alone instead of failing every id with it. On such a collection
55
+ * a get against a nonexistent doc is also denied, so "absent" and "hidden" are the same
56
+ * client-visible signal — the id surfaces as that query's error, never as a null.
57
+ */
58
+ transport?: BatchFirestoreDocsTransport;
59
+ /**
60
+ * Time in milliseconds an ABSENT (`null`) result stays fresh (default: 30 seconds).
61
+ * Deliberately much shorter than `staleTime`: a missing doc is usually a doc that has
62
+ * not been mirrored/created YET, and holding "missing" as fresh for the full staleTime
63
+ * is what leaves a row blank long after the doc appears.
64
+ */
65
+ absentStaleTime?: number;
66
+ /**
67
+ * Backoff ladder (ms) for re-reading an id that resolved absent, one re-read per rung
68
+ * per absence episode (default: {@link ABSENT_RETRY_DELAYS_MS}). Pass `[]` to ask for no
69
+ * re-reads.
70
+ *
71
+ * The timer is per cache key, so many consumers of the same absent id still cost one
72
+ * re-read per rung — and consumers that disagree combine by UNION, never by mount order:
73
+ * the key polls if ANY mounted consumer wants polling, on the LONGEST ladder among them.
74
+ * `[]` therefore only silences re-reads that no other mounted consumer asked for. The
75
+ * effective ladder is recomputed on every mount and unmount; an episode already in
76
+ * progress continues on the new ladder's remaining rungs, or stops if it is now empty.
77
+ */
78
+ absentRetryDelaysMs?: readonly number[];
43
79
  };
44
80
  export type BatchFirestoreDocsResult<T> = {
45
81
  /**
46
- * Map of docId -> document data
47
- * Only includes documents that exist in Firestore
82
+ * Map of docId -> document data.
83
+ * Only includes documents that exist AND are currently readable: an id whose query is in
84
+ * an error state is excluded, so a previously cached doc is never presented as readable
85
+ * after access to it fails.
48
86
  */
49
87
  data: Record<string, T>;
50
88
  /**
51
- * True if any batch queries are still loading
89
+ * True while any requested id has not resolved yet
52
90
  */
53
91
  isLoading: boolean;
54
92
  /**
55
- * True if any batch queries have errored
93
+ * True if any requested id is in an error state
56
94
  */
57
95
  isError: boolean;
58
96
  /**
59
- * First error from batch queries, if any
97
+ * First error across the requested ids, if any
60
98
  */
61
99
  error: Error | null;
62
100
  /**
63
- * Refetch all documents (even cached ones)
101
+ * Refetch every requested id, cached ones included. A no-op in subscribe mode.
64
102
  */
65
103
  refetch: () => Promise<void>;
66
104
  };
67
105
  /**
68
- * Efficiently batch fetch Firestore documents with individual caching
106
+ * Resolve many document ids into individually observed, individually cached React Query
107
+ * entries at `[queryKeyPrefix, id]`.
108
+ *
109
+ * There is exactly ONE data cache: the per-id entries. Each id owns a real query with a
110
+ * real `queryFn`, so exact-key invalidation, `refetchQueries({ type: 'active' })`, and
111
+ * `refetchOnMount` all work on a single id the way they work on any other query — and the
112
+ * returned `data` is derived reactively from those query results, never read imperatively.
69
113
  *
70
- * Features:
71
- * - Each document is cached individually for maximum reuse
72
- * - Only fetches documents that are missing or stale
73
- * - Respects Firestore's 30-item limit for 'in' queries
74
- * - Returns combined result as Record<docId, data>
114
+ * A nonexistent document resolves to `null` (negative caching) and is excluded from `data`;
115
+ * an absent id is then re-read on a bounded ladder (see `absentRetryDelaysMs`) so a doc
116
+ * that appears shortly afterwards shows up without an invalidation.
75
117
  *
76
118
  * @example
77
119
  * ```typescript
@@ -80,10 +122,10 @@ export type BatchFirestoreDocsResult<T> = {
80
122
  * collectionPath: 'publicUsers',
81
123
  * ids: ['user1', 'user2', 'user3'],
82
124
  * queryKeyPrefix: 'publicUser',
83
- * staleTime: 30 * 60 * 1000, // 30 minutes
125
+ * staleTime: 30 * 60 * 1000,
84
126
  * });
85
127
  * // users = { user1: {...}, user2: {...}, user3: {...} }
86
128
  * ```
87
129
  */
88
- export declare function useBatchFirestoreDocs<T extends Record<string, any>>({ db, collectionPath, ids, queryKeyPrefix, staleTime, gcTime, enabled, subscribe, }: BatchFirestoreDocsOptions): BatchFirestoreDocsResult<T>;
130
+ export declare function useBatchFirestoreDocs<T extends Record<string, any>>({ db, collectionPath, ids, queryKeyPrefix, staleTime, gcTime, enabled, subscribe, transport, absentStaleTime, absentRetryDelaysMs, }: BatchFirestoreDocsOptions): BatchFirestoreDocsResult<T>;
89
131
  //# sourceMappingURL=useBatchFirestoreDocs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useBatchFirestoreDocs.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/useBatchFirestoreDocs.ts"],"names":[],"mappings":"AAIA,OAAO,EAAiD,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAKnG,MAAM,MAAM,yBAAyB,GAAG;IACtC;;OAEG;IACH,EAAE,EAAE,SAAS,CAAC;IAEd;;OAEG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,GAAG,EAAE,MAAM,EAAE,CAAC;IAEd;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,wBAAwB,CAAC,CAAC,IAAI;IACxC;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAExB;;OAEG;IACH,SAAS,EAAE,OAAO,CAAC;IAEnB;;OAEG;IACH,OAAO,EAAE,OAAO,CAAC;IAEjB;;OAEG;IACH,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAEpB;;OAEG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,EACnE,EAAE,EACF,cAAc,EACd,GAAG,EACH,cAAc,EACd,SAA0B,EAC1B,MAAuB,EACvB,OAAc,EACd,SAAiB,GAClB,EAAE,yBAAyB,GAAG,wBAAwB,CAAC,CAAC,CAAC,CAmKzD"}
1
+ {"version":3,"file":"useBatchFirestoreDocs.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/useBatchFirestoreDocs.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAKpD,wDAAwD;AACxD,MAAM,MAAM,2BAA2B,GAAG,OAAO,GAAG,KAAK,CAAC;AAE1D,MAAM,MAAM,yBAAyB,GAAG;IACtC;;OAEG;IACH,EAAE,EAAE,SAAS,CAAC;IAEd;;OAEG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,GAAG,EAAE,MAAM,EAAE,CAAC;IAEd;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,2BAA2B,CAAC;IAExC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;;;;;;;;OAWG;IACH,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,wBAAwB,CAAC,CAAC,IAAI;IACxC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAExB;;OAEG;IACH,SAAS,EAAE,OAAO,CAAC;IAEnB;;OAEG;IACH,OAAO,EAAE,OAAO,CAAC;IAEjB;;OAEG;IACH,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAEpB;;OAEG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAWF;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,EACnE,EAAE,EACF,cAAc,EACd,GAAG,EACH,cAAc,EACd,SAA0B,EAC1B,MAAuB,EACvB,OAAc,EACd,SAAiB,EACjB,SAAmB,EACnB,eAA2B,EAC3B,mBAA4C,GAC7C,EAAE,yBAAyB,GAAG,wBAAwB,CAAC,CAAC,CAAC,CA4IzD"}
@@ -1,17 +1,31 @@
1
1
  'use client';
2
- import { useEffect, useMemo, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useQueries, useQueryClient } from '@tanstack/react-query';
4
- import { collection, getDocs, query, where, documentId } from 'firebase/firestore';
5
- import { subscribeDoc } from './doc-subscription-registry.js';
6
- const FIRESTORE_IN_LIMIT = 30;
4
+ import { isListenerOwned, subscribeDoc } from './doc-subscription-registry.js';
5
+ import { getDocLoader } from './batch-doc-loader.js';
6
+ import { ABSENT_RETRY_DELAYS_MS, trackAbsentDoc } from './absence-scheduler.js';
7
+ function shallowRecordEqual(a, b) {
8
+ const aKeys = Object.keys(a);
9
+ if (aKeys.length !== Object.keys(b).length)
10
+ return false;
11
+ for (const key of aKeys) {
12
+ if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key])
13
+ return false;
14
+ }
15
+ return true;
16
+ }
7
17
  /**
8
- * Efficiently batch fetch Firestore documents with individual caching
18
+ * Resolve many document ids into individually observed, individually cached React Query
19
+ * entries at `[queryKeyPrefix, id]`.
20
+ *
21
+ * There is exactly ONE data cache: the per-id entries. Each id owns a real query with a
22
+ * real `queryFn`, so exact-key invalidation, `refetchQueries({ type: 'active' })`, and
23
+ * `refetchOnMount` all work on a single id the way they work on any other query — and the
24
+ * returned `data` is derived reactively from those query results, never read imperatively.
9
25
  *
10
- * Features:
11
- * - Each document is cached individually for maximum reuse
12
- * - Only fetches documents that are missing or stale
13
- * - Respects Firestore's 30-item limit for 'in' queries
14
- * - Returns combined result as Record<docId, data>
26
+ * A nonexistent document resolves to `null` (negative caching) and is excluded from `data`;
27
+ * an absent id is then re-read on a bounded ladder (see `absentRetryDelaysMs`) so a doc
28
+ * that appears shortly afterwards shows up without an invalidation.
15
29
  *
16
30
  * @example
17
31
  * ```typescript
@@ -20,12 +34,12 @@ const FIRESTORE_IN_LIMIT = 30;
20
34
  * collectionPath: 'publicUsers',
21
35
  * ids: ['user1', 'user2', 'user3'],
22
36
  * queryKeyPrefix: 'publicUser',
23
- * staleTime: 30 * 60 * 1000, // 30 minutes
37
+ * staleTime: 30 * 60 * 1000,
24
38
  * });
25
39
  * // users = { user1: {...}, user2: {...}, user3: {...} }
26
40
  * ```
27
41
  */
28
- export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix, staleTime = 30 * 60 * 1000, gcTime = 60 * 60 * 1000, enabled = true, subscribe = false, }) {
42
+ export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix, staleTime = 30 * 60 * 1000, gcTime = 60 * 60 * 1000, enabled = true, subscribe = false, transport = 'batch', absentStaleTime = 30 * 1000, absentRetryDelaysMs = ABSENT_RETRY_DELAYS_MS, }) {
29
43
  const queryClient = useQueryClient();
30
44
  // Stabilize ids reference to prevent cascading re-renders
31
45
  const idsKey = JSON.stringify(ids);
@@ -35,9 +49,40 @@ export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix,
35
49
  return Array.from(new Set(parsed.filter(Boolean)));
36
50
  }, [idsKey]);
37
51
  // ── Real-time subscribe mode ──────────────────────────────────────────────
38
- // Re-render trigger; bumped whenever a subscribed doc's snapshot lands.
39
- const [snapshotVersion, setSnapshotVersion] = useState(0);
52
+ // Listener errors cannot surface through the per-id queries (they are disabled while
53
+ // subscribed), so they are tracked here and merged into the returned result.
40
54
  const [subscribeError, setSubscribeError] = useState(null);
55
+ // Snapshot re-render trigger. The per-id query observers do notify on the listener's
56
+ // cache write, but only on React Query's batched microtask; bumping state in the
57
+ // snapshot callback keeps a landed snapshot visible in the SAME tick it arrives.
58
+ const [, setSnapshotVersion] = useState(0);
59
+ const results = useQueries({
60
+ queries: uniqueIds.map((id) => ({
61
+ queryKey: [queryKeyPrefix, id],
62
+ queryFn: async ({ signal }) => {
63
+ const loader = getDocLoader(queryClient, db, collectionPath);
64
+ const loaded = transport === 'get' ? await loader.loadOne(id, signal) : await loader.loadBatched(id, signal);
65
+ // If a shared listener took ownership of this key while the read was in flight, the
66
+ // listener's value is the newer truth — `onSnapshot` will not re-emit until the doc
67
+ // changes, so returning the read result here would leave a stale value stuck.
68
+ if (isListenerOwned(queryClient, queryKeyPrefix, id)) {
69
+ const owned = queryClient.getQueryData([queryKeyPrefix, id]);
70
+ if (owned !== undefined)
71
+ return owned;
72
+ }
73
+ return loaded;
74
+ },
75
+ enabled: enabled && !subscribe,
76
+ // The consuming app's global default is `refetchOnMount: false`; these lookups opt
77
+ // back in so a remount re-reads a STALE id (refetch-on-mount never fires on a fresh
78
+ // one, so an all-cache-hit mount still costs nothing).
79
+ refetchOnMount: !subscribe,
80
+ staleTime: subscribe
81
+ ? Infinity
82
+ : (query) => query.state.data === null ? absentStaleTime : staleTime,
83
+ gcTime,
84
+ })),
85
+ });
41
86
  useEffect(() => {
42
87
  if (!subscribe || !enabled || uniqueIds.length === 0)
43
88
  return;
@@ -48,110 +93,73 @@ export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix,
48
93
  collectionPath,
49
94
  queryKeyPrefix,
50
95
  id,
51
- onUpdate: () => setSnapshotVersion((v) => v + 1),
96
+ onUpdate: () => setSnapshotVersion((version) => version + 1),
52
97
  onError: (error) => setSubscribeError(error),
53
98
  }));
54
99
  return () => {
55
100
  for (const unsubscribe of unsubscribers)
56
101
  unsubscribe();
57
102
  };
58
- // eslint-disable-next-line react-hooks/exhaustive-deps -- idsKey stands in for uniqueIds
59
- }, [idsKey, subscribe, enabled, collectionPath, queryKeyPrefix, queryClient, db]);
60
- // Separate IDs into cached/fresh vs needs-fetch (one-shot path only)
61
- const { fetchIds } = useMemo(() => {
62
- if (!enabled || subscribe) {
63
- return { fetchIds: [] };
64
- }
65
- const needsFetch = [];
66
- for (const id of uniqueIds) {
67
- const queryKey = [queryKeyPrefix, id];
68
- const state = queryClient.getQueryState(queryKey);
69
- // Check if data exists and is still fresh. `null` counts as data — it marks a
70
- // known-absent doc (negative caching) and must not be re-fetched while fresh.
71
- const hasResolvedData = state !== undefined && state.data !== undefined;
72
- if (!(hasResolvedData && state.dataUpdatedAt && Date.now() - state.dataUpdatedAt < staleTime)) {
73
- needsFetch.push(id);
74
- }
75
- }
76
- return { fetchIds: needsFetch };
77
- }, [uniqueIds, queryKeyPrefix, queryClient, staleTime, enabled, subscribe]);
78
- // Batch the fetchIds into groups of 30
79
- const batches = useMemo(() => {
80
- const result = [];
81
- for (let i = 0; i < fetchIds.length; i += FIRESTORE_IN_LIMIT) {
82
- result.push(fetchIds.slice(i, i + FIRESTORE_IN_LIMIT));
83
- }
84
- return result;
85
- }, [fetchIds]);
86
- // Fetch each batch and populate individual cache entries
87
- const batchQueries = useQueries({
88
- queries: batches.map((batch, batchIndex) => ({
89
- queryKey: [queryKeyPrefix, 'batch', batchIndex, ...[...batch].sort()],
90
- queryFn: async () => {
91
- if (batch.length === 0)
92
- return [];
93
- const q = query(collection(db, collectionPath), where(documentId(), 'in', batch));
94
- const snapshot = await getDocs(q);
95
- const docs = [];
96
- const returnedIds = new Set();
97
- // Process each document and update individual cache entries
98
- snapshot.forEach((doc) => {
99
- const data = { id: doc.id, ...doc.data() };
100
- docs.push(data);
101
- returnedIds.add(doc.id);
102
- // Populate individual cache entry
103
- queryClient.setQueryData([queryKeyPrefix, doc.id], data, { updatedAt: Date.now() });
104
- });
105
- // Negative-cache requested ids that came back missing so they aren't
106
- // re-fetched on every render while fresh (mirrors the subscribe path).
107
- for (const id of batch) {
108
- if (!returnedIds.has(id)) {
109
- queryClient.setQueryData([queryKeyPrefix, id], null, { updatedAt: Date.now() });
110
- }
111
- }
112
- return docs;
113
- },
114
- staleTime,
115
- gcTime,
116
- enabled: enabled && batch.length > 0,
117
- })),
118
- });
119
- // Combine results (one-shot batch path)
120
- const batchIsLoading = batchQueries.some(q => q.isLoading);
121
- const batchIsError = batchQueries.some(q => q.isError);
122
- const batchError = batchQueries.find(q => q.error)?.error || null;
123
- // Trigger re-derivation when batch fetches complete
124
- const batchSettledCount = batchQueries.filter(q => q.isSuccess || q.isError).length;
125
- const batchUpdateTimestamp = batchQueries.reduce((sum, q) => sum + (q.dataUpdatedAt ?? 0), 0);
126
- const data = useMemo(() => {
127
- const combined = {};
128
- // Always read all IDs from individual cache entries. Both paths populate these via
129
- // setQueryData (batch queryFn, or the subscribe listener); we never read batch results
130
- // directly, so IDs can't fall through the cached/fetched split. A `null` entry means a
131
- // doc is known-absent (negative caching, both paths) and is correctly excluded here.
132
- for (const id of uniqueIds) {
133
- const cached = queryClient.getQueryData([queryKeyPrefix, id]);
134
- if (cached) {
135
- combined[id] = cached;
136
- }
103
+ }, [uniqueIds, subscribe, enabled, collectionPath, queryKeyPrefix, queryClient, db]);
104
+ // Bounded re-read ladder for ids that resolved absent. Registration is per cache key, so
105
+ // overlapping consumers share one episode budget. The ladder is rebuilt from its own
106
+ // serialized form so an inline array literal cannot re-register (and reset it) every render.
107
+ const absentDelaysKey = absentRetryDelaysMs.join(',');
108
+ const absentDelays = useMemo(() => absentDelaysKey.split(',').filter(Boolean).map(Number), [absentDelaysKey]);
109
+ useEffect(() => {
110
+ if (uniqueIds.length === 0)
111
+ return;
112
+ const releases = uniqueIds.map((id) => trackAbsentDoc({ queryClient, queryKey: [queryKeyPrefix, id], delays: absentDelays }));
113
+ return () => {
114
+ for (const release of releases)
115
+ release();
116
+ };
117
+ }, [uniqueIds, queryKeyPrefix, queryClient, absentDelays]);
118
+ const nextData = {};
119
+ let anyError = null;
120
+ let oneShotLoading = false;
121
+ let anyUnresolved = false;
122
+ for (let index = 0; index < uniqueIds.length; index += 1) {
123
+ const result = results[index];
124
+ if (!result)
125
+ continue;
126
+ if (result.isError) {
127
+ anyError ??= result.error ?? null;
128
+ continue;
137
129
  }
138
- return combined;
139
- // batchSettledCount/batchUpdateTimestamp/snapshotVersion are intentional re-derivation triggers:
140
- // the cache is read imperatively via getQueryData (non-reactive), so these counters force a
141
- // recompute when a batch fetch settles or a subscribed doc updates.
142
- // eslint-disable-next-line react-hooks/exhaustive-deps
143
- }, [uniqueIds, queryKeyPrefix, queryClient, batchSettledCount, batchUpdateTimestamp, snapshotVersion]);
144
- // In subscribe mode the batch queries are inert; "loading" means a subscribed id has not
145
- // received its first snapshot yet (no cache entry — `null` counts as resolved-missing).
146
- const subscribeIsLoading = subscribe && enabled && uniqueIds.some((id) => queryClient.getQueryData([queryKeyPrefix, id]) === undefined);
147
- const refetch = async () => {
148
- await Promise.all(batchQueries.map(q => q.refetch()));
149
- };
130
+ if (result.isLoading)
131
+ oneShotLoading = true;
132
+ const value = result.data;
133
+ if (value === undefined)
134
+ anyUnresolved = true;
135
+ else if (value)
136
+ nextData[uniqueIds[index]] = value;
137
+ }
138
+ // The map is rebuilt every render; keep the last COMMITTED object while it is unchanged so
139
+ // consumers can safely depend on `data`'s identity. Only the effect writes the ref, so a
140
+ // concurrent render React discards can never leave it pointing at an uncommitted map.
141
+ const committedDataRef = useRef(nextData);
142
+ const data = shallowRecordEqual(committedDataRef.current, nextData)
143
+ ? committedDataRef.current
144
+ : nextData;
145
+ useEffect(() => {
146
+ committedDataRef.current = data;
147
+ }, [data]);
148
+ const refetch = useCallback(async () => {
149
+ // Subscribe mode owns its freshness through the shared listeners — refetching would
150
+ // issue reads against disabled queries and race the listener's value.
151
+ if (subscribe || !enabled)
152
+ return;
153
+ await Promise.all(uniqueIds.map((id) => queryClient.refetchQueries({ queryKey: [queryKeyPrefix, id], exact: true })));
154
+ }, [uniqueIds, subscribe, enabled, queryClient, queryKeyPrefix]);
155
+ // In subscribe mode "loading" means a subscribed id has no cache entry yet (`null`
156
+ // counts as resolved-missing).
157
+ const isLoading = subscribe ? enabled && anyUnresolved : oneShotLoading;
150
158
  return {
151
159
  data,
152
- isLoading: batchIsLoading || subscribeIsLoading,
153
- isError: batchIsError || subscribeError !== null,
154
- error: batchError ?? subscribeError,
160
+ isLoading,
161
+ isError: anyError !== null || subscribeError !== null,
162
+ error: anyError ?? subscribeError,
155
163
  refetch,
156
164
  };
157
165
  }
@@ -1 +1 @@
1
- {"version":3,"file":"useBatchFirestoreDocs.js","sourceRoot":"","sources":["../../../src/react/firestore/useBatchFirestoreDocs.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { useQueries, useQueryClient } from '@tanstack/react-query';\nimport { collection, getDocs, query, where, documentId, type Firestore } from 'firebase/firestore';\nimport { subscribeDoc } from './doc-subscription-registry.js';\n\nconst FIRESTORE_IN_LIMIT = 30;\n\nexport type BatchFirestoreDocsOptions = {\n /**\n * Firestore instance to use for queries\n */\n db: Firestore;\n\n /**\n * Full collection path (e.g., 'publicUsers', 'entities/public')\n */\n collectionPath: string;\n\n /**\n * Array of document IDs to fetch\n */\n ids: string[];\n\n /**\n * Query key prefix for individual document cache entries\n * Each document will be cached as [queryKeyPrefix, docId]\n * @example 'publicUser' -> ['publicUser', 'user123']\n */\n queryKeyPrefix: string;\n\n /**\n * Time in milliseconds to consider data fresh (default: 30 minutes)\n */\n staleTime?: number;\n\n /**\n * Time in milliseconds to keep unused data in cache (default: 1 hour)\n */\n gcTime?: number;\n\n /**\n * Whether queries should be enabled (default: true)\n */\n enabled?: boolean;\n\n /**\n * Real-time mode (default: false). When true, each id is resolved via a shared,\n * reference-counted `onSnapshot` listener instead of a one-shot `getDocs` batch:\n * - a missing doc caches `null` (negative caching) and resolves the instant it appears,\n * - identity edits propagate live, and every browser tab stays consistent,\n * - `staleTime` is irrelevant (listeners keep the cache fresh).\n * Initial read cost matches the batch path; listeners then stay open while mounted.\n * Use for small, frequently-rendered, change-sensitive identity docs (e.g. publicUsers).\n */\n subscribe?: boolean;\n};\n\nexport type BatchFirestoreDocsResult<T> = {\n /**\n * Map of docId -> document data\n * Only includes documents that exist in Firestore\n */\n data: Record<string, T>;\n\n /**\n * True if any batch queries are still loading\n */\n isLoading: boolean;\n\n /**\n * True if any batch queries have errored\n */\n isError: boolean;\n\n /**\n * First error from batch queries, if any\n */\n error: Error | null;\n\n /**\n * Refetch all documents (even cached ones)\n */\n refetch: () => Promise<void>;\n};\n\n/**\n * Efficiently batch fetch Firestore documents with individual caching\n *\n * Features:\n * - Each document is cached individually for maximum reuse\n * - Only fetches documents that are missing or stale\n * - Respects Firestore's 30-item limit for 'in' queries\n * - Returns combined result as Record<docId, data>\n *\n * @example\n * ```typescript\n * const { data: users, isLoading } = useBatchFirestoreDocs<PublicUser>({\n * db,\n * collectionPath: 'publicUsers',\n * ids: ['user1', 'user2', 'user3'],\n * queryKeyPrefix: 'publicUser',\n * staleTime: 30 * 60 * 1000, // 30 minutes\n * });\n * // users = { user1: {...}, user2: {...}, user3: {...} }\n * ```\n */\nexport function useBatchFirestoreDocs<T extends Record<string, any>>({\n db,\n collectionPath,\n ids,\n queryKeyPrefix,\n staleTime = 30 * 60 * 1000,\n gcTime = 60 * 60 * 1000,\n enabled = true,\n subscribe = false,\n}: BatchFirestoreDocsOptions): BatchFirestoreDocsResult<T> {\n const queryClient = useQueryClient();\n\n // Stabilize ids reference to prevent cascading re-renders\n const idsKey = JSON.stringify(ids);\n\n // Deduplicate and filter out empty IDs\n const uniqueIds = useMemo(() => {\n const parsed: string[] = JSON.parse(idsKey);\n return Array.from(new Set(parsed.filter(Boolean)));\n }, [idsKey]);\n\n // ── Real-time subscribe mode ──────────────────────────────────────────────\n // Re-render trigger; bumped whenever a subscribed doc's snapshot lands.\n const [snapshotVersion, setSnapshotVersion] = useState(0);\n const [subscribeError, setSubscribeError] = useState<Error | null>(null);\n\n useEffect(() => {\n if (!subscribe || !enabled || uniqueIds.length === 0) return;\n setSubscribeError(null);\n const unsubscribers = uniqueIds.map((id) =>\n subscribeDoc({\n queryClient,\n db,\n collectionPath,\n queryKeyPrefix,\n id,\n onUpdate: () => setSnapshotVersion((v) => v + 1),\n onError: (error) => setSubscribeError(error),\n }),\n );\n return () => {\n for (const unsubscribe of unsubscribers) unsubscribe();\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps -- idsKey stands in for uniqueIds\n }, [idsKey, subscribe, enabled, collectionPath, queryKeyPrefix, queryClient, db]);\n\n // Separate IDs into cached/fresh vs needs-fetch (one-shot path only)\n const { fetchIds } = useMemo(() => {\n if (!enabled || subscribe) {\n return { fetchIds: [] };\n }\n\n const needsFetch: string[] = [];\n\n for (const id of uniqueIds) {\n const queryKey = [queryKeyPrefix, id];\n const state = queryClient.getQueryState(queryKey);\n\n // Check if data exists and is still fresh. `null` counts as data — it marks a\n // known-absent doc (negative caching) and must not be re-fetched while fresh.\n const hasResolvedData = state !== undefined && state.data !== undefined;\n if (!(hasResolvedData && state.dataUpdatedAt && Date.now() - state.dataUpdatedAt < staleTime)) {\n needsFetch.push(id);\n }\n }\n\n return { fetchIds: needsFetch };\n }, [uniqueIds, queryKeyPrefix, queryClient, staleTime, enabled, subscribe]);\n\n // Batch the fetchIds into groups of 30\n const batches = useMemo(() => {\n const result: string[][] = [];\n for (let i = 0; i < fetchIds.length; i += FIRESTORE_IN_LIMIT) {\n result.push(fetchIds.slice(i, i + FIRESTORE_IN_LIMIT));\n }\n return result;\n }, [fetchIds]);\n\n // Fetch each batch and populate individual cache entries\n const batchQueries = useQueries({\n queries: batches.map((batch, batchIndex) => ({\n queryKey: [queryKeyPrefix, 'batch', batchIndex, ...[...batch].sort()],\n queryFn: async (): Promise<T[]> => {\n if (batch.length === 0) return [];\n\n const q = query(\n collection(db, collectionPath),\n where(documentId(), 'in', batch)\n );\n\n const snapshot = await getDocs(q);\n const docs: T[] = [];\n const returnedIds = new Set<string>();\n\n // Process each document and update individual cache entries\n snapshot.forEach((doc) => {\n const data = { id: doc.id, ...doc.data() } as unknown as T;\n docs.push(data);\n returnedIds.add(doc.id);\n\n // Populate individual cache entry\n queryClient.setQueryData(\n [queryKeyPrefix, doc.id],\n data,\n { updatedAt: Date.now() }\n );\n });\n\n // Negative-cache requested ids that came back missing so they aren't\n // re-fetched on every render while fresh (mirrors the subscribe path).\n for (const id of batch) {\n if (!returnedIds.has(id)) {\n queryClient.setQueryData([queryKeyPrefix, id], null, { updatedAt: Date.now() });\n }\n }\n\n return docs;\n },\n staleTime,\n gcTime,\n enabled: enabled && batch.length > 0,\n })),\n });\n\n // Combine results (one-shot batch path)\n const batchIsLoading = batchQueries.some(q => q.isLoading);\n const batchIsError = batchQueries.some(q => q.isError);\n const batchError = (batchQueries.find(q => q.error)?.error as Error | null) || null;\n\n // Trigger re-derivation when batch fetches complete\n const batchSettledCount = batchQueries.filter(q => q.isSuccess || q.isError).length;\n const batchUpdateTimestamp = batchQueries.reduce((sum, q) => sum + (q.dataUpdatedAt ?? 0), 0);\n\n const data = useMemo(() => {\n const combined: Record<string, T> = {};\n\n // Always read all IDs from individual cache entries. Both paths populate these via\n // setQueryData (batch queryFn, or the subscribe listener); we never read batch results\n // directly, so IDs can't fall through the cached/fetched split. A `null` entry means a\n // doc is known-absent (negative caching, both paths) and is correctly excluded here.\n for (const id of uniqueIds) {\n const cached = queryClient.getQueryData<T>([queryKeyPrefix, id]);\n if (cached) {\n combined[id] = cached;\n }\n }\n\n return combined;\n // batchSettledCount/batchUpdateTimestamp/snapshotVersion are intentional re-derivation triggers:\n // the cache is read imperatively via getQueryData (non-reactive), so these counters force a\n // recompute when a batch fetch settles or a subscribed doc updates.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [uniqueIds, queryKeyPrefix, queryClient, batchSettledCount, batchUpdateTimestamp, snapshotVersion]);\n\n // In subscribe mode the batch queries are inert; \"loading\" means a subscribed id has not\n // received its first snapshot yet (no cache entry — `null` counts as resolved-missing).\n const subscribeIsLoading =\n subscribe && enabled && uniqueIds.some(\n (id) => queryClient.getQueryData([queryKeyPrefix, id]) === undefined,\n );\n\n const refetch = async () => {\n await Promise.all(batchQueries.map(q => q.refetch()));\n };\n\n return {\n data,\n isLoading: batchIsLoading || subscribeIsLoading,\n isError: batchIsError || subscribeError !== null,\n error: batchError ?? subscribeError,\n refetch,\n };\n}\n"]}
1
+ {"version":3,"file":"useBatchFirestoreDocs.js","sourceRoot":"","sources":["../../../src/react/firestore/useBatchFirestoreDocs.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useQueries, useQueryClient } from '@tanstack/react-query';\nimport type { Firestore } from 'firebase/firestore';\nimport { isListenerOwned, subscribeDoc } from './doc-subscription-registry.js';\nimport { getDocLoader } from './batch-doc-loader.js';\nimport { ABSENT_RETRY_DELAYS_MS, trackAbsentDoc } from './absence-scheduler.js';\n\n/** Transport used to resolve an id in one-shot mode. */\nexport type BatchFirestoreDocsTransport = 'batch' | 'get';\n\nexport type BatchFirestoreDocsOptions = {\n /**\n * Firestore instance to use for queries\n */\n db: Firestore;\n\n /**\n * Full collection path (e.g., 'publicUsers', 'entities/public')\n */\n collectionPath: string;\n\n /**\n * Array of document IDs to fetch\n */\n ids: string[];\n\n /**\n * Query key prefix for individual document cache entries\n * Each document is cached as [queryKeyPrefix, docId]\n * @example 'publicUser' -> ['publicUser', 'user123']\n */\n queryKeyPrefix: string;\n\n /**\n * Time in milliseconds a PRESENT document stays fresh (default: 30 minutes).\n * An absent document uses `absentStaleTime` instead.\n */\n staleTime?: number;\n\n /**\n * Time in milliseconds to keep unused data in cache (default: 1 hour)\n */\n gcTime?: number;\n\n /**\n * Whether queries should be enabled (default: true)\n */\n enabled?: boolean;\n\n /**\n * Real-time mode (default: false). When true, each id is resolved via a shared,\n * reference-counted `onSnapshot` listener instead of a one-shot read:\n * - a missing doc caches `null` (negative caching) and resolves the instant it appears,\n * - identity edits propagate live, and every browser tab stays consistent,\n * - `staleTime` is irrelevant (listeners keep the cache fresh) and `refetch()` is a no-op.\n * Initial read cost matches the one-shot path; listeners then stay open while mounted.\n * Use for small, frequently-rendered, change-sensitive docs owned by the signed-in user.\n */\n subscribe?: boolean;\n\n /**\n * One-shot transport (default: `'batch'`).\n *\n * - `'batch'` — ids enqueued in the same microtask are coalesced into\n * `where(documentId(), 'in', …)` queries of at most 30 ids each. Fewest round-trips.\n * - `'get'` — one `getDoc` per id, run concurrently. Required for a collection whose\n * Firestore rules gate reads on `resource.data`: an unconstrained id-list query is\n * denied WHOLESALE there, while per-document gets are evaluated per document, so one\n * unreadable id errors alone instead of failing every id with it. On such a collection\n * a get against a nonexistent doc is also denied, so \"absent\" and \"hidden\" are the same\n * client-visible signal — the id surfaces as that query's error, never as a null.\n */\n transport?: BatchFirestoreDocsTransport;\n\n /**\n * Time in milliseconds an ABSENT (`null`) result stays fresh (default: 30 seconds).\n * Deliberately much shorter than `staleTime`: a missing doc is usually a doc that has\n * not been mirrored/created YET, and holding \"missing\" as fresh for the full staleTime\n * is what leaves a row blank long after the doc appears.\n */\n absentStaleTime?: number;\n\n /**\n * Backoff ladder (ms) for re-reading an id that resolved absent, one re-read per rung\n * per absence episode (default: {@link ABSENT_RETRY_DELAYS_MS}). Pass `[]` to ask for no\n * re-reads.\n *\n * The timer is per cache key, so many consumers of the same absent id still cost one\n * re-read per rung — and consumers that disagree combine by UNION, never by mount order:\n * the key polls if ANY mounted consumer wants polling, on the LONGEST ladder among them.\n * `[]` therefore only silences re-reads that no other mounted consumer asked for. The\n * effective ladder is recomputed on every mount and unmount; an episode already in\n * progress continues on the new ladder's remaining rungs, or stops if it is now empty.\n */\n absentRetryDelaysMs?: readonly number[];\n};\n\nexport type BatchFirestoreDocsResult<T> = {\n /**\n * Map of docId -> document data.\n * Only includes documents that exist AND are currently readable: an id whose query is in\n * an error state is excluded, so a previously cached doc is never presented as readable\n * after access to it fails.\n */\n data: Record<string, T>;\n\n /**\n * True while any requested id has not resolved yet\n */\n isLoading: boolean;\n\n /**\n * True if any requested id is in an error state\n */\n isError: boolean;\n\n /**\n * First error across the requested ids, if any\n */\n error: Error | null;\n\n /**\n * Refetch every requested id, cached ones included. A no-op in subscribe mode.\n */\n refetch: () => Promise<void>;\n};\n\nfunction shallowRecordEqual<T>(a: Record<string, T>, b: Record<string, T>): boolean {\n const aKeys = Object.keys(a);\n if (aKeys.length !== Object.keys(b).length) return false;\n for (const key of aKeys) {\n if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) return false;\n }\n return true;\n}\n\n/**\n * Resolve many document ids into individually observed, individually cached React Query\n * entries at `[queryKeyPrefix, id]`.\n *\n * There is exactly ONE data cache: the per-id entries. Each id owns a real query with a\n * real `queryFn`, so exact-key invalidation, `refetchQueries({ type: 'active' })`, and\n * `refetchOnMount` all work on a single id the way they work on any other query — and the\n * returned `data` is derived reactively from those query results, never read imperatively.\n *\n * A nonexistent document resolves to `null` (negative caching) and is excluded from `data`;\n * an absent id is then re-read on a bounded ladder (see `absentRetryDelaysMs`) so a doc\n * that appears shortly afterwards shows up without an invalidation.\n *\n * @example\n * ```typescript\n * const { data: users, isLoading } = useBatchFirestoreDocs<PublicUser>({\n * db,\n * collectionPath: 'publicUsers',\n * ids: ['user1', 'user2', 'user3'],\n * queryKeyPrefix: 'publicUser',\n * staleTime: 30 * 60 * 1000,\n * });\n * // users = { user1: {...}, user2: {...}, user3: {...} }\n * ```\n */\nexport function useBatchFirestoreDocs<T extends Record<string, any>>({\n db,\n collectionPath,\n ids,\n queryKeyPrefix,\n staleTime = 30 * 60 * 1000,\n gcTime = 60 * 60 * 1000,\n enabled = true,\n subscribe = false,\n transport = 'batch',\n absentStaleTime = 30 * 1000,\n absentRetryDelaysMs = ABSENT_RETRY_DELAYS_MS,\n}: BatchFirestoreDocsOptions): BatchFirestoreDocsResult<T> {\n const queryClient = useQueryClient();\n\n // Stabilize ids reference to prevent cascading re-renders\n const idsKey = JSON.stringify(ids);\n\n // Deduplicate and filter out empty IDs\n const uniqueIds = useMemo(() => {\n const parsed: string[] = JSON.parse(idsKey);\n return Array.from(new Set(parsed.filter(Boolean)));\n }, [idsKey]);\n\n // ── Real-time subscribe mode ──────────────────────────────────────────────\n // Listener errors cannot surface through the per-id queries (they are disabled while\n // subscribed), so they are tracked here and merged into the returned result.\n const [subscribeError, setSubscribeError] = useState<Error | null>(null);\n // Snapshot re-render trigger. The per-id query observers do notify on the listener's\n // cache write, but only on React Query's batched microtask; bumping state in the\n // snapshot callback keeps a landed snapshot visible in the SAME tick it arrives.\n const [, setSnapshotVersion] = useState(0);\n\n const results = useQueries({\n queries: uniqueIds.map((id) => ({\n queryKey: [queryKeyPrefix, id],\n queryFn: async ({ signal }: { signal: AbortSignal }): Promise<T | null> => {\n const loader = getDocLoader(queryClient, db, collectionPath);\n const loaded =\n transport === 'get' ? await loader.loadOne(id, signal) : await loader.loadBatched(id, signal);\n // If a shared listener took ownership of this key while the read was in flight, the\n // listener's value is the newer truth — `onSnapshot` will not re-emit until the doc\n // changes, so returning the read result here would leave a stale value stuck.\n if (isListenerOwned(queryClient, queryKeyPrefix, id)) {\n const owned = queryClient.getQueryData([queryKeyPrefix, id]);\n if (owned !== undefined) return owned as T | null;\n }\n return loaded as T | null;\n },\n enabled: enabled && !subscribe,\n // The consuming app's global default is `refetchOnMount: false`; these lookups opt\n // back in so a remount re-reads a STALE id (refetch-on-mount never fires on a fresh\n // one, so an all-cache-hit mount still costs nothing).\n refetchOnMount: !subscribe,\n staleTime: subscribe\n ? Infinity\n : (query: { state: { data: unknown } }) =>\n query.state.data === null ? absentStaleTime : staleTime,\n gcTime,\n })),\n });\n\n useEffect(() => {\n if (!subscribe || !enabled || uniqueIds.length === 0) return;\n setSubscribeError(null);\n const unsubscribers = uniqueIds.map((id) =>\n subscribeDoc({\n queryClient,\n db,\n collectionPath,\n queryKeyPrefix,\n id,\n onUpdate: () => setSnapshotVersion((version) => version + 1),\n onError: (error) => setSubscribeError(error),\n }),\n );\n return () => {\n for (const unsubscribe of unsubscribers) unsubscribe();\n };\n }, [uniqueIds, subscribe, enabled, collectionPath, queryKeyPrefix, queryClient, db]);\n\n // Bounded re-read ladder for ids that resolved absent. Registration is per cache key, so\n // overlapping consumers share one episode budget. The ladder is rebuilt from its own\n // serialized form so an inline array literal cannot re-register (and reset it) every render.\n const absentDelaysKey = absentRetryDelaysMs.join(',');\n const absentDelays = useMemo(\n () => absentDelaysKey.split(',').filter(Boolean).map(Number),\n [absentDelaysKey],\n );\n\n useEffect(() => {\n if (uniqueIds.length === 0) return;\n const releases = uniqueIds.map((id) =>\n trackAbsentDoc({ queryClient, queryKey: [queryKeyPrefix, id], delays: absentDelays }),\n );\n return () => {\n for (const release of releases) release();\n };\n }, [uniqueIds, queryKeyPrefix, queryClient, absentDelays]);\n\n const nextData: Record<string, T> = {};\n let anyError: Error | null = null;\n let oneShotLoading = false;\n let anyUnresolved = false;\n\n for (let index = 0; index < uniqueIds.length; index += 1) {\n const result = results[index];\n if (!result) continue;\n if (result.isError) {\n anyError ??= (result.error as Error | null) ?? null;\n continue;\n }\n if (result.isLoading) oneShotLoading = true;\n const value = result.data as T | null | undefined;\n if (value === undefined) anyUnresolved = true;\n else if (value) nextData[uniqueIds[index]] = value;\n }\n\n // The map is rebuilt every render; keep the last COMMITTED object while it is unchanged so\n // consumers can safely depend on `data`'s identity. Only the effect writes the ref, so a\n // concurrent render React discards can never leave it pointing at an uncommitted map.\n const committedDataRef = useRef<Record<string, T>>(nextData);\n const data = shallowRecordEqual(committedDataRef.current, nextData)\n ? committedDataRef.current\n : nextData;\n\n useEffect(() => {\n committedDataRef.current = data;\n }, [data]);\n\n const refetch = useCallback(async () => {\n // Subscribe mode owns its freshness through the shared listeners — refetching would\n // issue reads against disabled queries and race the listener's value.\n if (subscribe || !enabled) return;\n await Promise.all(\n uniqueIds.map((id) =>\n queryClient.refetchQueries({ queryKey: [queryKeyPrefix, id], exact: true }),\n ),\n );\n }, [uniqueIds, subscribe, enabled, queryClient, queryKeyPrefix]);\n\n // In subscribe mode \"loading\" means a subscribed id has no cache entry yet (`null`\n // counts as resolved-missing).\n const isLoading = subscribe ? enabled && anyUnresolved : oneShotLoading;\n\n return {\n data,\n isLoading,\n isError: anyError !== null || subscribeError !== null,\n error: anyError ?? subscribeError,\n refetch,\n };\n}\n"]}
@@ -15,6 +15,7 @@ export { useFirestoreLiveInfinite } from './firestore/use-firestore-live-infinit
15
15
  export type { FirestoreLiveInfiniteResult } from './firestore/use-firestore-live-infinite.js';
16
16
  export { useFirestoreSet, useFirestoreUpdate, useFirestoreDelete, useFirestoreBatch, } from './firestore/use-firestore-mutations.js';
17
17
  export { useBatchFirestoreDocs, } from './firestore/useBatchFirestoreDocs.js';
18
- export type { BatchFirestoreDocsOptions, BatchFirestoreDocsResult, } from './firestore/useBatchFirestoreDocs.js';
18
+ export type { BatchFirestoreDocsOptions, BatchFirestoreDocsResult, BatchFirestoreDocsTransport, } from './firestore/useBatchFirestoreDocs.js';
19
+ export { ABSENT_RETRY_DELAYS_MS } from './firestore/absence-scheduler.js';
19
20
  export { useFirestoreSearch } from './search/use-firestore-search.js';
20
21
  //# sourceMappingURL=index.d.ts.map