import useSWR , { mutate as mutateStatic , ConfigInterface } from 'swr' ; import { fuego } from '../context' ; import { useRef , useEffect , useMemo , useCallback } from 'react' ; import { empty } from '../helpers/empty' ; import { collectionCache } from '../classes/Cache' ; import { FieldPath , OrderByDirection , WhereFilterOp , Query , } from '@firebase/firestore-types' ; import { isDev } from '../helpers/is-dev' ; import { withDocumentDatesParsed } from '../helpers/doc-date-parser' ; import { Document } from '../types' ; type KeyHack = string & { } ; type OrderByArray < Doc extends object = { } , Key = keyof Doc > = [ Key | FieldPath | KeyHack , OrderByDirection ] ; type OrderByItem < Doc extends object = { } , Key = keyof Doc > = OrderByArray < Doc > | Key | KeyHack ; type OrderByType < Doc extends object = { } > = OrderByItem < Doc > | OrderByArray < Doc > [ ] ; type WhereItem < Doc extends object = { } , Key = keyof Doc > = [ Key | FieldPath | KeyHack , WhereFilterOp , unknown ] ; type WhereArray < Doc extends object = { } > = WhereItem < Doc > [ ] ; type WhereType < Doc extends object = { } > = WhereItem < Doc > | WhereArray < Doc > ; export type CollectionQueryType < Doc extends object = { } > = { limit ? : number ; orderBy ? : OrderByType < Doc > ; where ? : WhereType < Doc > ; isCollectionGroup ? : boolean ; /**
     * For now, this can only be a number, since it has to be JSON serializable.
     *
     * **TODO** allow DocumentSnapshot here too. This will probably be used with a useStaticCollection hook in the future.
     */ startAt ? : number ; /**
     * For now, this can only be a number, since it has to be JSON serializable.
     *
     * **TODO** allow DocumentSnapshot here too. This will probably be used with a useStaticCollection hook in the future.
     */ endAt ? : number ; /**
     * For now, this can only be a number, since it has to be JSON serializable.
     *
     * **TODO** allow DocumentSnapshot here too. This will probably be used with a useStaticCollection hook in the future.
     */ startAfter ? : number ; /**
     * For now, this can only be a number, since it has to be JSON serializable.
     *
     * **TODO** allow DocumentSnapshot here too. This will probably be used with a useStaticCollection hook in the future.
     */ endBefore ? : number ; } ; export const getCollection = async < Doc extends Document = Document > ( path , query = { } , { parseDates , ignoreFirestoreDocumentSnapshotField , } = empty . object ) => { const ref = createFirestoreRef ( path , query ) ; const data = await ref . get ( ) . then ( querySnapshot => { const array = [ ] ; querySnapshot . forEach ( doc => { const docData = doc . data ( { serverTimestamps : 'estimate' , } ) ?? empty . object ; const docToAdd = withDocumentDatesParsed ( { ... docData , id : doc . id , exists : doc . exists , hasPendingWrites : doc . metadata . hasPendingWrites , __snapshot : ignoreFirestoreDocumentSnapshotField ? undefined : doc , } as any , parseDates ) ; mutateStatic ( doc . ref . path , docToAdd , false ) ; if ( isDev && ( docData . exists || docData . id || docData . hasPendingWrites ) ) { console . warn ( '[get-collection] 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.' ) ; } array . push ( docToAdd ) ; } ) ; return array ; } ) ; return data ; } ; const createFirestoreRef = < Doc extends object = { } > ( path , { where , orderBy , limit , startAt , endAt , startAfter , endBefore , isCollectionGroup , } ) => { let ref = fuego . db . collection ( path ) ; if ( isCollectionGroup ) { ref = fuego . db . collectionGroup ( path ) ; } if ( where ) { function multipleConditions ( w ) { return ! ! ( w as WhereArray ) && Array . isArray ( w [ 0 ] ) ; } if ( multipleConditions ( where ) ) { where . forEach ( w => { ref = ref . where ( w [ 0 ] as string | FieldPath , w [ 1 ] , w [ 2 ] ) ; } ) ; } else if ( typeof where [ 0 ] === 'string' && typeof where [ 1 ] === 'string' ) { ref = ref . where ( where [ 0 ] , where [ 1 ] , where [ 2 ] ) ; } } if ( orderBy ) { if ( typeof orderBy === 'string' ) { ref = ref . orderBy ( orderBy ) ; } else if ( Array . isArray ( orderBy ) ) { function multipleOrderBy ( o ) { return Array . isArray ( ( o as OrderByArray < Doc > [ ] ) [ 0 ] ) ; } if ( multipleOrderBy ( orderBy ) ) { orderBy . forEach ( ( [ order , direction ] ) => { ref = ref . orderBy ( order as string | FieldPath , direction ) ; } ) ; } else { const [ order , direction ] = orderBy ; ref = ref . orderBy ( order as string | FieldPath , direction ) ; } } } if ( startAt ) { ref = ref . startAt ( startAt ) ; } if ( endAt ) { ref = ref . endAt ( endAt ) ; } if ( startAfter ) { ref = ref . startAfter ( startAfter ) ; } if ( endBefore ) { ref = ref . endBefore ( endBefore ) ; } if ( limit ) { ref = ref . limit ( limit ) ; } return ref ; } ; type ListenerReturnType < Doc extends Document = Document > = { initialData : Doc [ ] | null ; unsubscribe : ReturnType < ReturnType < typeof fuego [ 'db' ] [ 'doc' ] > [ 'onSnapshot' ] > ; } ; const createListenerAsync = async < Doc extends Document = Document > ( path , queryString , { parseDates , ignoreFirestoreDocumentSnapshotField = true , } ) : Promise < ListenerReturnType < Doc > > => { return new Promise ( resolve => { const query = JSON . parse ( queryString ) ?? { } ; const ref = createFirestoreRef ( path , query ) ; const unsubscribe = ref . onSnapshot ( { includeMetadataChanges : true } , querySnapshot => { const data = [ ] ; querySnapshot . forEach ( doc => { const docData = doc . data ( { serverTimestamps : 'estimate' , } ) ?? empty . object ; const docToAdd = withDocumentDatesParsed ( { ... docData , id : doc . id , exists : doc . exists , hasPendingWrites : doc . metadata . hasPendingWrites , __snapshot : ignoreFirestoreDocumentSnapshotField ? undefined : doc , } as any , parseDates ) ; if ( isDev && ( docData . exists || docData . id || docData . hasPendingWrites ) ) { console . warn ( '[use-collection] 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.' ) ; } mutateStatic ( doc . ref . path , docToAdd , false ) ; data . push ( docToAdd ) ; } ) ; resolve ( { initialData : data , unsubscribe , } ) ; mutateStatic ( [ path , queryString ] , data , false ) ; } ) ; } ) ; } ; export type CollectionSWROptions < Doc extends Document = Document > = ConfigInterface < Doc [ ] | null > ; template Doc param path param query param options export const useCollection = < Data extends object = { } , Doc extends Document = Document < Data > > ( path , query = empty . object , options = empty . object ) => { const unsubscribeRef = useRef < ListenerReturnType [ 'unsubscribe' ] | null > ( null ) ; const { where , endAt , endBefore , startAfter , startAt , orderBy , limit , listen = false , parseDates , isCollectionGroup , ignoreFirestoreDocumentSnapshotField = true , } = query ; 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 = { ... options , refreshInterval , refreshWhenHidden , refreshWhenOffline , revalidateOnFocus , revalidateOnReconnect , dedupingInterval , } ; const memoQueryString = useMemo ( ( ) => JSON . stringify ( { where , endAt , endBefore , startAfter , startAt , orderBy , limit , isCollectionGroup , } ) , [ endAt , endBefore , isCollectionGroup , limit , orderBy , startAfter , startAt , where , ] ) ; const dateParser = useRef ( parseDates ) ; useEffect ( ( ) => { dateParser . current = parseDates ; } , [ parseDates ] ) ; const shouldListen = useRef ( listen ) ; useEffect ( ( ) => { shouldListen . current = listen ; } ) ; const shouldIgnoreSnapshot = useRef ( ignoreFirestoreDocumentSnapshotField ) ; useEffect ( ( ) => { shouldIgnoreSnapshot . current = ignoreFirestoreDocumentSnapshotField ; } , [ ignoreFirestoreDocumentSnapshotField ] ) ; const swr = useSWR < Doc [ ] | null > ( path === null ? null : [ path , memoQueryString ] , async ( path , queryString ) => { if ( shouldListen . current ) { if ( unsubscribeRef . current ) { unsubscribeRef . current ( ) ; unsubscribeRef . current = null ; } const { unsubscribe , initialData } = await createListenerAsync < Doc > ( path , queryString , { parseDates : dateParser . current , ignoreFirestoreDocumentSnapshotField : shouldIgnoreSnapshot . current , } ) ; unsubscribeRef . current = unsubscribe ; return initialData ; } const data = await getCollection < Doc > ( path , JSON . parse ( queryString ) as CollectionQueryType < Doc > , { parseDates : dateParser . current , ignoreFirestoreDocumentSnapshotField : shouldIgnoreSnapshot . current , } ) ; return data ; } , swrOptions ) ; 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 , memoQueryString ] ) ; useEffect ( ( ) => { if ( path ) collectionCache . addCollectionToCache ( path , memoQueryString ) ; } , [ path , memoQueryString ] ) ; const { data , isValidating , revalidate , mutate , error } = swr ; /**
     * `add(data)`: Extends the Firestore document [`add` function](https://firebase.google.com/docs/firestore/manage-data/add-data).
     * - It also updates the local cache using SWR's `mutate`. This will prove highly convenient over the regular `add` function provided by Firestore.
     */ const add = useCallback ( < T extends Data | Data [ ] > ( data ) : Promise < T extends Data ? string : string [ ] > | null => { if ( ! path ) return null ; const multiple = Array . isArray ( data ) ; const dataArray = multiple ? ( data as T [ ] ) : [ data ] ; const ref = fuego . db . collection ( path ) ; const docsToAdd = ( dataArray . map ( doc => ( { ... doc , id : ref . doc ( ) . id , } ) ) as unknown ) as Doc [ ] ; if ( ! listen ) { mutate ( prevState => { const state = prevState ?? empty . array ; return [ ... state , ... docsToAdd ] ; } , false ) ; } const batch = fuego . db . batch ( ) ; docsToAdd . forEach ( ( { id , ... doc } ) => { batch . set ( ref . doc ( id ) , doc ) ; } ) ; return batch . commit ( ) . then ( ( ) => { const ids = docsToAdd . map ( ( { id } ) => id ) ; const returnValue = multiple ? ids : ids [ 0 ] ; return returnValue as T extends Data ? string : string [ ] ; } ) ; } , [ listen , mutate , path ] ) ; return { data , isValidating , revalidate , mutate , error , add , loading : ! data && ! error , /**
         * 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. `useCollection` already unmounts the listener for you. This is only intended if you want to unsubscribe on your own.
         */ unsubscribe : unsubscribeRef . current , } ; } ;