import useSWR , { mutate , ConfigInterface } from 'swr' ; import type { SetOptions , FieldValue } from '@firebase/firestore-types' ; import { fuego } from '../context' ; import { useRef , useEffect , useCallback } from 'react' ; import { empty } from '../helpers/empty' ; import { AllowType , Document } from '../types/Document' ; import { collectionCache } from '../classes/Cache' ; import { isDev } from '../helpers/is-dev' ; import { withDocumentDatesParsed } from '../helpers/doc-date-parser' ; import { deleteDocument } from './static-mutations' ; type Options < Doc extends Document = Document > = { /**
     * If `true`, sets up a real-time subscription to the Firestore backend.
     *
     * Default: `false`
     */ listen ? : boolean ; /**
     * An array of key strings that indicate where there will be dates in the document.
     *
     * Example: if your dates are in the `lastUpdated` and `user.createdAt` fields, then pass `{parseDates: ["lastUpdated", "user.createdAt"]}`.
     *
     * This will automatically turn all Firestore dates into JS Date objects, removing the need to do `.toDate()` on your dates.
     */ parseDates ? : ( string | keyof Omit < Doc , 'id' | 'exists' | 'hasPendingWrites' | '__snapshot' > ) [ ] ; /**
     * If `true`, doc returned in `data` will not include the firestore `__snapshot` field.
     *
     * If `false`, it will include a `__snapshot` field. This lets you access the document snapshot, but makes the document not JSON serializable.
     *
     * Default: `true`
     */ ignoreFirestoreDocumentSnapshotField ? : boolean ; } & ConfigInterface < Doc | null > ; type ListenerReturnType < Doc extends Document = Document > = { initialData : Doc ; unsubscribe : ReturnType < ReturnType < typeof fuego [ 'db' ] [ 'doc' ] > [ 'onSnapshot' ] > ; } ; export const getDocument = async < Doc extends Document = Document > ( path , { parseDates , ignoreFirestoreDocumentSnapshotField = true , } = empty . object ) => { const data = await fuego . db . doc ( path ) . get ( ) . then ( doc => { const docData = doc . data ( { serverTimestamps : 'estimate' , } ) ?? empty . object ; if ( isDev && ( docData . exists || docData . id || docData . hasPendingWrites ) ) { console . warn ( '[get-document] warning: Your document, ' , doc . id , ' is using one of the following reserved fields: [exists, id, hasPendingWrites]. These fields are reserved. Please remove them from your documents.' ) ; } return withDocumentDatesParsed ( ( { ... docData , id : doc . id , exists : doc . exists , hasPendingWrites : doc . metadata . hasPendingWrites , __snapshot : ignoreFirestoreDocumentSnapshotField ? undefined : doc , } as unknown ) as Doc , parseDates ) ; } ) ; let collection = path . split ( `/${ data . id }` ) ; collection . pop ( ) ; collection = collection . join ( '/' ) ; if ( collection ) { collectionCache . getSWRKeysFromCollectionPath ( collection ) . forEach ( key => { mutate ( key , ( currentState = empty . array ) : Doc [ ] => { if ( ! currentState . some ( doc => doc . id === data . id ) ) { return currentState ; } return currentState . map ( document => { if ( document . id === data . id ) { return data ; } return document ; } ) ; } , false ) ; } ) ; } return data ; } ; const createListenerAsync = async < Doc extends Document = Document > ( path , { parseDates , ignoreFirestoreDocumentSnapshotField = true , } = { } ) : Promise < ListenerReturnType < Doc > > => { return await new Promise ( resolve => { const unsubscribe = fuego . db . doc ( path ) . onSnapshot ( doc => { const docData = doc . data ( ) ?? empty . object ; const data = withDocumentDatesParsed < Doc > ( ( { ... docData , id : doc . id , exists : doc . exists , hasPendingWrites : doc . metadata . hasPendingWrites , __snapshot : ignoreFirestoreDocumentSnapshotField ? undefined : doc , } as unknown ) as Doc , parseDates ) ; mutate ( path , data , false ) ; if ( isDev && ( docData . exists || docData . id || docData . hasPendingWrites ) ) { console . warn ( '[use-document] warning: Your document, ' , doc . id , ' is using one of the following reserved fields: [exists, id, hasPendingWrites]. These fields are reserved. Please remove them from your documents.' ) ; } let collection = path . split ( `/${ doc . id }` ) . filter ( Boolean ) ; collection . pop ( ) ; collection = collection . join ( '/' ) ; if ( collection ) { collectionCache . getSWRKeysFromCollectionPath ( collection ) . forEach ( key => { mutate ( key , ( currentState = empty . array ) : Doc [ ] => { if ( ! currentState . some ( doc => doc . id && doc . id === data . id ) ) { return currentState ; } return currentState . map ( document => { if ( document . id === data . id ) { return data ; } return document ; } ) ; } , false ) ; } ) ; } resolve ( { initialData : data , unsubscribe , } ) ; } ) ; } ) ; } ; export const useDocument = < Data extends object = { } , Doc extends Document = Document < Data > > ( path , options = empty . object ) => { const unsubscribeRef = useRef < ListenerReturnType [ 'unsubscribe' ] | null > ( null ) ; const { listen = false , parseDates , ignoreFirestoreDocumentSnapshotField = true , ... opts } = options ; const { refreshInterval = listen ? 0 : undefined , refreshWhenHidden = listen ? false : undefined , refreshWhenOffline = listen ? false : undefined , revalidateOnFocus = listen ? false : undefined , revalidateOnReconnect = listen ? false : undefined , dedupingInterval = listen ? 0 : undefined , } = options ; const swrOptions = { ... opts , refreshInterval , refreshWhenHidden , refreshWhenOffline , revalidateOnFocus , revalidateOnReconnect , dedupingInterval , } ; const shouldListen = useRef ( listen ) ; useEffect ( ( ) => { shouldListen . current = listen ; } , [ listen ] ) ; const datesToParse = useRef ( parseDates ) ; useEffect ( ( ) => { datesToParse . current = parseDates ; } , [ parseDates ] ) ; const shouldIgnoreSnapshot = useRef ( ignoreFirestoreDocumentSnapshotField ) ; useEffect ( ( ) => { shouldIgnoreSnapshot . current = ignoreFirestoreDocumentSnapshotField ; } , [ ignoreFirestoreDocumentSnapshotField ] ) ; const swr = useSWR < Doc | null > ( path , async ( path ) => { if ( shouldListen . current ) { if ( unsubscribeRef . current ) { unsubscribeRef . current ( ) ; unsubscribeRef . current = null ; } const { unsubscribe , initialData } = await createListenerAsync < Doc > ( path , { parseDates : datesToParse . current , ignoreFirestoreDocumentSnapshotField : shouldIgnoreSnapshot . current , } ) ; unsubscribeRef . current = unsubscribe ; return initialData ; } const data = await getDocument < Doc > ( path , { parseDates : datesToParse . current , ignoreFirestoreDocumentSnapshotField : shouldIgnoreSnapshot . current , } ) ; return data ; } , swrOptions ) ; const { data , isValidating , revalidate , mutate : connectedMutate , error } = swr ; const mounted = useRef ( false ) ; useEffect ( ( ) => { if ( mounted . current ) revalidateRef . current ( ) ; else mounted . current = true ; } , [ listen ] ) ; const revalidateRef = useRef ( swr . revalidate ) ; useEffect ( ( ) => { revalidateRef . current = swr . revalidate ; } ) ; useEffect ( ( ) => { return ( ) => { if ( unsubscribeRef . current ) { unsubscribeRef . current ( ) ; unsubscribeRef . current = null ; } } ; } , [ path , listen ] ) ; /**
     * `set(data, SetOptions?)`: Extends the `firestore` document `set` function.
     * - You can call this when you want to edit your document.
     * - It also updates the local cache using SWR's `mutate`. This will prove highly convenient over the regular Firestore `set` function.
     * - The second argument is the same as the second argument for [Firestore `set`](https://firebase.google.com/docs/firestore/manage-data/add-data#set_a_document).
     */ const set = useCallback ( ( data , options ? ) => { if ( ! listen ) { connectedMutate ( ( prevState = empty . object ) => { if ( ! options ?. merge ) return data ; return { ... prevState , ... data , } ; } ) ; } if ( ! path ) return null ; return fuego . db . doc ( path ) . set ( data , options ) ; } , [ path , listen , connectedMutate ] ) ; /**
     * - `update(data)`: Extends the Firestore document [`update` function](https://firebase.google.com/docs/firestore/manage-data/add-data#update-data).
     * - It also updates the local cache using SWR's `mutate`. This will prove highly convenient over the regular `set` function.
     */ const update = useCallback ( ( data ) => { if ( ! listen ) { connectedMutate ( ( prevState = empty . object ) => { return { ... prevState , ... data , } ; } ) ; } if ( ! path ) return null ; return fuego . db . doc ( path ) . update ( data ) ; } , [ listen , path , connectedMutate ] ) ; const connectedDelete = useCallback ( ( ) => { return deleteDocument ( path , listen ) ; } , [ path , listen ] ) ; return { data , isValidating , revalidate , mutate : connectedMutate , error , set , update , loading : ! data && ! error , deleteDocument : connectedDelete , /**
         * A function that, when called, unsubscribes the Firestore listener.
         *
         * The function can be null, so make sure to check that it exists before calling it.
         *
         * **Note**: This is not necessary to use. `useDocument` already unmounts the listener for you. This is only intended if you want to unsubscribe on your own.
         */ unsubscribe : unsubscribeRef . current } ; } ;