@aglyn/tenant-feature-instance 1.0.0-beta.293 → 1.0.0
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/package.json +7 -7
- package/src/lib/hooks/firebase/firestore-multitab-wedge.d.ts +12 -0
- package/src/lib/hooks/firebase/firestore-multitab-wedge.js +19 -3
- package/src/lib/hooks/firebase/firestore-multitab-wedge.js.map +1 -1
- package/src/lib/hooks/firebase/firestore-stall-recovery.d.ts +52 -4
- package/src/lib/hooks/firebase/firestore-stall-recovery.js +61 -9
- package/src/lib/hooks/firebase/firestore-stall-recovery.js.map +1 -1
- package/src/lib/hooks/helpers/guarded-seed-write.d.ts +8 -58
- package/src/lib/hooks/helpers/guarded-seed-write.js +48 -44
- package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -1
- package/src/lib/hooks/helpers/listen-confirmation.d.ts +104 -0
- package/src/lib/hooks/helpers/listen-confirmation.js +127 -0
- package/src/lib/hooks/helpers/listen-confirmation.js.map +1 -0
- package/src/lib/hooks/helpers/use-doc.js +7 -0
- package/src/lib/hooks/helpers/use-doc.js.map +1 -1
- package/src/lib/hooks/use-confirmed-doc.js +10 -1
- package/src/lib/hooks/use-confirmed-doc.js.map +1 -1
- package/src/lib/hooks/use-firestore-collection.js +17 -5
- package/src/lib/hooks/use-firestore-collection.js.map +1 -1
- package/src/lib/hooks/use-firestore-doc.js +9 -1
- package/src/lib/hooks/use-firestore-doc.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/use-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { onSnapshot, type DocumentReference } from 'firebase/firestore'\nimport { useEffect, useRef, useState } from 'react'\nimport type { ObservableStatus, FirestoreDocOptions } from '../firebase/firebase-services'\nimport {\n DENIAL_STREAK_TO_REPORT,\n noteFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from '../firestore-denial-reporter'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './listen-options'\nimport useModifyDocCallback, {\n type UseModifyDocCallback,\n} from './use-modify-doc-callback'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n// The refused cadence is shared policy — see `scheduleRefusedReopen` in\n// `firestore-denial-reporter.ts` (AGL-1066, AGL-1440, AGL-2944, AGL-2945).\n\n/**\n * Raw `onSnapshot` listener with its own retry/backoff instead of\n * reactfire's `useFirestoreDocData` — that hook caches the query's RxJS\n * Observable, and once it errors the Observable is *terminated forever*:\n * remounting the component never reopens the Firestore subscription, so a\n * single transient `permission-denied` (e.g. right after sign-in, before\n * Firestore's own credential provider has attached the user's ID token)\n * permanently breaks every caller of `useHost`/`useLayout`/`useScreen`/\n * `useScreenVersion`/`useLayoutVersion` for the rest of the session\n * (AGL-216/217/223). A fresh `onSnapshot` call each retry opens a genuinely\n * new listener, so it recovers once the token has propagated.\n */\nexport function useDocData<T>(\n ref: DocumentReference<T>,\n options?: FirestoreDocOptions<T>,\n): ObservableStatus<T> {\n const idField = options?.idField\n const initialData = options?.initialData\n const [status, setStatus] = useState<'loading' | 'error' | 'success'>(\n initialData !== undefined ? 'success' : 'loading',\n )\n const [data, setData] = useState<T>(initialData as T)\n const [error, setError] = useState<Error | undefined>(undefined)\n const [hasEmitted, setHasEmitted] = useState(initialData !== undefined)\n const [hasPendingWrites, setHasPendingWrites] = useState(false)\n // Un-confirmed until a snapshot says otherwise (AGL-1066).\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const resolveFirstValueRef = useRef<(() => void) | undefined>(undefined)\n const firstValuePromiseRef = useRef<Promise<void> | undefined>(undefined)\n if (!firstValuePromiseRef.current) {\n firstValuePromiseRef.current = new Promise((resolve) => {\n resolveFirstValueRef.current = resolve\n })\n }\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n /**\n * Refusals since the last SERVER snapshot (AGL-1066).\n *\n * Still distinct from `attempt` after the flip: `attempt` is a BUDGET\n * that the heal broadcast may refund, whereas this is EVIDENCE about the\n * server that only a server snapshot may clear. Counted here to publish\n * `serverDenied` and to decide the slow cadence — the `session-health`\n * reporting the other two listener hooks do is a separate decision, and\n * this hook's callers (the besigner document family) are exactly the ones\n * a false verdict would hurt most.\n */\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n /**\n * The retry budget is spent and the server's last word was a refusal —\n * what keeps `status: 'error'` from being undone by the cached emission\n * that precedes every reopened refusal (AGL-1066). See the longer note\n * on the same variable in `use-firestore-collection`.\n */\n let terminal = false\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n // Only the SERVER answering refunds the retry budget, and only a\n // server snapshot ends a refusal (AGL-1066).\n if (!snapshot.metadata.fromCache) {\n attempt = 0\n terminal = false\n deniedStreak = 0\n setServerDenied(false)\n // Evidence only, deliberately not the reporting call: this hook\n // stays out of `session-health` (see `deniedStreak` above), but\n // its server answers are still proof the session can read, which\n // is what `refusedRetryDelayMs` splits the cadence on (AGL-1440).\n noteFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — not the AGL-1066 fault, so the budget is refunded\n // exactly as it always was. This is what keeps offline inert:\n // `unavailable` never enters `deniedStreak`.\n attempt = 0\n }\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n // Always true after ANY emission, cached included: this is what\n // tells a consumer \"I have data, the server refused\" apart from \"I\n // never had data\" — the distinction the besigner editors and the\n // host setup Theme tab blank on (AGL-1066).\n setHasEmitted(true)\n // Kept, not dropped: a snapshot that still carries this client's\n // own queued write is not evidence of what the store holds\n // (AGL-1262). Callers that record a \"last saved\" state need to\n // know the difference.\n setHasPendingWrites(snapshot.metadata.hasPendingWrites)\n setFromCache(snapshot.metadata.fromCache)\n const value = snapshot.exists()\n ? ({\n ...(snapshot.data() as object),\n ...(idField ? { [idField]: snapshot.id } : {}),\n } as T)\n : (undefined as T)\n setData(value)\n resolveFirstValueRef.current?.()\n },\n (err) => {\n if (cancelled) return\n unsubscribe?.()\n // Only a refusal — a lost network produces no error callback at\n // all, and anything that does surface one carries `unavailable`,\n // which must never read as a dead session.\n if ((err as { code?: string })?.code === 'permission-denied') {\n deniedStreak += 1\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) setServerDenied(true)\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n resolveFirstValueRef.current?.()\n // A refusal streak keeps a slow road back for a recovery nobody\n // announced — see the same branch in `use-firestore-collection`.\n // The cadence splits on session-vs-ref evidence (AGL-1440), grows\n // with the streak's age (AGL-2945) and waits while the tab is\n // hidden (AGL-2944).\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n /**\n * Reopen when the session heals — see the long note on the same\n * subscription in `use-firestore-collection` (AGL-1066).\n *\n * This hook is where it counts most: its deps are `[ref.firestore,\n * ref.path]`, neither of which moves when a `stale` re-auth signs the\n * same uid back in, and it is what every besigner editor reads through.\n * Nothing else was ever going to bring those pages back without a reload.\n */\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n subscribe()\n })\n\n return () => {\n cancelled = true\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [ref.firestore, ref.path])\n\n return {\n status,\n hasEmitted,\n isComplete: false,\n data,\n error,\n firstValuePromise: firstValuePromiseRef.current,\n hasPendingWrites,\n fromCache,\n serverDenied,\n }\n}\nexport type UseDocData<T> = typeof useDocData<T>\n\nexport function useDoc<T>(\n ref: DocumentReference<T>,\n options?: FirestoreDocOptions<T>,\n): {doc: ReturnType<UseDocData<T>>, setDoc: ReturnType<UseModifyDocCallback<T>>} {\n return {doc: useDocData(ref, options), setDoc: useModifyDocCallback(ref)}\n}\n\n\nexport default useDoc\n"],"names":["onSnapshot","useEffect","useRef","useState","DENIAL_STREAK_TO_REPORT","noteFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","CONFIRMABLE_LISTEN_OPTIONS","useModifyDocCallback","RETRY_DELAY_MS","MAX_RETRIES","useDocData","ref","options","idField","initialData","status","setStatus","undefined","data","setData","error","setError","hasEmitted","setHasEmitted","hasPendingWrites","setHasPendingWrites","fromCache","setFromCache","serverDenied","setServerDenied","resolveFirstValueRef","firstValuePromiseRef","current","Promise","resolve","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","terminal","subscribe","snapshot","metadata","value","exists","id","err","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout","firestore","path","isComplete","firstValuePromise","useDoc","doc","setDoc"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/use-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type DocumentSnapshot,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState } from 'react'\nimport type { ObservableStatus, FirestoreDocOptions } from '../firebase/firebase-services'\nimport {\n DENIAL_STREAK_TO_REPORT,\n noteFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from '../firestore-denial-reporter'\nimport { sameDocument, trackListenConfirmation } from './listen-confirmation'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './listen-options'\nimport useModifyDocCallback, {\n type UseModifyDocCallback,\n} from './use-modify-doc-callback'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n// The refused cadence is shared policy — see `scheduleRefusedReopen` in\n// `firestore-denial-reporter.ts` (AGL-1066, AGL-1440, AGL-2944, AGL-2945).\n\n/**\n * Raw `onSnapshot` listener with its own retry/backoff instead of\n * reactfire's `useFirestoreDocData` — that hook caches the query's RxJS\n * Observable, and once it errors the Observable is *terminated forever*:\n * remounting the component never reopens the Firestore subscription, so a\n * single transient `permission-denied` (e.g. right after sign-in, before\n * Firestore's own credential provider has attached the user's ID token)\n * permanently breaks every caller of `useHost`/`useLayout`/`useScreen`/\n * `useScreenVersion`/`useLayoutVersion` for the rest of the session\n * (AGL-216/217/223). A fresh `onSnapshot` call each retry opens a genuinely\n * new listener, so it recovers once the token has propagated.\n */\nexport function useDocData<T>(\n ref: DocumentReference<T>,\n options?: FirestoreDocOptions<T>,\n): ObservableStatus<T> {\n const idField = options?.idField\n const initialData = options?.initialData\n const [status, setStatus] = useState<'loading' | 'error' | 'success'>(\n initialData !== undefined ? 'success' : 'loading',\n )\n const [data, setData] = useState<T>(initialData as T)\n const [error, setError] = useState<Error | undefined>(undefined)\n const [hasEmitted, setHasEmitted] = useState(initialData !== undefined)\n const [hasPendingWrites, setHasPendingWrites] = useState(false)\n // Un-confirmed until a snapshot says otherwise (AGL-1066).\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const resolveFirstValueRef = useRef<(() => void) | undefined>(undefined)\n const firstValuePromiseRef = useRef<Promise<void> | undefined>(undefined)\n if (!firstValuePromiseRef.current) {\n firstValuePromiseRef.current = new Promise((resolve) => {\n resolveFirstValueRef.current = resolve\n })\n }\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n /**\n * Refusals since the last SERVER snapshot (AGL-1066).\n *\n * Still distinct from `attempt` after the flip: `attempt` is a BUDGET\n * that the heal broadcast may refund, whereas this is EVIDENCE about the\n * server that only a server snapshot may clear. Counted here to publish\n * `serverDenied` and to decide the slow cadence — the `session-health`\n * reporting the other two listener hooks do is a separate decision, and\n * this hook's callers (the besigner document family) are exactly the ones\n * a false verdict would hurt most.\n */\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n /**\n * The retry budget is spent and the server's last word was a refusal —\n * what keeps `status: 'error'` from being undone by the cached emission\n * that precedes every reopened refusal (AGL-1066). See the longer note\n * on the same variable in `use-firestore-collection`.\n */\n let terminal = false\n /** What a save waits on while this listener is unconfirmed (AGL-4120). */\n const confirmation = trackListenConfirmation()\n /** The cached snapshot on screen, while one is (AGL-4120). */\n let shownFromCache: DocumentSnapshot<DocumentData> | null = null\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n // Only the SERVER answering refunds the retry budget, and only a\n // server snapshot ends a refusal (AGL-1066).\n if (!snapshot.metadata.fromCache) {\n attempt = 0\n terminal = false\n deniedStreak = 0\n setServerDenied(false)\n // Evidence only, deliberately not the reporting call: this hook\n // stays out of `session-health` (see `deniedStreak` above), but\n // its server answers are still proof the session can read, which\n // is what `refusedRetryDelayMs` splits the cadence on (AGL-1440).\n noteFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — not the AGL-1066 fault, so the budget is refunded\n // exactly as it always was. This is what keeps offline inert:\n // `unavailable` never enters `deniedStreak`.\n attempt = 0\n }\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n // Always true after ANY emission, cached included: this is what\n // tells a consumer \"I have data, the server refused\" apart from \"I\n // never had data\" — the distinction the besigner editors and the\n // host setup Theme tab blank on (AGL-1066).\n setHasEmitted(true)\n // Kept, not dropped: a snapshot that still carries this client's\n // own queued write is not evidence of what the store holds\n // (AGL-1262). Callers that record a \"last saved\" state need to\n // know the difference.\n setHasPendingWrites(snapshot.metadata.hasPendingWrites)\n setFromCache(snapshot.metadata.fromCache)\n confirmation.report(\n snapshot.metadata.fromCache,\n !snapshot.metadata.fromCache &&\n shownFromCache !== null &&\n !sameDocument(shownFromCache, snapshot),\n )\n shownFromCache = snapshot.metadata.fromCache ? snapshot : null\n const value = snapshot.exists()\n ? ({\n ...(snapshot.data() as object),\n ...(idField ? { [idField]: snapshot.id } : {}),\n } as T)\n : (undefined as T)\n setData(value)\n resolveFirstValueRef.current?.()\n },\n (err) => {\n if (cancelled) return\n confirmation.release()\n unsubscribe?.()\n // Only a refusal — a lost network produces no error callback at\n // all, and anything that does surface one carries `unavailable`,\n // which must never read as a dead session.\n if ((err as { code?: string })?.code === 'permission-denied') {\n deniedStreak += 1\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) setServerDenied(true)\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n resolveFirstValueRef.current?.()\n // A refusal streak keeps a slow road back for a recovery nobody\n // announced — see the same branch in `use-firestore-collection`.\n // The cadence splits on session-vs-ref evidence (AGL-1440), grows\n // with the streak's age (AGL-2945) and waits while the tab is\n // hidden (AGL-2944).\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n /**\n * Reopen when the session heals — see the long note on the same\n * subscription in `use-firestore-collection` (AGL-1066).\n *\n * This hook is where it counts most: its deps are `[ref.firestore,\n * ref.path]`, neither of which moves when a `stale` re-auth signs the\n * same uid back in, and it is what every besigner editor reads through.\n * Nothing else was ever going to bring those pages back without a reload.\n */\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n subscribe()\n })\n\n return () => {\n cancelled = true\n confirmation.release()\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [ref.firestore, ref.path])\n\n return {\n status,\n hasEmitted,\n isComplete: false,\n data,\n error,\n firstValuePromise: firstValuePromiseRef.current,\n hasPendingWrites,\n fromCache,\n serverDenied,\n }\n}\nexport type UseDocData<T> = typeof useDocData<T>\n\nexport function useDoc<T>(\n ref: DocumentReference<T>,\n options?: FirestoreDocOptions<T>,\n): {doc: ReturnType<UseDocData<T>>, setDoc: ReturnType<UseModifyDocCallback<T>>} {\n return {doc: useDocData(ref, options), setDoc: useModifyDocCallback(ref)}\n}\n\n\nexport default useDoc\n"],"names":["onSnapshot","useEffect","useRef","useState","DENIAL_STREAK_TO_REPORT","noteFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","sameDocument","trackListenConfirmation","CONFIRMABLE_LISTEN_OPTIONS","useModifyDocCallback","RETRY_DELAY_MS","MAX_RETRIES","useDocData","ref","options","idField","initialData","status","setStatus","undefined","data","setData","error","setError","hasEmitted","setHasEmitted","hasPendingWrites","setHasPendingWrites","fromCache","setFromCache","serverDenied","setServerDenied","resolveFirstValueRef","firstValuePromiseRef","current","Promise","resolve","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","terminal","confirmation","shownFromCache","subscribe","snapshot","metadata","report","value","exists","id","err","release","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout","firestore","path","isComplete","firstValuePromise","useDoc","doc","setDoc"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,UAAU,QAIL,qBAAoB;AAC3B,SAASC,SAAS,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAO;AAEnD,SACEC,uBAAuB,EACvBC,uBAAuB,EACvBC,qBAAqB,EACrBC,6BAA6B,QACxB,kCAA8B;AACrC,SAASC,YAAY,EAAEC,uBAAuB,QAAQ,2BAAuB;AAC7E,SAASC,0BAA0B,QAAQ,sBAAkB;AAC7D,OAAOC,0BAEA,+BAA2B;AAElC,MAAMC,iBAAiB;AACvB,MAAMC,cAAc;AACpB,wEAAwE;AACxE,2EAA2E;AAE3E;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,WACdC,GAAyB,EACzBC,OAAgC;IAEhC,MAAMC,UAAUD,2BAAAA,QAASC,OAAO;IAChC,MAAMC,cAAcF,2BAAAA,QAASE,WAAW;IACxC,MAAM,CAACC,QAAQC,UAAU,GAAGjB,SAC1Be,gBAAgBG,YAAY,YAAY;IAE1C,MAAM,CAACC,MAAMC,QAAQ,GAAGpB,SAAYe;IACpC,MAAM,CAACM,OAAOC,SAAS,GAAGtB,SAA4BkB;IACtD,MAAM,CAACK,YAAYC,cAAc,GAAGxB,SAASe,gBAAgBG;IAC7D,MAAM,CAACO,kBAAkBC,oBAAoB,GAAG1B,SAAS;IACzD,2DAA2D;IAC3D,MAAM,CAAC2B,WAAWC,aAAa,GAAG5B,SAAS;IAC3C,MAAM,CAAC6B,cAAcC,gBAAgB,GAAG9B,SAAS;IACjD,MAAM+B,uBAAuBhC,OAAiCmB;IAC9D,MAAMc,uBAAuBjC,OAAkCmB;IAC/D,IAAI,CAACc,qBAAqBC,OAAO,EAAE;QACjCD,qBAAqBC,OAAO,GAAG,IAAIC,QAAQ,CAACC;YAC1CJ,qBAAqBE,OAAO,GAAGE;QACjC;IACF;IAEArC,UAAU;QACRmB,UAAU;QACVK,SAASJ;QAET,IAAIkB,YAAY;QAChB,IAAIC,cAAmC;QACvC,IAAIC,QAA8C;QAClD,gEAAgE,GAChE,IAAIC,sBAA2C;QAC/C,IAAIC,UAAU;QACd;;;;;;;;;;KAUC,GACD,IAAIC,eAAe;QACnB,+DAA+D,GAC/D,IAAIC,wBAAwB;QAC5B;;;;;KAKC,GACD,IAAIC,WAAW;QACf,wEAAwE,GACxE,MAAMC,eAAetC;QACrB,4DAA4D,GAC5D,IAAIuC,iBAAwD;QAE5D,MAAMC,YAAY;YAChBT,cAAcxC,WACZe,KACA,oEAAoE;YACpE,kEAAkE;YAClE,sEAAsE;YACtEL,4BACA,CAACwC;gBACC,IAAIX,WAAW;gBACf,iEAAiE;gBACjE,6CAA6C;gBAC7C,IAAI,CAACW,SAASC,QAAQ,CAACrB,SAAS,EAAE;oBAChCa,UAAU;oBACVG,WAAW;oBACXF,eAAe;oBACfX,gBAAgB;oBAChB,gEAAgE;oBAChE,gEAAgE;oBAChE,iEAAiE;oBACjE,kEAAkE;oBAClE5B;gBACF,OAAO,IAAIuC,iBAAiB,GAAG;oBAC7B,6DAA6D;oBAC7D,+DAA+D;oBAC/D,8DAA8D;oBAC9D,6CAA6C;oBAC7CD,UAAU;gBACZ;gBACA,IAAI,CAACG,UAAU;oBACb1B,UAAU;oBACVK,SAASJ;gBACX;gBACA,gEAAgE;gBAChE,mEAAmE;gBACnE,iEAAiE;gBACjE,4CAA4C;gBAC5CM,cAAc;gBACd,iEAAiE;gBACjE,2DAA2D;gBAC3D,+DAA+D;gBAC/D,uBAAuB;gBACvBE,oBAAoBqB,SAASC,QAAQ,CAACvB,gBAAgB;gBACtDG,aAAamB,SAASC,QAAQ,CAACrB,SAAS;gBACxCiB,aAAaK,MAAM,CACjBF,SAASC,QAAQ,CAACrB,SAAS,EAC3B,CAACoB,SAASC,QAAQ,CAACrB,SAAS,IAC1BkB,mBAAmB,QACnB,CAACxC,aAAawC,gBAAgBE;gBAElCF,iBAAiBE,SAASC,QAAQ,CAACrB,SAAS,GAAGoB,WAAW;gBAC1D,MAAMG,QAAQH,SAASI,MAAM,KACxB,aACKJ,SAAS5B,IAAI,IACbL,UAAU;oBAAE,CAACA,QAAQ,EAAEiC,SAASK,EAAE;gBAAC,IAAI,CAAC,KAE7ClC;gBACLE,QAAQ8B;gBACRnB,qBAAqBE,OAAO,oBAA5BF,qBAAqBE,OAAO,MAA5BF;YACF,GACA,CAACsB;gBACC,IAAIjB,WAAW;gBACfQ,aAAaU,OAAO;gBACpBjB,+BAAAA;gBACA,gEAAgE;gBAChE,iEAAiE;gBACjE,2CAA2C;gBAC3C,IAAI,CAACgB,uBAAD,AAACA,IAA2BE,IAAI,MAAK,qBAAqB;oBAC5Dd,gBAAgB;oBAChB,IAAIA,iBAAiB,GAAGC,wBAAwBc,KAAKC,GAAG;oBACxD,IAAIhB,gBAAgBxC,yBAAyB6B,gBAAgB;gBAC/D;gBACA,IAAIU,UAAU9B,aAAa;oBACzB8B,WAAW;oBACX,IAAIC,eAAe/B,aAAa;wBAC9B6B,sBAAsBpC,sBACpB2C,WACAJ;oBAEJ,OAAO;wBACLJ,QAAQoB,WAAWZ,WAAWrC;oBAChC;gBACF,OAAO;oBACLkC,WAAW;oBACX1B,UAAU;oBACVK,SAAS+B;oBACTtB,qBAAqBE,OAAO,oBAA5BF,qBAAqBE,OAAO,MAA5BF;oBACA,gEAAgE;oBAChE,iEAAiE;oBACjE,kEAAkE;oBAClE,8DAA8D;oBAC9D,qBAAqB;oBACrB,IAAIU,eAAe/B,aAAa;wBAC9B6B,sBAAsBpC,sBACpB2C,WACAJ;oBAEJ;gBACF;YACF;QAEJ;QACAI;QAEA;;;;;;;;KAQC,GACD,MAAMa,kBAAkBvD,8BAA8B;YACpD,IAAIgC,aAAaK,iBAAiB,GAAG;YACrC,IAAIH,OAAO;gBACTsB,aAAatB;gBACbA,QAAQ;YACV;YACAC,uCAAAA;YACAA,sBAAsB;YACtBF,+BAAAA;YACAG,UAAU;YACVM;QACF;QAEA,OAAO;YACLV,YAAY;YACZQ,aAAaU,OAAO;YACpBK;YACA,IAAIrB,OAAOsB,aAAatB;YACxBC,uCAAAA;YACAF,+BAAAA;QACF;IACA,uDAAuD;IACzD,GAAG;QAACzB,IAAIiD,SAAS;QAAEjD,IAAIkD,IAAI;KAAC;IAE5B,OAAO;QACL9C;QACAO;QACAwC,YAAY;QACZ5C;QACAE;QACA2C,mBAAmBhC,qBAAqBC,OAAO;QAC/CR;QACAE;QACAE;IACF;AACF;AAGA,OAAO,SAASoC,OACdrD,GAAyB,EACzBC,OAAgC;IAEhC,OAAO;QAACqD,KAAKvD,WAAWC,KAAKC;QAAUsD,QAAQ3D,qBAAqBI;IAAI;AAC1E;AAGA,eAAeqD,OAAM"}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
18
|
import { doc, getDocFromServer, onSnapshot } from "firebase/firestore";
|
|
19
19
|
import { useEffect, useState } from "react";
|
|
20
|
+
import { trackListenConfirmation } from "./helpers/listen-confirmation.js";
|
|
20
21
|
const RETRY_DELAY_MS = 400;
|
|
21
22
|
const MAX_RETRIES = 8;
|
|
22
23
|
const MAX_RETRY_DELAY_MS = 15000;
|
|
@@ -118,7 +119,13 @@ const MAX_RETRY_DELAY_MS = 15000;
|
|
|
118
119
|
let confirmTimer = null;
|
|
119
120
|
let confirmRequested = false;
|
|
120
121
|
const ref = doc(firestore, pathKey);
|
|
121
|
-
|
|
122
|
+
/** What a save waits on while this read is unconfirmed (AGL-4120). */ const confirmation = trackListenConfirmation();
|
|
123
|
+
/** The cached values on screen, while they are (AGL-4120). */ let shownFromCache;
|
|
124
|
+
const deliver = (exists, id, payload, fromCache)=>{
|
|
125
|
+
const values = JSON.stringify(exists ? payload != null ? payload : null : undefined);
|
|
126
|
+
confirmation.report(fromCache, !fromCache && shownFromCache !== undefined && shownFromCache !== values);
|
|
127
|
+
shownFromCache = fromCache ? values != null ? values : 'missing' : undefined;
|
|
128
|
+
setState({
|
|
122
129
|
data: exists ? _extends({
|
|
123
130
|
$id: id
|
|
124
131
|
}, payload) : undefined,
|
|
@@ -126,6 +133,7 @@ const MAX_RETRY_DELAY_MS = 15000;
|
|
|
126
133
|
error: false,
|
|
127
134
|
fromCache
|
|
128
135
|
});
|
|
136
|
+
};
|
|
129
137
|
const subscribe = ()=>{
|
|
130
138
|
unsubscribe = onSnapshot(ref, (snapshot)=>{
|
|
131
139
|
if (cancelled) return;
|
|
@@ -188,6 +196,7 @@ const MAX_RETRY_DELAY_MS = 15000;
|
|
|
188
196
|
subscribe();
|
|
189
197
|
return ()=>{
|
|
190
198
|
cancelled = true;
|
|
199
|
+
confirmation.release();
|
|
191
200
|
if (timer) clearTimeout(timer);
|
|
192
201
|
if (confirmTimer) clearTimeout(confirmTimer);
|
|
193
202
|
unsubscribe == null ? void 0 : unsubscribe();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-confirmed-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n doc,\n getDocFromServer,\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type DocumentSnapshot,\n type Firestore,\n} from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 8\nconst MAX_RETRY_DELAY_MS = 15_000\n\nexport interface ConfirmedDocState<T> {\n /** The doc's data (with `$id`), or undefined while loading / when absent. */\n data: T | undefined\n /**\n * True once an answer worth acting on has arrived: any snapshot that has\n * data, or a MISS the server itself has confirmed. Consumers must not\n * 404, fall back to a default, or otherwise treat \"no doc\" as real until\n * this is true.\n */\n ready: boolean\n /** True only when retries were exhausted — offer a retry, never a 404. */\n error: boolean\n /**\n * `data` is being served from the local cache and the server has not\n * confirmed it (AGL-1066). Never a reason to withhold the value — only a\n * reason not to let it decide something irreversible.\n */\n fromCache: boolean\n}\n\nexport interface UseConfirmedDocOptions {\n /**\n * Also confirm a cache-served hit that EXISTS, not just a miss (AGL-1066).\n *\n * The base contract trusts any snapshot that exists, whatever its source.\n * That is right for ordinary content — the value was legitimately the\n * user's a moment ago, and blanking it costs more than it buys. It is NOT\n * right for the entitlement doc: `orgs/{orgId}` is the only plan source\n * (AGL-238), and a cached copy from before a plan change renders the org\n * at the wrong tier for as long as the session stays stale. AGL-887 closed\n * the tombstone direction of exactly this fault (a Business workspace\n * rendering as Free); this closes the stale-EXISTS direction.\n *\n * Opt-in because it can cost one extra server read, and only when the\n * listener has actually failed to reach the server — see the deferral in\n * the implementation. Enable it for entitlement-shaped documents only.\n */\n confirmCachedHit?: boolean\n}\n\n/**\n * How long a cached hit is allowed to stand before it is confirmed.\n *\n * Not zero, and this is the whole reason the option is affordable: on a\n * healthy load the first snapshot is routinely from cache and the server\n * snapshot follows within milliseconds. Confirming immediately would bill an\n * extra read on every mount for every user; waiting means the confirm fires\n * only when the server genuinely did not answer.\n */\nconst CONFIRM_CACHED_HIT_DELAY_MS = 1_500\n\n/**\n * Confirms in flight, keyed by document path (AGL-1440).\n *\n * `useCurrentOrg` is a plain hook, not a context, and the console chrome mounts\n * it seven-plus times per page — user menu, org switcher, branding, and three\n * separate paths through `ConsolePluginsGate`. The `onSnapshot`s are nearly\n * free, because the SDK serves identical listens off ONE target; the confirms\n * are not. Each instance scheduled its own `getDocFromServer`, so a console\n * page load billed seven server reads of `orgs/{orgId}` to answer one question.\n *\n * Sharing the PROMISE, rather than suppressing the duplicates, is what keeps\n * this behaviour-preserving: every instance still resolves from a real server\n * snapshot and still calls `deliver(..., fromCache: false)`, so none of them is\n * left displaying a value flagged unconfirmed. Suppressing instead would leave\n * the six quiet instances on `fromCache: true` forever, which is a state\n * consumers are told means \"do not let this decide anything irreversible\".\n *\n * The entry is dropped as soon as it settles, so this is a burst collapser and\n * nothing more: a mount two seconds later asks the server again, exactly as it\n * does today. NOTHING about the entitlement is cached — this shares one READ\n * of the org document; every consumer evaluates the plan off it as before.\n */\nconst confirmsInFlight = new Map<\n string,\n Promise<DocumentSnapshot<DocumentData>>\n>()\n\n/** The server's copy of `ref`, shared with any confirm already in flight. */\nfunction sharedConfirm(\n pathKey: string,\n ref: DocumentReference,\n): Promise<DocumentSnapshot<DocumentData>> {\n const existing = confirmsInFlight.get(pathKey)\n if (existing) return existing\n const pending = getDocFromServer(ref).finally(() => {\n confirmsInFlight.delete(pathKey)\n })\n confirmsInFlight.set(pathKey, pending)\n return pending\n}\n\n/** Test seam — the in-flight map is module scope by design. */\nexport function resetConfirmedDocSharing(): void {\n confirmsInFlight.clear()\n}\n\n/**\n * A doc listen that never presents an unconfirmed miss (AGL-928).\n *\n * This is the shared form of the contract four consumers learned the hard\n * way (AGL-813/827/875/886/887): the console's multi-tab IndexedDB\n * persistence can serve a stale `noDocument` tombstone for a LIVE doc, and\n * a resumed listen never re-sends the correction — so `exists() === false`\n * from cache looks confirmed while being wrong. Symptoms ranged from valid\n * hosts 404ing to a Business workspace rendering as Free.\n *\n * Contract implemented here:\n * - Subscribe with a raw `onSnapshot` + exponential-backoff resubscribe:\n * right after sign-in (and on cold loads behind App Check) the first\n * listen can sit in transient `permission-denied` before the ID token\n * attaches (AGL-216); reactfire's cached Observable would replay that\n * error forever.\n * - A snapshot that EXISTS is trusted immediately, whatever its source.\n * - A snapshot that is MISSING and came from cache is confirmed via\n * `getDocFromServer` before it is believed; a failed confirm leaves\n * `ready` false rather than lying.\n * - Exhausted retries set `error` (and `ready`) so consumers can offer\n * \"try again\" — never a false 404 or a fallback default.\n *\n * For QUERY listens the same contract lives in `useOrgHosts` (empty →\n * `getDocsFromServer` confirm); extract it the same way if a third query\n * consumer appears.\n *\n * @param segments Doc path segments (`['orgs', orgId]`), or null to idle\n * (state resets while null, matching \"clear on scope change\").\n */\nexport function useConfirmedDoc<T extends Record<string, unknown>>(\n firestore: Firestore,\n segments: readonly [string, ...string[]] | null,\n options: UseConfirmedDocOptions = {},\n): ConfirmedDocState<T> {\n const [state, setState] = useState<ConfirmedDocState<T>>({\n data: undefined,\n ready: false,\n error: false,\n fromCache: true,\n })\n // Effect key: a new path must tear down and restart the listen.\n const pathKey = segments ? segments.join('/') : null\n const confirmCachedHit = options.confirmCachedHit\n\n useEffect(() => {\n setState({ data: undefined, ready: false, error: false, fromCache: true })\n if (!pathKey) return\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n let attempt = 0\n // One confirm per mount, and never once the server has answered.\n let confirmTimer: ReturnType<typeof setTimeout> | null = null\n let confirmRequested = false\n const ref = doc(firestore, pathKey)\n\n const deliver = (\n exists: boolean,\n id: string,\n payload: Record<string, unknown> | undefined,\n fromCache: boolean,\n ) =>\n setState({\n data: exists ? ({ $id: id, ...payload } as unknown as T) : undefined,\n ready: true,\n error: false,\n fromCache,\n })\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n (snapshot) => {\n if (cancelled) return\n attempt = 0\n const fromCache = snapshot.metadata.fromCache\n if (!fromCache && confirmTimer) {\n // The server answered on its own; nothing left to confirm.\n clearTimeout(confirmTimer)\n confirmTimer = null\n }\n if (!snapshot.exists() && fromCache) {\n // Unconfirmed miss: ask the server before believing it. On\n // failure stay un-ready — the listener remains subscribed and\n // the next (server) snapshot resolves it.\n void sharedConfirm(pathKey, ref)\n .then((server) => {\n if (cancelled) return\n deliver(server.exists(), server.id, server.data(), false)\n })\n .catch(() => undefined)\n return\n }\n deliver(snapshot.exists(), snapshot.id, snapshot.data(), fromCache)\n\n /**\n * Unconfirmed HIT (AGL-1066), for entitlement-shaped docs only.\n *\n * The value is already delivered above and stays on screen\n * whatever happens here — this never blanks the page and never\n * holds `ready` false, because a transient blip must not become a\n * wrong tier OR a 404. It only replaces a cached answer with the\n * server's when the server can be reached.\n */\n if (\n confirmCachedHit &&\n fromCache &&\n snapshot.exists() &&\n !confirmRequested &&\n !confirmTimer\n ) {\n confirmTimer = setTimeout(() => {\n confirmTimer = null\n confirmRequested = true\n void sharedConfirm(pathKey, ref)\n .then((server) => {\n if (cancelled) return\n deliver(server.exists(), server.id, server.data(), false)\n })\n // Could not reach the server: keep showing the cached value,\n // still flagged `fromCache`. Refusing to answer would be the\n // stale-`ready` false-404 this hook exists to prevent.\n .catch(() => undefined)\n }, CONFIRM_CACHED_HIT_DELAY_MS)\n }\n },\n () => {\n if (cancelled) return\n unsubscribe?.()\n if (attempt < MAX_RETRIES) {\n const delay = Math.min(\n RETRY_DELAY_MS * 2 ** attempt,\n MAX_RETRY_DELAY_MS,\n )\n attempt += 1\n timer = setTimeout(subscribe, delay)\n } else {\n setState({\n data: undefined,\n ready: true,\n error: true,\n fromCache: true,\n })\n }\n },\n )\n }\n subscribe()\n\n return () => {\n cancelled = true\n if (timer) clearTimeout(timer)\n if (confirmTimer) clearTimeout(confirmTimer)\n unsubscribe?.()\n }\n }, [firestore, pathKey, confirmCachedHit])\n\n return state\n}\n\nexport default useConfirmedDoc\n"],"names":["doc","getDocFromServer","onSnapshot","useEffect","useState","RETRY_DELAY_MS","MAX_RETRIES","MAX_RETRY_DELAY_MS","CONFIRM_CACHED_HIT_DELAY_MS","confirmsInFlight","Map","sharedConfirm","pathKey","ref","existing","get","pending","finally","delete","set","resetConfirmedDocSharing","clear","useConfirmedDoc","firestore","segments","options","state","setState","data","undefined","ready","error","fromCache","join","confirmCachedHit","cancelled","unsubscribe","timer","attempt","confirmTimer","confirmRequested","deliver","exists","id","payload","$id","subscribe","snapshot","metadata","clearTimeout","then","server","catch","setTimeout","delay","Math","min"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-confirmed-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n doc,\n getDocFromServer,\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type DocumentSnapshot,\n type Firestore,\n} from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport { trackListenConfirmation } from './helpers/listen-confirmation'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 8\nconst MAX_RETRY_DELAY_MS = 15_000\n\nexport interface ConfirmedDocState<T> {\n /** The doc's data (with `$id`), or undefined while loading / when absent. */\n data: T | undefined\n /**\n * True once an answer worth acting on has arrived: any snapshot that has\n * data, or a MISS the server itself has confirmed. Consumers must not\n * 404, fall back to a default, or otherwise treat \"no doc\" as real until\n * this is true.\n */\n ready: boolean\n /** True only when retries were exhausted — offer a retry, never a 404. */\n error: boolean\n /**\n * `data` is being served from the local cache and the server has not\n * confirmed it (AGL-1066). Never a reason to withhold the value — only a\n * reason not to let it decide something irreversible.\n */\n fromCache: boolean\n}\n\nexport interface UseConfirmedDocOptions {\n /**\n * Also confirm a cache-served hit that EXISTS, not just a miss (AGL-1066).\n *\n * The base contract trusts any snapshot that exists, whatever its source.\n * That is right for ordinary content — the value was legitimately the\n * user's a moment ago, and blanking it costs more than it buys. It is NOT\n * right for the entitlement doc: `orgs/{orgId}` is the only plan source\n * (AGL-238), and a cached copy from before a plan change renders the org\n * at the wrong tier for as long as the session stays stale. AGL-887 closed\n * the tombstone direction of exactly this fault (a Business workspace\n * rendering as Free); this closes the stale-EXISTS direction.\n *\n * Opt-in because it can cost one extra server read, and only when the\n * listener has actually failed to reach the server — see the deferral in\n * the implementation. Enable it for entitlement-shaped documents only.\n */\n confirmCachedHit?: boolean\n}\n\n/**\n * How long a cached hit is allowed to stand before it is confirmed.\n *\n * Not zero, and this is the whole reason the option is affordable: on a\n * healthy load the first snapshot is routinely from cache and the server\n * snapshot follows within milliseconds. Confirming immediately would bill an\n * extra read on every mount for every user; waiting means the confirm fires\n * only when the server genuinely did not answer.\n */\nconst CONFIRM_CACHED_HIT_DELAY_MS = 1_500\n\n/**\n * Confirms in flight, keyed by document path (AGL-1440).\n *\n * `useCurrentOrg` is a plain hook, not a context, and the console chrome mounts\n * it seven-plus times per page — user menu, org switcher, branding, and three\n * separate paths through `ConsolePluginsGate`. The `onSnapshot`s are nearly\n * free, because the SDK serves identical listens off ONE target; the confirms\n * are not. Each instance scheduled its own `getDocFromServer`, so a console\n * page load billed seven server reads of `orgs/{orgId}` to answer one question.\n *\n * Sharing the PROMISE, rather than suppressing the duplicates, is what keeps\n * this behaviour-preserving: every instance still resolves from a real server\n * snapshot and still calls `deliver(..., fromCache: false)`, so none of them is\n * left displaying a value flagged unconfirmed. Suppressing instead would leave\n * the six quiet instances on `fromCache: true` forever, which is a state\n * consumers are told means \"do not let this decide anything irreversible\".\n *\n * The entry is dropped as soon as it settles, so this is a burst collapser and\n * nothing more: a mount two seconds later asks the server again, exactly as it\n * does today. NOTHING about the entitlement is cached — this shares one READ\n * of the org document; every consumer evaluates the plan off it as before.\n */\nconst confirmsInFlight = new Map<\n string,\n Promise<DocumentSnapshot<DocumentData>>\n>()\n\n/** The server's copy of `ref`, shared with any confirm already in flight. */\nfunction sharedConfirm(\n pathKey: string,\n ref: DocumentReference,\n): Promise<DocumentSnapshot<DocumentData>> {\n const existing = confirmsInFlight.get(pathKey)\n if (existing) return existing\n const pending = getDocFromServer(ref).finally(() => {\n confirmsInFlight.delete(pathKey)\n })\n confirmsInFlight.set(pathKey, pending)\n return pending\n}\n\n/** Test seam — the in-flight map is module scope by design. */\nexport function resetConfirmedDocSharing(): void {\n confirmsInFlight.clear()\n}\n\n/**\n * A doc listen that never presents an unconfirmed miss (AGL-928).\n *\n * This is the shared form of the contract four consumers learned the hard\n * way (AGL-813/827/875/886/887): the console's multi-tab IndexedDB\n * persistence can serve a stale `noDocument` tombstone for a LIVE doc, and\n * a resumed listen never re-sends the correction — so `exists() === false`\n * from cache looks confirmed while being wrong. Symptoms ranged from valid\n * hosts 404ing to a Business workspace rendering as Free.\n *\n * Contract implemented here:\n * - Subscribe with a raw `onSnapshot` + exponential-backoff resubscribe:\n * right after sign-in (and on cold loads behind App Check) the first\n * listen can sit in transient `permission-denied` before the ID token\n * attaches (AGL-216); reactfire's cached Observable would replay that\n * error forever.\n * - A snapshot that EXISTS is trusted immediately, whatever its source.\n * - A snapshot that is MISSING and came from cache is confirmed via\n * `getDocFromServer` before it is believed; a failed confirm leaves\n * `ready` false rather than lying.\n * - Exhausted retries set `error` (and `ready`) so consumers can offer\n * \"try again\" — never a false 404 or a fallback default.\n *\n * For QUERY listens the same contract lives in `useOrgHosts` (empty →\n * `getDocsFromServer` confirm); extract it the same way if a third query\n * consumer appears.\n *\n * @param segments Doc path segments (`['orgs', orgId]`), or null to idle\n * (state resets while null, matching \"clear on scope change\").\n */\nexport function useConfirmedDoc<T extends Record<string, unknown>>(\n firestore: Firestore,\n segments: readonly [string, ...string[]] | null,\n options: UseConfirmedDocOptions = {},\n): ConfirmedDocState<T> {\n const [state, setState] = useState<ConfirmedDocState<T>>({\n data: undefined,\n ready: false,\n error: false,\n fromCache: true,\n })\n // Effect key: a new path must tear down and restart the listen.\n const pathKey = segments ? segments.join('/') : null\n const confirmCachedHit = options.confirmCachedHit\n\n useEffect(() => {\n setState({ data: undefined, ready: false, error: false, fromCache: true })\n if (!pathKey) return\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n let attempt = 0\n // One confirm per mount, and never once the server has answered.\n let confirmTimer: ReturnType<typeof setTimeout> | null = null\n let confirmRequested = false\n const ref = doc(firestore, pathKey)\n /** What a save waits on while this read is unconfirmed (AGL-4120). */\n const confirmation = trackListenConfirmation()\n /** The cached values on screen, while they are (AGL-4120). */\n let shownFromCache: string | undefined\n\n const deliver = (\n exists: boolean,\n id: string,\n payload: Record<string, unknown> | undefined,\n fromCache: boolean,\n ) => {\n const values = JSON.stringify(exists ? (payload ?? null) : undefined)\n confirmation.report(\n fromCache,\n !fromCache && shownFromCache !== undefined && shownFromCache !== values,\n )\n shownFromCache = fromCache ? (values ?? 'missing') : undefined\n setState({\n data: exists ? ({ $id: id, ...payload } as unknown as T) : undefined,\n ready: true,\n error: false,\n fromCache,\n })\n }\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n (snapshot) => {\n if (cancelled) return\n attempt = 0\n const fromCache = snapshot.metadata.fromCache\n if (!fromCache && confirmTimer) {\n // The server answered on its own; nothing left to confirm.\n clearTimeout(confirmTimer)\n confirmTimer = null\n }\n if (!snapshot.exists() && fromCache) {\n // Unconfirmed miss: ask the server before believing it. On\n // failure stay un-ready — the listener remains subscribed and\n // the next (server) snapshot resolves it.\n void sharedConfirm(pathKey, ref)\n .then((server) => {\n if (cancelled) return\n deliver(server.exists(), server.id, server.data(), false)\n })\n .catch(() => undefined)\n return\n }\n deliver(snapshot.exists(), snapshot.id, snapshot.data(), fromCache)\n\n /**\n * Unconfirmed HIT (AGL-1066), for entitlement-shaped docs only.\n *\n * The value is already delivered above and stays on screen\n * whatever happens here — this never blanks the page and never\n * holds `ready` false, because a transient blip must not become a\n * wrong tier OR a 404. It only replaces a cached answer with the\n * server's when the server can be reached.\n */\n if (\n confirmCachedHit &&\n fromCache &&\n snapshot.exists() &&\n !confirmRequested &&\n !confirmTimer\n ) {\n confirmTimer = setTimeout(() => {\n confirmTimer = null\n confirmRequested = true\n void sharedConfirm(pathKey, ref)\n .then((server) => {\n if (cancelled) return\n deliver(server.exists(), server.id, server.data(), false)\n })\n // Could not reach the server: keep showing the cached value,\n // still flagged `fromCache`. Refusing to answer would be the\n // stale-`ready` false-404 this hook exists to prevent.\n .catch(() => undefined)\n }, CONFIRM_CACHED_HIT_DELAY_MS)\n }\n },\n () => {\n if (cancelled) return\n unsubscribe?.()\n if (attempt < MAX_RETRIES) {\n const delay = Math.min(\n RETRY_DELAY_MS * 2 ** attempt,\n MAX_RETRY_DELAY_MS,\n )\n attempt += 1\n timer = setTimeout(subscribe, delay)\n } else {\n setState({\n data: undefined,\n ready: true,\n error: true,\n fromCache: true,\n })\n }\n },\n )\n }\n subscribe()\n\n return () => {\n cancelled = true\n confirmation.release()\n if (timer) clearTimeout(timer)\n if (confirmTimer) clearTimeout(confirmTimer)\n unsubscribe?.()\n }\n }, [firestore, pathKey, confirmCachedHit])\n\n return state\n}\n\nexport default useConfirmedDoc\n"],"names":["doc","getDocFromServer","onSnapshot","useEffect","useState","trackListenConfirmation","RETRY_DELAY_MS","MAX_RETRIES","MAX_RETRY_DELAY_MS","CONFIRM_CACHED_HIT_DELAY_MS","confirmsInFlight","Map","sharedConfirm","pathKey","ref","existing","get","pending","finally","delete","set","resetConfirmedDocSharing","clear","useConfirmedDoc","firestore","segments","options","state","setState","data","undefined","ready","error","fromCache","join","confirmCachedHit","cancelled","unsubscribe","timer","attempt","confirmTimer","confirmRequested","confirmation","shownFromCache","deliver","exists","id","payload","values","JSON","stringify","report","$id","subscribe","snapshot","metadata","clearTimeout","then","server","catch","setTimeout","delay","Math","min","release"],"mappings":"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"}
|
|
@@ -19,7 +19,8 @@ import { getDocsFromServer, onSnapshot } from "firebase/firestore";
|
|
|
19
19
|
import { useEffect, useRef, useState } from "react";
|
|
20
20
|
import { CONFIRMABLE_LISTEN_OPTIONS } from "./helpers/listen-options.js";
|
|
21
21
|
import { DENIAL_STREAK_TO_REPORT, denialLabelForQuery, reportFirestoreDenial, reportFirestoreServerRead, scheduleRefusedReopen, subscribeFirestoreSessionHeal } from "./firestore-denial-reporter.js";
|
|
22
|
-
import { armFirestoreStallWatch } from "./firebase/firestore-stall-recovery.js";
|
|
22
|
+
import { armFirestoreStallWatch, noteFirestoreServerAnswer } from "./firebase/firestore-stall-recovery.js";
|
|
23
|
+
import { trackListenConfirmation } from "./helpers/listen-confirmation.js";
|
|
23
24
|
const RETRY_DELAY_MS = 400;
|
|
24
25
|
const MAX_RETRIES = 5;
|
|
25
26
|
/**
|
|
@@ -100,10 +101,12 @@ const MAX_RETRIES = 5;
|
|
|
100
101
|
* first server-confirmed snapshot, by an error (a refusal is not a
|
|
101
102
|
* stall) and by cleanup.
|
|
102
103
|
*/ const disarmStall = armFirestoreStallWatch(q.firestore);
|
|
104
|
+
/** What a save waits on while this listener is unconfirmed (AGL-4120). */ const confirmation = trackListenConfirmation();
|
|
105
|
+
/** Whether the rows on screen came from the cache (AGL-4120). */ let shownFromCache = false;
|
|
103
106
|
// Disappearance tracking (AGL-1196); inert unless opted in.
|
|
104
107
|
const seen = new Set();
|
|
105
108
|
const confirmedGone = new Set();
|
|
106
|
-
const emit = (docs, cached)=>{
|
|
109
|
+
const emit = (docs, cached, changed)=>{
|
|
107
110
|
// A server-answered emission is the one thing that ends the refusal,
|
|
108
111
|
// including the `getDocsFromServer` confirmation path below.
|
|
109
112
|
if (!cached) terminal = false;
|
|
@@ -112,6 +115,8 @@ const MAX_RETRIES = 5;
|
|
|
112
115
|
setError(undefined);
|
|
113
116
|
}
|
|
114
117
|
setFromCache(cached);
|
|
118
|
+
confirmation.report(cached, !cached && shownFromCache && changed);
|
|
119
|
+
shownFromCache = cached;
|
|
115
120
|
setData(docs.map((docSnap)=>{
|
|
116
121
|
const value = _extends({}, docSnap.data());
|
|
117
122
|
if (idField) value[idField] = docSnap.id;
|
|
@@ -165,6 +170,7 @@ const MAX_RETRIES = 5;
|
|
|
165
170
|
*/ // Only the SERVER answering is evidence the session can read.
|
|
166
171
|
if (!snapshot.metadata.fromCache) {
|
|
167
172
|
disarmStall();
|
|
173
|
+
noteFirestoreServerAnswer(q.firestore);
|
|
168
174
|
attempt = 0;
|
|
169
175
|
deniedStreak = 0;
|
|
170
176
|
denialReported = false;
|
|
@@ -179,6 +185,8 @@ const MAX_RETRIES = 5;
|
|
|
179
185
|
// refunded exactly as it always was.
|
|
180
186
|
attempt = 0;
|
|
181
187
|
}
|
|
188
|
+
// Only the values count: a metadata-only tick is not a change.
|
|
189
|
+
const changed = (snapshot.docChanges == null ? void 0 : snapshot.docChanges.call(snapshot).length) > 0;
|
|
182
190
|
if (confirmDisappearances) {
|
|
183
191
|
const present = new Set(snapshot.docs.map((d)=>d.id));
|
|
184
192
|
const vanished = [
|
|
@@ -198,20 +206,23 @@ const MAX_RETRIES = 5;
|
|
|
198
206
|
});
|
|
199
207
|
freshIds.forEach((id)=>seen.add(id));
|
|
200
208
|
// `getDocsFromServer` is server-confirmed by definition.
|
|
201
|
-
emit(fresh.docs, false);
|
|
209
|
+
emit(fresh.docs, false, true);
|
|
202
210
|
}).catch(()=>{
|
|
203
211
|
// Couldn't confirm: show the live result rather than an
|
|
204
212
|
// error. A possibly-short list beats an empty one.
|
|
205
|
-
if (!cancelled)
|
|
213
|
+
if (!cancelled) {
|
|
214
|
+
emit(snapshot.docs, snapshot.metadata.fromCache, changed);
|
|
215
|
+
}
|
|
206
216
|
});
|
|
207
217
|
return;
|
|
208
218
|
}
|
|
209
219
|
snapshot.docs.forEach((d)=>seen.add(d.id));
|
|
210
220
|
}
|
|
211
|
-
emit(snapshot.docs, snapshot.metadata.fromCache);
|
|
221
|
+
emit(snapshot.docs, snapshot.metadata.fromCache, changed);
|
|
212
222
|
}, (err)=>{
|
|
213
223
|
if (cancelled) return;
|
|
214
224
|
disarmStall();
|
|
225
|
+
confirmation.release();
|
|
215
226
|
unsubscribe == null ? void 0 : unsubscribe();
|
|
216
227
|
// Say so once per outage, and only for a refusal — a lost network
|
|
217
228
|
// produces no error callback at all, so this cannot fire offline.
|
|
@@ -301,6 +312,7 @@ const MAX_RETRIES = 5;
|
|
|
301
312
|
return ()=>{
|
|
302
313
|
cancelled = true;
|
|
303
314
|
disarmStall();
|
|
315
|
+
confirmation.release();
|
|
304
316
|
unsubscribeHeal();
|
|
305
317
|
if (timer) clearTimeout(timer);
|
|
306
318
|
cancelRefusedReopen == null ? void 0 : cancelRefusedReopen();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-firestore-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n getDocsFromServer,\n onSnapshot,\n type DocumentData,\n type FirestoreError,\n type Query,\n type QueryDocumentSnapshot,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState, type DependencyList } from 'react'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './helpers/listen-options'\nimport {\n DENIAL_STREAK_TO_REPORT,\n denialLabelForQuery,\n reportFirestoreDenial,\n reportFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from './firestore-denial-reporter'\nimport { armFirestoreStallWatch } from './firebase/firestore-stall-recovery'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n\n/*\n * The cadence once a refusal streak has outlived the retry budget lives in\n * `firestore-denial-reporter.ts` (`scheduleRefusedReopen`), shared by all\n * three listener hooks. It starts at 2s while the fault could be the whole\n * session and at 60s once another listener's server answer proves the\n * session reads and this refusal is about the ref (AGL-1440), then grows\n * with the streak's age to a five-minute ceiling (AGL-2945). A hidden tab\n * sets no timer (AGL-2944). The tab becoming visible, the window regaining\n * focus and a heal broadcast (`subscribeFirestoreSessionHeal`) all reopen\n * at once.\n */\n\nexport type FirestoreCollectionStatus = 'loading' | 'success' | 'error'\n\nexport interface UseFirestoreCollectionOptions {\n idField?: string\n /**\n * Confirm against the server when a document disappears from a NON-EMPTY\n * result, instead of trusting the live snapshot (AGL-1196).\n *\n * OPT-IN, and only worth setting for a query whose `where` reads a MUTABLE\n * field. Such a document can stop matching mid-session — a scope edit, an\n * un/republish — leave the query target, and get cached as a `noDocument`\n * tombstone at its own path, which is then served to every other reader of\n * that path including single-document reads (AGL-827/929).\n *\n * A query with no predicate, or one keyed on `documentId()`, cannot produce\n * this and must not pay for the extra read. Where the predicate can be\n * dropped entirely, drop it — that removes the mechanism rather than\n * repairing it after the fact (AGL-1190).\n */\n confirmDisappearances?: boolean\n}\n\nexport interface UseFirestoreCollectionResult<T> {\n data: T[]\n status: FirestoreCollectionStatus\n error: FirestoreError | undefined\n /**\n * `data` came from the local cache and the server has not confirmed it\n * (AGL-1066). See `ObservableStatus.fromCache` for why this matters and\n * why it is the signal to reach for rather than `staleSession`.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget, and\n * no server snapshot has arrived since (AGL-1066).\n *\n * Kept after the flip rather than folded into `status`, because it says\n * something `status` does not: WHY the listen is failing. `status:\n * 'error'` covers every terminal error; this one counts `permission-denied`\n * alone, so it cannot fire offline, and a single server snapshot clears it.\n * A consumer asking \"is what I am rendering cache-only because the session\n * is refused\" wants this; one asking \"did the read fail at all\" wants\n * `status`.\n */\n serverDenied: boolean\n}\n\n/**\n * Collection-query counterpart to reactfire's `useFirestoreCollectionData`,\n * but backed by a raw `onSnapshot` listener with its own retry/backoff\n * instead of reactfire's cached Observable. That cache terminates forever on\n * its first error — a transient `permission-denied` right after sign-in\n * (before Firestore's own credential provider has attached the user's ID\n * token) permanently breaks the query for the rest of the session, even\n * across remounts. A fresh `onSnapshot` call each retry reopens a genuinely\n * new listener, so it recovers once the token has propagated (AGL-216/217).\n *\n * `buildQuery` is re-invoked from `deps` the same way a `useEffect`/`useMemo`\n * callback would be — pass the same primitive values (ids, uid, firestore)\n * you'd put in a `useMemo` dependency array, not the `Query` object itself.\n */\nexport function useFirestoreCollection<T = DocumentData>(\n buildQuery: () => Query<DocumentData> | null | undefined,\n deps: DependencyList,\n options: UseFirestoreCollectionOptions = {},\n): UseFirestoreCollectionResult<T> {\n const [data, setData] = useState<T[]>([])\n const [status, setStatus] = useState<FirestoreCollectionStatus>('loading')\n const [error, setError] = useState<FirestoreError | undefined>(undefined)\n // Un-confirmed until a snapshot says otherwise: a hook that has not heard\n // from the server yet must not read as server-confirmed.\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const buildQueryRef = useRef(buildQuery)\n buildQueryRef.current = buildQuery\n const idField = options.idField\n const confirmDisappearances = options.confirmDisappearances\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n // Clear on EVERY dep change, not only when the query goes null: when\n // the scope moves between two live queries (org A → org B, host A →\n // host B) the old rows would otherwise stay rendered until the new\n // snapshot arrives — the org-switch \"remnants\" bug (AGL-591). Same\n // hold-nothing-rather-than-show-the-wrong-org rule as useOrgHosts.\n setData([])\n setFromCache(true)\n setServerDenied(false)\n\n const q = buildQueryRef.current()\n if (!q) {\n return\n }\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n /**\n * Refusals since the last SERVER snapshot (AGL-1066).\n *\n * Deliberately not `attempt`: that one is reset by any snapshot,\n * including a cached one, so under `persistentLocalCache` it never\n * reaches its budget and can never report anything.\n */\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n let denialReported = false\n /**\n * The retry budget is spent and the last thing the server did was refuse\n * (AGL-1066).\n *\n * This is what makes `status: 'error'` STICK. Every reopened listen emits\n * from the cache before it learns it is still refused, and an\n * unconditional `setStatus('success')` in the success handler would hand\n * the page back to `success` on that emission — so the surface would\n * oscillate error → success → error at the retry cadence, which is worse\n * to look at than either state. Only a snapshot the SERVER answered\n * clears this, for the same reason it is the only thing that clears\n * `deniedStreak`: a cached emission is not evidence about the server.\n */\n let terminal = false\n const denialLabel = denialLabelForQuery(q)\n /**\n * A listen the server has not confirmed within `FIRESTORE_STALL_MS`,\n * while the browser is online and the tab is visible, is the multi-tab\n * wedge (AGL-3373): no tab is syncing, so an empty cache raises no first\n * snapshot at all and a warm one never confirms. The client is asked to\n * recover — a network cycle that forces the lease election — and this\n * listener then hears from the server like any other. Disarmed by the\n * first server-confirmed snapshot, by an error (a refusal is not a\n * stall) and by cleanup.\n */\n const disarmStall = armFirestoreStallWatch(q.firestore)\n // Disappearance tracking (AGL-1196); inert unless opted in.\n const seen = new Set<string>()\n const confirmedGone = new Set<string>()\n\n const emit = (\n docs: QueryDocumentSnapshot<DocumentData>[],\n cached: boolean,\n ) => {\n // A server-answered emission is the one thing that ends the refusal,\n // including the `getDocsFromServer` confirmation path below.\n if (!cached) terminal = false\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n setFromCache(cached)\n setData(\n docs.map((docSnap) => {\n const value = { ...docSnap.data() } as Record<string, unknown>\n if (idField) value[idField] = docSnap.id\n return value as T\n }),\n )\n }\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n q,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n /**\n * Only a SERVER-answered snapshot refunds the retry budget — the\n * AGL-1066 flip, and the reason it took four commits to become\n * safe.\n *\n * Under `persistentLocalCache` a reopened listen emits from the\n * cache BEFORE it learns the server refused it. Refunding on that\n * emission handed the budget back every cycle, so `attempt` never\n * reached `MAX_RETRIES`, `status` could never become `'error'`, and\n * a dead session looked exactly like a healthy one on every\n * listener-backed page for as long as it stayed dead.\n *\n * Landing this alone would have been a regression, and the three\n * things that made it one are now in place:\n *\n * - the budget is 5 x 400ms, and the error branch used to stop\n * retrying for good, while the heal is an AGL-664 in-place\n * re-auth that takes as long as a human takes to type a\n * password. A resolved re-auth now broadcasts and this listener\n * reopens on it (`subscribeFirestoreSessionHeal` below), and a\n * refusal streak keeps a slow road back for a recovery nobody\n * announced (see the error branch);\n * - `status: 'error'` had to become STICKY, or the cached emission\n * that precedes each refusal would flip it straight back — see\n * `terminal` above;\n * - the surfaces that BLANK on `error` had to learn the\n * difference between \"never had data\" and \"have data, the server\n * refused\". They read `hasEmitted` now, so the besigner canvas\n * and the host setup Theme tab keep rendering across a refusal\n * instead of collapsing to \"Not found\".\n *\n * The withholding is keyed on a REFUSAL having happened, not on the\n * emission being cached, and that is load-bearing: offline stays\n * exactly as inert as it was. A lost network yields `unavailable`\n * (when it yields an error callback at all), which never enters\n * `deniedStreak`, so a cached emission still refunds the budget and\n * an offline listener still never goes terminal. The cache exists\n * for offline; this must not change what happens there.\n */\n // Only the SERVER answering is evidence the session can read.\n if (!snapshot.metadata.fromCache) {\n disarmStall()\n attempt = 0\n deniedStreak = 0\n denialReported = false\n setServerDenied(false)\n // Proof the session can read — stronger than any accumulated\n // denial, and what stops a scoped collaborator's by-design\n // denials from ever adding up to a re-auth prompt.\n reportFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — so this is not the AGL-1066 fault and the budget is\n // refunded exactly as it always was.\n attempt = 0\n }\n\n if (confirmDisappearances) {\n const present = new Set(snapshot.docs.map((d) => d.id))\n const vanished = [...seen].filter(\n (id) => !present.has(id) && !confirmedGone.has(id),\n )\n if (vanished.length) {\n // A fresh (non-resumed) read ignores the resume token, returns\n // the true set and rewrites the cache, dropping any tombstone.\n getDocsFromServer(q)\n .then((fresh) => {\n if (cancelled) return\n const freshIds = new Set(fresh.docs.map((d) => d.id))\n // Only an absence the SERVER agrees with is real. One that\n // came back was a tombstone and must stay eligible to heal\n // if it vanishes again.\n vanished.forEach((id) => {\n if (!freshIds.has(id)) confirmedGone.add(id)\n })\n freshIds.forEach((id) => seen.add(id))\n // `getDocsFromServer` is server-confirmed by definition.\n emit(fresh.docs, false)\n })\n .catch(() => {\n // Couldn't confirm: show the live result rather than an\n // error. A possibly-short list beats an empty one.\n if (!cancelled) emit(snapshot.docs, snapshot.metadata.fromCache)\n })\n return\n }\n snapshot.docs.forEach((d) => seen.add(d.id))\n }\n\n emit(snapshot.docs, snapshot.metadata.fromCache)\n },\n (err) => {\n if (cancelled) return\n disarmStall()\n unsubscribe?.()\n // Say so once per outage, and only for a refusal — a lost network\n // produces no error callback at all, so this cannot fire offline.\n if (err?.code === 'permission-denied') {\n deniedStreak += 1\n // When the streak began — the reference point that lets\n // `refusedRetryDelayMs` ask whether the session has read from\n // the server SINCE this listen started being refused (AGL-1440).\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) {\n setServerDenied(true)\n if (!denialReported) {\n denialReported = true\n reportFirestoreDenial(denialLabel)\n }\n }\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n /**\n * A refusal streak keeps a slow road back (AGL-1066).\n *\n * Before the flip this listener reopened forever, which is also\n * what HEALED it — a token that attached late, an App Check\n * hiccup, one of the AGL-1143 transient token-layer denials.\n * None of those broadcast a heal, because none of them involved\n * a re-auth the console asked for, so stopping here would leave\n * every listen in the console terminal until the user reloaded.\n * The status is corrected; the recovery is not taken away.\n *\n * Any OTHER terminal error still stops exactly as before — this\n * is deliberately keyed on the refusal streak, not on `attempt`.\n * How slow the road is depends on whether the refusal looks like\n * the session or the ref (AGL-1440) and on how long it has\n * lasted (AGL-2945), and a hidden tab waits to be looked at\n * (AGL-2944) — see `scheduleRefusedReopen`.\n */\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n /**\n * The session came back — reopen NOW rather than on the next tick of a\n * cadence tuned for a session that has not (AGL-1066).\n *\n * Gated on this listener actually being refused. A healthy listener has\n * an open subscription and nothing to recover, so it must ignore the\n * broadcast entirely: without this gate a heal would tear down and\n * reopen every listen in the console at once, which is a listener storm\n * dressed up as a fix.\n *\n * What is refunded is `attempt` — the retry BUDGET, which was spent on a\n * fault that is now over. `deniedStreak` is deliberately left alone: it\n * is evidence about the server, and only the server answering may clear\n * it. So `serverDenied` stays true across the heal and goes false on the\n * snapshot that actually proves the read works, and a heal that turns\n * out to be wishful thinking falls straight back to the slow cadence\n * instead of resuming a 400ms storm.\n *\n * `denialReported` IS reset: the outage ended here. If reads are still\n * refused afterwards that is a new one, and it has to be able to raise\n * the AGL-1063 banner again.\n */\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n denialReported = false\n subscribe()\n })\n\n return () => {\n cancelled = true\n disarmStall()\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { data, status, error, fromCache, serverDenied }\n}\n\nexport default useFirestoreCollection\n"],"names":["getDocsFromServer","onSnapshot","useEffect","useRef","useState","CONFIRMABLE_LISTEN_OPTIONS","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","reportFirestoreDenial","reportFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","armFirestoreStallWatch","RETRY_DELAY_MS","MAX_RETRIES","useFirestoreCollection","buildQuery","deps","options","data","setData","status","setStatus","error","setError","undefined","fromCache","setFromCache","serverDenied","setServerDenied","buildQueryRef","current","idField","confirmDisappearances","q","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","denialReported","terminal","denialLabel","disarmStall","firestore","seen","Set","confirmedGone","emit","docs","cached","map","docSnap","value","id","subscribe","snapshot","metadata","present","d","vanished","filter","has","length","then","fresh","freshIds","forEach","add","catch","err","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-firestore-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n getDocsFromServer,\n onSnapshot,\n type DocumentData,\n type FirestoreError,\n type Query,\n type QueryDocumentSnapshot,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState, type DependencyList } from 'react'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './helpers/listen-options'\nimport {\n DENIAL_STREAK_TO_REPORT,\n denialLabelForQuery,\n reportFirestoreDenial,\n reportFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from './firestore-denial-reporter'\nimport {\n armFirestoreStallWatch,\n noteFirestoreServerAnswer,\n} from './firebase/firestore-stall-recovery'\nimport { trackListenConfirmation } from './helpers/listen-confirmation'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n\n/*\n * The cadence once a refusal streak has outlived the retry budget lives in\n * `firestore-denial-reporter.ts` (`scheduleRefusedReopen`), shared by all\n * three listener hooks. It starts at 2s while the fault could be the whole\n * session and at 60s once another listener's server answer proves the\n * session reads and this refusal is about the ref (AGL-1440), then grows\n * with the streak's age to a five-minute ceiling (AGL-2945). A hidden tab\n * sets no timer (AGL-2944). The tab becoming visible, the window regaining\n * focus and a heal broadcast (`subscribeFirestoreSessionHeal`) all reopen\n * at once.\n */\n\nexport type FirestoreCollectionStatus = 'loading' | 'success' | 'error'\n\nexport interface UseFirestoreCollectionOptions {\n idField?: string\n /**\n * Confirm against the server when a document disappears from a NON-EMPTY\n * result, instead of trusting the live snapshot (AGL-1196).\n *\n * OPT-IN, and only worth setting for a query whose `where` reads a MUTABLE\n * field. Such a document can stop matching mid-session — a scope edit, an\n * un/republish — leave the query target, and get cached as a `noDocument`\n * tombstone at its own path, which is then served to every other reader of\n * that path including single-document reads (AGL-827/929).\n *\n * A query with no predicate, or one keyed on `documentId()`, cannot produce\n * this and must not pay for the extra read. Where the predicate can be\n * dropped entirely, drop it — that removes the mechanism rather than\n * repairing it after the fact (AGL-1190).\n */\n confirmDisappearances?: boolean\n}\n\nexport interface UseFirestoreCollectionResult<T> {\n data: T[]\n status: FirestoreCollectionStatus\n error: FirestoreError | undefined\n /**\n * `data` came from the local cache and the server has not confirmed it\n * (AGL-1066). See `ObservableStatus.fromCache` for why this matters and\n * why it is the signal to reach for rather than `staleSession`.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget, and\n * no server snapshot has arrived since (AGL-1066).\n *\n * Kept after the flip rather than folded into `status`, because it says\n * something `status` does not: WHY the listen is failing. `status:\n * 'error'` covers every terminal error; this one counts `permission-denied`\n * alone, so it cannot fire offline, and a single server snapshot clears it.\n * A consumer asking \"is what I am rendering cache-only because the session\n * is refused\" wants this; one asking \"did the read fail at all\" wants\n * `status`.\n */\n serverDenied: boolean\n}\n\n/**\n * Collection-query counterpart to reactfire's `useFirestoreCollectionData`,\n * but backed by a raw `onSnapshot` listener with its own retry/backoff\n * instead of reactfire's cached Observable. That cache terminates forever on\n * its first error — a transient `permission-denied` right after sign-in\n * (before Firestore's own credential provider has attached the user's ID\n * token) permanently breaks the query for the rest of the session, even\n * across remounts. A fresh `onSnapshot` call each retry reopens a genuinely\n * new listener, so it recovers once the token has propagated (AGL-216/217).\n *\n * `buildQuery` is re-invoked from `deps` the same way a `useEffect`/`useMemo`\n * callback would be — pass the same primitive values (ids, uid, firestore)\n * you'd put in a `useMemo` dependency array, not the `Query` object itself.\n */\nexport function useFirestoreCollection<T = DocumentData>(\n buildQuery: () => Query<DocumentData> | null | undefined,\n deps: DependencyList,\n options: UseFirestoreCollectionOptions = {},\n): UseFirestoreCollectionResult<T> {\n const [data, setData] = useState<T[]>([])\n const [status, setStatus] = useState<FirestoreCollectionStatus>('loading')\n const [error, setError] = useState<FirestoreError | undefined>(undefined)\n // Un-confirmed until a snapshot says otherwise: a hook that has not heard\n // from the server yet must not read as server-confirmed.\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const buildQueryRef = useRef(buildQuery)\n buildQueryRef.current = buildQuery\n const idField = options.idField\n const confirmDisappearances = options.confirmDisappearances\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n // Clear on EVERY dep change, not only when the query goes null: when\n // the scope moves between two live queries (org A → org B, host A →\n // host B) the old rows would otherwise stay rendered until the new\n // snapshot arrives — the org-switch \"remnants\" bug (AGL-591). Same\n // hold-nothing-rather-than-show-the-wrong-org rule as useOrgHosts.\n setData([])\n setFromCache(true)\n setServerDenied(false)\n\n const q = buildQueryRef.current()\n if (!q) {\n return\n }\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n /**\n * Refusals since the last SERVER snapshot (AGL-1066).\n *\n * Deliberately not `attempt`: that one is reset by any snapshot,\n * including a cached one, so under `persistentLocalCache` it never\n * reaches its budget and can never report anything.\n */\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n let denialReported = false\n /**\n * The retry budget is spent and the last thing the server did was refuse\n * (AGL-1066).\n *\n * This is what makes `status: 'error'` STICK. Every reopened listen emits\n * from the cache before it learns it is still refused, and an\n * unconditional `setStatus('success')` in the success handler would hand\n * the page back to `success` on that emission — so the surface would\n * oscillate error → success → error at the retry cadence, which is worse\n * to look at than either state. Only a snapshot the SERVER answered\n * clears this, for the same reason it is the only thing that clears\n * `deniedStreak`: a cached emission is not evidence about the server.\n */\n let terminal = false\n const denialLabel = denialLabelForQuery(q)\n /**\n * A listen the server has not confirmed within `FIRESTORE_STALL_MS`,\n * while the browser is online and the tab is visible, is the multi-tab\n * wedge (AGL-3373): no tab is syncing, so an empty cache raises no first\n * snapshot at all and a warm one never confirms. The client is asked to\n * recover — a network cycle that forces the lease election — and this\n * listener then hears from the server like any other. Disarmed by the\n * first server-confirmed snapshot, by an error (a refusal is not a\n * stall) and by cleanup.\n */\n const disarmStall = armFirestoreStallWatch(q.firestore)\n /** What a save waits on while this listener is unconfirmed (AGL-4120). */\n const confirmation = trackListenConfirmation()\n /** Whether the rows on screen came from the cache (AGL-4120). */\n let shownFromCache = false\n // Disappearance tracking (AGL-1196); inert unless opted in.\n const seen = new Set<string>()\n const confirmedGone = new Set<string>()\n\n const emit = (\n docs: QueryDocumentSnapshot<DocumentData>[],\n cached: boolean,\n changed: boolean,\n ) => {\n // A server-answered emission is the one thing that ends the refusal,\n // including the `getDocsFromServer` confirmation path below.\n if (!cached) terminal = false\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n setFromCache(cached)\n confirmation.report(cached, !cached && shownFromCache && changed)\n shownFromCache = cached\n setData(\n docs.map((docSnap) => {\n const value = { ...docSnap.data() } as Record<string, unknown>\n if (idField) value[idField] = docSnap.id\n return value as T\n }),\n )\n }\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n q,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n /**\n * Only a SERVER-answered snapshot refunds the retry budget — the\n * AGL-1066 flip, and the reason it took four commits to become\n * safe.\n *\n * Under `persistentLocalCache` a reopened listen emits from the\n * cache BEFORE it learns the server refused it. Refunding on that\n * emission handed the budget back every cycle, so `attempt` never\n * reached `MAX_RETRIES`, `status` could never become `'error'`, and\n * a dead session looked exactly like a healthy one on every\n * listener-backed page for as long as it stayed dead.\n *\n * Landing this alone would have been a regression, and the three\n * things that made it one are now in place:\n *\n * - the budget is 5 x 400ms, and the error branch used to stop\n * retrying for good, while the heal is an AGL-664 in-place\n * re-auth that takes as long as a human takes to type a\n * password. A resolved re-auth now broadcasts and this listener\n * reopens on it (`subscribeFirestoreSessionHeal` below), and a\n * refusal streak keeps a slow road back for a recovery nobody\n * announced (see the error branch);\n * - `status: 'error'` had to become STICKY, or the cached emission\n * that precedes each refusal would flip it straight back — see\n * `terminal` above;\n * - the surfaces that BLANK on `error` had to learn the\n * difference between \"never had data\" and \"have data, the server\n * refused\". They read `hasEmitted` now, so the besigner canvas\n * and the host setup Theme tab keep rendering across a refusal\n * instead of collapsing to \"Not found\".\n *\n * The withholding is keyed on a REFUSAL having happened, not on the\n * emission being cached, and that is load-bearing: offline stays\n * exactly as inert as it was. A lost network yields `unavailable`\n * (when it yields an error callback at all), which never enters\n * `deniedStreak`, so a cached emission still refunds the budget and\n * an offline listener still never goes terminal. The cache exists\n * for offline; this must not change what happens there.\n */\n // Only the SERVER answering is evidence the session can read.\n if (!snapshot.metadata.fromCache) {\n disarmStall()\n noteFirestoreServerAnswer(q.firestore)\n attempt = 0\n deniedStreak = 0\n denialReported = false\n setServerDenied(false)\n // Proof the session can read — stronger than any accumulated\n // denial, and what stops a scoped collaborator's by-design\n // denials from ever adding up to a re-auth prompt.\n reportFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — so this is not the AGL-1066 fault and the budget is\n // refunded exactly as it always was.\n attempt = 0\n }\n\n // Only the values count: a metadata-only tick is not a change.\n const changed = snapshot.docChanges?.().length > 0\n\n if (confirmDisappearances) {\n const present = new Set(snapshot.docs.map((d) => d.id))\n const vanished = [...seen].filter(\n (id) => !present.has(id) && !confirmedGone.has(id),\n )\n if (vanished.length) {\n // A fresh (non-resumed) read ignores the resume token, returns\n // the true set and rewrites the cache, dropping any tombstone.\n getDocsFromServer(q)\n .then((fresh) => {\n if (cancelled) return\n const freshIds = new Set(fresh.docs.map((d) => d.id))\n // Only an absence the SERVER agrees with is real. One that\n // came back was a tombstone and must stay eligible to heal\n // if it vanishes again.\n vanished.forEach((id) => {\n if (!freshIds.has(id)) confirmedGone.add(id)\n })\n freshIds.forEach((id) => seen.add(id))\n // `getDocsFromServer` is server-confirmed by definition.\n emit(fresh.docs, false, true)\n })\n .catch(() => {\n // Couldn't confirm: show the live result rather than an\n // error. A possibly-short list beats an empty one.\n if (!cancelled) {\n emit(snapshot.docs, snapshot.metadata.fromCache, changed)\n }\n })\n return\n }\n snapshot.docs.forEach((d) => seen.add(d.id))\n }\n\n emit(snapshot.docs, snapshot.metadata.fromCache, changed)\n },\n (err) => {\n if (cancelled) return\n disarmStall()\n confirmation.release()\n unsubscribe?.()\n // Say so once per outage, and only for a refusal — a lost network\n // produces no error callback at all, so this cannot fire offline.\n if (err?.code === 'permission-denied') {\n deniedStreak += 1\n // When the streak began — the reference point that lets\n // `refusedRetryDelayMs` ask whether the session has read from\n // the server SINCE this listen started being refused (AGL-1440).\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) {\n setServerDenied(true)\n if (!denialReported) {\n denialReported = true\n reportFirestoreDenial(denialLabel)\n }\n }\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n /**\n * A refusal streak keeps a slow road back (AGL-1066).\n *\n * Before the flip this listener reopened forever, which is also\n * what HEALED it — a token that attached late, an App Check\n * hiccup, one of the AGL-1143 transient token-layer denials.\n * None of those broadcast a heal, because none of them involved\n * a re-auth the console asked for, so stopping here would leave\n * every listen in the console terminal until the user reloaded.\n * The status is corrected; the recovery is not taken away.\n *\n * Any OTHER terminal error still stops exactly as before — this\n * is deliberately keyed on the refusal streak, not on `attempt`.\n * How slow the road is depends on whether the refusal looks like\n * the session or the ref (AGL-1440) and on how long it has\n * lasted (AGL-2945), and a hidden tab waits to be looked at\n * (AGL-2944) — see `scheduleRefusedReopen`.\n */\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n /**\n * The session came back — reopen NOW rather than on the next tick of a\n * cadence tuned for a session that has not (AGL-1066).\n *\n * Gated on this listener actually being refused. A healthy listener has\n * an open subscription and nothing to recover, so it must ignore the\n * broadcast entirely: without this gate a heal would tear down and\n * reopen every listen in the console at once, which is a listener storm\n * dressed up as a fix.\n *\n * What is refunded is `attempt` — the retry BUDGET, which was spent on a\n * fault that is now over. `deniedStreak` is deliberately left alone: it\n * is evidence about the server, and only the server answering may clear\n * it. So `serverDenied` stays true across the heal and goes false on the\n * snapshot that actually proves the read works, and a heal that turns\n * out to be wishful thinking falls straight back to the slow cadence\n * instead of resuming a 400ms storm.\n *\n * `denialReported` IS reset: the outage ended here. If reads are still\n * refused afterwards that is a new one, and it has to be able to raise\n * the AGL-1063 banner again.\n */\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n denialReported = false\n subscribe()\n })\n\n return () => {\n cancelled = true\n disarmStall()\n confirmation.release()\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { data, status, error, fromCache, serverDenied }\n}\n\nexport default useFirestoreCollection\n"],"names":["getDocsFromServer","onSnapshot","useEffect","useRef","useState","CONFIRMABLE_LISTEN_OPTIONS","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","reportFirestoreDenial","reportFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","armFirestoreStallWatch","noteFirestoreServerAnswer","trackListenConfirmation","RETRY_DELAY_MS","MAX_RETRIES","useFirestoreCollection","buildQuery","deps","options","data","setData","status","setStatus","error","setError","undefined","fromCache","setFromCache","serverDenied","setServerDenied","buildQueryRef","current","idField","confirmDisappearances","q","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","denialReported","terminal","denialLabel","disarmStall","firestore","confirmation","shownFromCache","seen","Set","confirmedGone","emit","docs","cached","changed","report","map","docSnap","value","id","subscribe","snapshot","metadata","docChanges","length","present","d","vanished","filter","has","then","fresh","freshIds","forEach","add","catch","err","release","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout"],"mappings":"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"}
|
|
@@ -19,7 +19,8 @@ import { onSnapshot } from "firebase/firestore";
|
|
|
19
19
|
import { useEffect, useRef, useState } from "react";
|
|
20
20
|
import { CONFIRMABLE_LISTEN_OPTIONS } from "./helpers/listen-options.js";
|
|
21
21
|
import { DENIAL_STREAK_TO_REPORT, denialLabelForQuery, reportFirestoreDenial, reportFirestoreServerRead, scheduleRefusedReopen, subscribeFirestoreSessionHeal } from "./firestore-denial-reporter.js";
|
|
22
|
-
import { armFirestoreStallWatch } from "./firebase/firestore-stall-recovery.js";
|
|
22
|
+
import { armFirestoreStallWatch, noteFirestoreServerAnswer } from "./firebase/firestore-stall-recovery.js";
|
|
23
|
+
import { sameDocument, trackListenConfirmation } from "./helpers/listen-confirmation.js";
|
|
23
24
|
const RETRY_DELAY_MS = 400;
|
|
24
25
|
const MAX_RETRIES = 5;
|
|
25
26
|
/**
|
|
@@ -95,6 +96,8 @@ const MAX_RETRIES = 5;
|
|
|
95
96
|
* first server-confirmed snapshot, by an error (a refusal is not a
|
|
96
97
|
* stall) and by cleanup.
|
|
97
98
|
*/ const disarmStall = armFirestoreStallWatch(ref.firestore);
|
|
99
|
+
/** What a save waits on while this listener is unconfirmed (AGL-4120). */ const confirmation = trackListenConfirmation();
|
|
100
|
+
/** The cached snapshot on screen, while one is (AGL-4120). */ let shownFromCache = null;
|
|
98
101
|
const subscribe = ()=>{
|
|
99
102
|
unsubscribe = onSnapshot(ref, // Without this the cache→server confirmation is never delivered and
|
|
100
103
|
// `fromCache` latches true for the life of the listener, refusing
|
|
@@ -107,11 +110,14 @@ const MAX_RETRIES = 5;
|
|
|
107
110
|
}
|
|
108
111
|
setHasPendingWrites(snapshot.metadata.hasPendingWrites);
|
|
109
112
|
setFromCache(snapshot.metadata.fromCache);
|
|
113
|
+
confirmation.report(snapshot.metadata.fromCache, !snapshot.metadata.fromCache && shownFromCache !== null && !sameDocument(shownFromCache, snapshot));
|
|
114
|
+
shownFromCache = snapshot.metadata.fromCache ? snapshot : null;
|
|
110
115
|
// Only the SERVER answering is evidence the session can read — and
|
|
111
116
|
// the only thing that refunds the retry budget (AGL-1066). See the
|
|
112
117
|
// long note on the same gate in `use-firestore-collection`.
|
|
113
118
|
if (!snapshot.metadata.fromCache) {
|
|
114
119
|
disarmStall();
|
|
120
|
+
noteFirestoreServerAnswer(ref.firestore);
|
|
115
121
|
attempt = 0;
|
|
116
122
|
terminal = false;
|
|
117
123
|
setStatus('success');
|
|
@@ -140,6 +146,7 @@ const MAX_RETRIES = 5;
|
|
|
140
146
|
}, (err)=>{
|
|
141
147
|
if (cancelled) return;
|
|
142
148
|
disarmStall();
|
|
149
|
+
confirmation.release();
|
|
143
150
|
unsubscribe == null ? void 0 : unsubscribe();
|
|
144
151
|
// Say so once per outage, and only for a refusal — a lost network
|
|
145
152
|
// produces no error callback at all, so this cannot fire offline.
|
|
@@ -196,6 +203,7 @@ const MAX_RETRIES = 5;
|
|
|
196
203
|
return ()=>{
|
|
197
204
|
cancelled = true;
|
|
198
205
|
disarmStall();
|
|
206
|
+
confirmation.release();
|
|
199
207
|
unsubscribeHeal();
|
|
200
208
|
if (timer) clearTimeout(timer);
|
|
201
209
|
cancelRefusedReopen == null ? void 0 : cancelRefusedReopen();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-firestore-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type FirestoreError,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState, type DependencyList } from 'react'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './helpers/listen-options'\nimport {\n DENIAL_STREAK_TO_REPORT,\n denialLabelForQuery,\n reportFirestoreDenial,\n reportFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from './firestore-denial-reporter'\nimport { armFirestoreStallWatch } from './firebase/firestore-stall-recovery'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n// The refused cadence is shared policy — see `scheduleRefusedReopen` in\n// `firestore-denial-reporter.ts` (AGL-1066, AGL-1440, AGL-2944, AGL-2945).\n\nexport type FirestoreDocStatus = 'loading' | 'success' | 'error'\n\nexport interface UseFirestoreDocOptions {\n idField?: string\n}\n\nexport interface UseFirestoreDocResult<T> {\n data: T | undefined\n status: FirestoreDocStatus\n error: FirestoreError | undefined\n /**\n * `data` still carries writes this client made that the server has NOT\n * acknowledged. Carried rather than dropped for the same reason as\n * `ObservableStatus.hasPendingWrites` — see that comment (AGL-1262).\n */\n hasPendingWrites: boolean\n /**\n * `data` came from the local cache and the server has not confirmed it\n * (AGL-1066). See `ObservableStatus.fromCache` for why this matters and\n * why it is the signal to reach for rather than `staleSession`.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget and\n * no server snapshot has arrived since (AGL-1066) — the reason behind an\n * `error`, not a duplicate of it. See\n * `UseFirestoreCollectionResult.serverDenied`.\n */\n serverDenied: boolean\n}\n\n/** Where a document read stands: still loading, failed, answered with nothing, or answered. */\nexport type FirestoreDocPhase = 'loading' | 'error' | 'missing' | 'ready'\n\n/**\n * Where a {@link useFirestoreDoc} read stands (AGL-4089), for a page that\n * must not state a document's empty values as facts while it loads. A\n * document is only `missing` once the SERVER says so: the cache answers\n * \"missing\" first for a document it has never held, and while another tab\n * holds the shared cache that answer can stand for half a minute.\n */\nexport function firestoreDocPhase(\n read: Pick<UseFirestoreDocResult<unknown>, 'data' | 'status' | 'fromCache'>,\n): FirestoreDocPhase {\n if (read.data) return 'ready'\n if (read.status === 'error') return 'error'\n return read.status === 'loading' || read.fromCache ? 'loading' : 'missing'\n}\n\n/**\n * Single-document counterpart to `useFirestoreCollection` — see that hook's\n * doc comment for why this exists instead of reactfire's\n * `useFirestoreDocData` (AGL-216/217).\n *\n * `buildRef` is re-invoked from `deps` the same way a `useEffect`/`useMemo`\n * callback would be — pass the same primitive values (ids, uid, firestore)\n * you'd put in a `useMemo` dependency array, not the `DocumentReference`\n * object itself.\n */\nexport function useFirestoreDoc<T = DocumentData>(\n buildRef: () => DocumentReference<DocumentData> | null | undefined,\n deps: DependencyList,\n options: UseFirestoreDocOptions = {},\n): UseFirestoreDocResult<T> {\n const [data, setData] = useState<T | undefined>(undefined)\n const [status, setStatus] = useState<FirestoreDocStatus>('loading')\n const [error, setError] = useState<FirestoreError | undefined>(undefined)\n const [hasPendingWrites, setHasPendingWrites] = useState(false)\n // Un-confirmed until a snapshot says otherwise: a hook that has not heard\n // from the server yet must not read as server-confirmed.\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const buildRefRef = useRef(buildRef)\n buildRefRef.current = buildRef\n const idField = options.idField\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n // Clear on every dep change (AGL-591): a scope move between two live\n // refs must not keep showing the previous doc until the new snapshot\n // arrives. Mirrors use-firestore-collection.\n setData(undefined)\n setHasPendingWrites(false)\n setFromCache(true)\n setServerDenied(false)\n\n const ref = buildRefRef.current()\n if (!ref) {\n return\n }\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n // Refusals since the last SERVER snapshot (AGL-1066) — see the note on\n // `DENIAL_STREAK_TO_REPORT` for why this is not `attempt`.\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n let denialReported = false\n /**\n * The retry budget is spent and the server's last word was a refusal —\n * what keeps `status: 'error'` from being undone by the cached emission\n * that precedes every reopened refusal (AGL-1066). See the longer note\n * on the same variable in `use-firestore-collection`.\n */\n let terminal = false\n const denialLabel = denialLabelForQuery(ref)\n /**\n * A listen the server has not confirmed within `FIRESTORE_STALL_MS`,\n * while the browser is online and the tab is visible, is the multi-tab\n * wedge (AGL-3373): no tab is syncing, so an empty cache raises no first\n * snapshot at all and a warm one never confirms. The client is asked to\n * recover — a network cycle that forces the lease election — and this\n * listener then hears from the server like any other. Disarmed by the\n * first server-confirmed snapshot, by an error (a refusal is not a\n * stall) and by cleanup.\n */\n const disarmStall = armFirestoreStallWatch(ref.firestore)\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n setHasPendingWrites(snapshot.metadata.hasPendingWrites)\n setFromCache(snapshot.metadata.fromCache)\n // Only the SERVER answering is evidence the session can read — and\n // the only thing that refunds the retry budget (AGL-1066). See the\n // long note on the same gate in `use-firestore-collection`.\n if (!snapshot.metadata.fromCache) {\n disarmStall()\n attempt = 0\n terminal = false\n setStatus('success')\n setError(undefined)\n deniedStreak = 0\n denialReported = false\n setServerDenied(false)\n // Proof the session can read — stronger than any accumulated\n // denial, and what stops a scoped collaborator's by-design\n // denials from ever adding up to a re-auth prompt.\n reportFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — not the AGL-1066 fault, so the budget is refunded\n // exactly as it always was. This is what keeps offline inert:\n // `unavailable` never enters `deniedStreak`.\n attempt = 0\n }\n if (!snapshot.exists()) {\n setData(undefined)\n return\n }\n const value = { ...snapshot.data() } as Record<string, unknown>\n if (idField) value[idField] = snapshot.id\n setData(value as T)\n },\n (err) => {\n if (cancelled) return\n disarmStall()\n unsubscribe?.()\n // Say so once per outage, and only for a refusal — a lost network\n // produces no error callback at all, so this cannot fire offline.\n if (err?.code === 'permission-denied') {\n deniedStreak += 1\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) {\n setServerDenied(true)\n if (!denialReported) {\n denialReported = true\n reportFirestoreDenial(denialLabel)\n }\n }\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n // A refusal streak keeps a slow road back for a recovery nobody\n // announced — see the same branch in `use-firestore-collection`.\n // The cadence splits on session-vs-ref evidence (AGL-1440), grows\n // with the streak's age (AGL-2945) and waits while the tab is\n // hidden (AGL-2944).\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n // Reopen when the session heals — see the long note on the same\n // subscription in `use-firestore-collection` (AGL-1066). Gated on this\n // listen actually being refused, so a healthy one ignores the broadcast.\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n denialReported = false\n subscribe()\n })\n\n return () => {\n cancelled = true\n disarmStall()\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { data, status, error, hasPendingWrites, fromCache, serverDenied }\n}\n\nexport default useFirestoreDoc\n"],"names":["onSnapshot","useEffect","useRef","useState","CONFIRMABLE_LISTEN_OPTIONS","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","reportFirestoreDenial","reportFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","armFirestoreStallWatch","RETRY_DELAY_MS","MAX_RETRIES","firestoreDocPhase","read","data","status","fromCache","useFirestoreDoc","buildRef","deps","options","setData","undefined","setStatus","error","setError","hasPendingWrites","setHasPendingWrites","setFromCache","serverDenied","setServerDenied","buildRefRef","current","idField","ref","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","denialReported","terminal","denialLabel","disarmStall","firestore","subscribe","snapshot","metadata","exists","value","id","err","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-firestore-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type DocumentSnapshot,\n type FirestoreError,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState, type DependencyList } from 'react'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './helpers/listen-options'\nimport {\n DENIAL_STREAK_TO_REPORT,\n denialLabelForQuery,\n reportFirestoreDenial,\n reportFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from './firestore-denial-reporter'\nimport {\n armFirestoreStallWatch,\n noteFirestoreServerAnswer,\n} from './firebase/firestore-stall-recovery'\nimport { sameDocument, trackListenConfirmation } from './helpers/listen-confirmation'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n// The refused cadence is shared policy — see `scheduleRefusedReopen` in\n// `firestore-denial-reporter.ts` (AGL-1066, AGL-1440, AGL-2944, AGL-2945).\n\nexport type FirestoreDocStatus = 'loading' | 'success' | 'error'\n\nexport interface UseFirestoreDocOptions {\n idField?: string\n}\n\nexport interface UseFirestoreDocResult<T> {\n data: T | undefined\n status: FirestoreDocStatus\n error: FirestoreError | undefined\n /**\n * `data` still carries writes this client made that the server has NOT\n * acknowledged. Carried rather than dropped for the same reason as\n * `ObservableStatus.hasPendingWrites` — see that comment (AGL-1262).\n */\n hasPendingWrites: boolean\n /**\n * `data` came from the local cache and the server has not confirmed it\n * (AGL-1066). See `ObservableStatus.fromCache` for why this matters and\n * why it is the signal to reach for rather than `staleSession`.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget and\n * no server snapshot has arrived since (AGL-1066) — the reason behind an\n * `error`, not a duplicate of it. See\n * `UseFirestoreCollectionResult.serverDenied`.\n */\n serverDenied: boolean\n}\n\n/** Where a document read stands: still loading, failed, answered with nothing, or answered. */\nexport type FirestoreDocPhase = 'loading' | 'error' | 'missing' | 'ready'\n\n/**\n * Where a {@link useFirestoreDoc} read stands (AGL-4089), for a page that\n * must not state a document's empty values as facts while it loads. A\n * document is only `missing` once the SERVER says so: the cache answers\n * \"missing\" first for a document it has never held, and while another tab\n * holds the shared cache that answer can stand for half a minute.\n */\nexport function firestoreDocPhase(\n read: Pick<UseFirestoreDocResult<unknown>, 'data' | 'status' | 'fromCache'>,\n): FirestoreDocPhase {\n if (read.data) return 'ready'\n if (read.status === 'error') return 'error'\n return read.status === 'loading' || read.fromCache ? 'loading' : 'missing'\n}\n\n/**\n * Single-document counterpart to `useFirestoreCollection` — see that hook's\n * doc comment for why this exists instead of reactfire's\n * `useFirestoreDocData` (AGL-216/217).\n *\n * `buildRef` is re-invoked from `deps` the same way a `useEffect`/`useMemo`\n * callback would be — pass the same primitive values (ids, uid, firestore)\n * you'd put in a `useMemo` dependency array, not the `DocumentReference`\n * object itself.\n */\nexport function useFirestoreDoc<T = DocumentData>(\n buildRef: () => DocumentReference<DocumentData> | null | undefined,\n deps: DependencyList,\n options: UseFirestoreDocOptions = {},\n): UseFirestoreDocResult<T> {\n const [data, setData] = useState<T | undefined>(undefined)\n const [status, setStatus] = useState<FirestoreDocStatus>('loading')\n const [error, setError] = useState<FirestoreError | undefined>(undefined)\n const [hasPendingWrites, setHasPendingWrites] = useState(false)\n // Un-confirmed until a snapshot says otherwise: a hook that has not heard\n // from the server yet must not read as server-confirmed.\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const buildRefRef = useRef(buildRef)\n buildRefRef.current = buildRef\n const idField = options.idField\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n // Clear on every dep change (AGL-591): a scope move between two live\n // refs must not keep showing the previous doc until the new snapshot\n // arrives. Mirrors use-firestore-collection.\n setData(undefined)\n setHasPendingWrites(false)\n setFromCache(true)\n setServerDenied(false)\n\n const ref = buildRefRef.current()\n if (!ref) {\n return\n }\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n // Refusals since the last SERVER snapshot (AGL-1066) — see the note on\n // `DENIAL_STREAK_TO_REPORT` for why this is not `attempt`.\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n let denialReported = false\n /**\n * The retry budget is spent and the server's last word was a refusal —\n * what keeps `status: 'error'` from being undone by the cached emission\n * that precedes every reopened refusal (AGL-1066). See the longer note\n * on the same variable in `use-firestore-collection`.\n */\n let terminal = false\n const denialLabel = denialLabelForQuery(ref)\n /**\n * A listen the server has not confirmed within `FIRESTORE_STALL_MS`,\n * while the browser is online and the tab is visible, is the multi-tab\n * wedge (AGL-3373): no tab is syncing, so an empty cache raises no first\n * snapshot at all and a warm one never confirms. The client is asked to\n * recover — a network cycle that forces the lease election — and this\n * listener then hears from the server like any other. Disarmed by the\n * first server-confirmed snapshot, by an error (a refusal is not a\n * stall) and by cleanup.\n */\n const disarmStall = armFirestoreStallWatch(ref.firestore)\n /** What a save waits on while this listener is unconfirmed (AGL-4120). */\n const confirmation = trackListenConfirmation()\n /** The cached snapshot on screen, while one is (AGL-4120). */\n let shownFromCache: DocumentSnapshot<DocumentData> | null = null\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n setHasPendingWrites(snapshot.metadata.hasPendingWrites)\n setFromCache(snapshot.metadata.fromCache)\n confirmation.report(\n snapshot.metadata.fromCache,\n !snapshot.metadata.fromCache &&\n shownFromCache !== null &&\n !sameDocument(shownFromCache, snapshot),\n )\n shownFromCache = snapshot.metadata.fromCache ? snapshot : null\n // Only the SERVER answering is evidence the session can read — and\n // the only thing that refunds the retry budget (AGL-1066). See the\n // long note on the same gate in `use-firestore-collection`.\n if (!snapshot.metadata.fromCache) {\n disarmStall()\n noteFirestoreServerAnswer(ref.firestore)\n attempt = 0\n terminal = false\n setStatus('success')\n setError(undefined)\n deniedStreak = 0\n denialReported = false\n setServerDenied(false)\n // Proof the session can read — stronger than any accumulated\n // denial, and what stops a scoped collaborator's by-design\n // denials from ever adding up to a re-auth prompt.\n reportFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — not the AGL-1066 fault, so the budget is refunded\n // exactly as it always was. This is what keeps offline inert:\n // `unavailable` never enters `deniedStreak`.\n attempt = 0\n }\n if (!snapshot.exists()) {\n setData(undefined)\n return\n }\n const value = { ...snapshot.data() } as Record<string, unknown>\n if (idField) value[idField] = snapshot.id\n setData(value as T)\n },\n (err) => {\n if (cancelled) return\n disarmStall()\n confirmation.release()\n unsubscribe?.()\n // Say so once per outage, and only for a refusal — a lost network\n // produces no error callback at all, so this cannot fire offline.\n if (err?.code === 'permission-denied') {\n deniedStreak += 1\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) {\n setServerDenied(true)\n if (!denialReported) {\n denialReported = true\n reportFirestoreDenial(denialLabel)\n }\n }\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n // A refusal streak keeps a slow road back for a recovery nobody\n // announced — see the same branch in `use-firestore-collection`.\n // The cadence splits on session-vs-ref evidence (AGL-1440), grows\n // with the streak's age (AGL-2945) and waits while the tab is\n // hidden (AGL-2944).\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n // Reopen when the session heals — see the long note on the same\n // subscription in `use-firestore-collection` (AGL-1066). Gated on this\n // listen actually being refused, so a healthy one ignores the broadcast.\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n denialReported = false\n subscribe()\n })\n\n return () => {\n cancelled = true\n disarmStall()\n confirmation.release()\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { data, status, error, hasPendingWrites, fromCache, serverDenied }\n}\n\nexport default useFirestoreDoc\n"],"names":["onSnapshot","useEffect","useRef","useState","CONFIRMABLE_LISTEN_OPTIONS","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","reportFirestoreDenial","reportFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","armFirestoreStallWatch","noteFirestoreServerAnswer","sameDocument","trackListenConfirmation","RETRY_DELAY_MS","MAX_RETRIES","firestoreDocPhase","read","data","status","fromCache","useFirestoreDoc","buildRef","deps","options","setData","undefined","setStatus","error","setError","hasPendingWrites","setHasPendingWrites","setFromCache","serverDenied","setServerDenied","buildRefRef","current","idField","ref","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","denialReported","terminal","denialLabel","disarmStall","firestore","confirmation","shownFromCache","subscribe","snapshot","metadata","report","exists","value","id","err","release","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout"],"mappings":"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"}
|