Skip to content

Commit

Permalink
feat(firestore): accept a vue ref as parameter in useCollection() and…
Browse files Browse the repository at this point in the history
… useDocument()
  • Loading branch information
posva committed Oct 25, 2022
1 parent 961d548 commit ee180a7
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 44 deletions.
90 changes: 63 additions & 27 deletions src/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@ import type {
FirestoreError,
DocumentData,
} from 'firebase/firestore'
import { getCurrentScope, onScopeDispose, ref, Ref } from 'vue-demi'
import {
getCurrentScope,
isRef,
onScopeDispose,
ref,
Ref,
ShallowRef,
shallowRef,
unref,
watch,
} from 'vue-demi'
import {
isDocumentRef,
OperationsType,
walkSet,
_MaybeRef,
_RefWithState,
} from '../shared'
import { firestoreUnbinds } from './optionsApi'
Expand All @@ -33,40 +44,56 @@ export interface _UseFirestoreRefOptions extends FirestoreOptions {
* @internal
*/
export function _useFirestoreRef(
docOrCollectionRef:
| DocumentReference<unknown>
| Query<unknown>
| CollectionReference<unknown>,
docOrCollectionRef: _MaybeRef<
DocumentReference<unknown> | Query<unknown> | CollectionReference<unknown>
>,
options: _UseFirestoreRefOptions = {}
) {
let unbind!: UnbindType
let _unbind!: UnbindType

// TODO: allow passing pending and error refs as option for when this is called using the options api
const data = options.target || ref<unknown | null>(options.initialValue)
const pending = ref(true)
const error = ref<FirestoreError>()
// force the type since its value is set right after and undefined isn't possible
const promise = shallowRef() as ShallowRef<Promise<unknown | null>>
const createdPromises = new Set<Promise<unknown | null>>()
const hasCurrentScope = getCurrentScope()

const promise = new Promise<unknown | null>((resolve, reject) => {
unbind = (
isDocumentRef(docOrCollectionRef) ? bindDocument : bindCollection
)(
data,
// @ts-expect-error: the type is good because of the ternary
docOrCollectionRef,
ops,
resolve,
reject,
options
)
})
function bindFirestoreRef() {
const p = new Promise<unknown | null>((resolve, reject) => {
const docRefValue = unref(docOrCollectionRef)
_unbind = (isDocumentRef(docRefValue) ? bindDocument : bindCollection)(
data,
// @ts-expect-error: the type is good because of the ternary
docRefValue,
ops,
resolve,
reject,
options
)
})

promise
.catch((reason: FirestoreError) => {
// only add the first promise to the pending ones
if (!createdPromises.size) {
pendingPromises.add(p)
}
createdPromises.add(p)
promise.value = p

p.catch((reason: FirestoreError) => {
error.value = reason
})
.finally(() => {
}).finally(() => {
pending.value = false
})
}

let unwatch: ReturnType<typeof watch> | undefined
if (isRef(docOrCollectionRef)) {
unwatch = watch(docOrCollectionRef, bindFirestoreRef, { immediate: true })
} else {
bindFirestoreRef()
}

// TODO: SSR serialize the values for Nuxt to expose them later and use them
// as initial values while specifying a wait: true to only swap objects once
Expand All @@ -75,14 +102,23 @@ export function _useFirestoreRef(
// should take an option like once: true to not setting up any listener

// TODO: warn else
if (getCurrentScope()) {
pendingPromises.add(promise)
if (hasCurrentScope) {
onScopeDispose(() => {
pendingPromises.delete(promise)
unbind(options.reset)
for (const p of createdPromises) {
pendingPromises.delete(p)
}
_unbind(options.reset)
})
}

// TODO: rename to stop
function unbind() {
if (unwatch) {
unwatch()
}
_unbind(options.reset)
}

// allow to destructure the returned value
Object.defineProperties(data, {
error: {
Expand Down
2 changes: 1 addition & 1 deletion src/firestore/optionsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export const firestorePlugin = function firestorePlugin(
unbinds[key] = unbind
// @ts-expect-error: we are allowed to write it
this.$firestoreRefs[key] = docOrCollectionRef
return promise
return promise.value
}

app.mixin({
Expand Down
10 changes: 5 additions & 5 deletions src/firestore/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {
firestoreDefaultConverter,
FirestoreSerializer,
} from './utils'
import { walkGet, callOnceWithArg, OperationsType } from '../shared'
import { ref, Ref, unref } from 'vue-demi'
import { walkGet, callOnceWithArg, OperationsType, _MaybeRef } from '../shared'
import { isRef, ref, Ref, unref, watch } from 'vue-demi'
import type {
CollectionReference,
DocumentChange,
Expand Down Expand Up @@ -386,11 +386,11 @@ export function bindDocument<T>(
// TODO: warning check if key exists?
// const boundRefs = Object.create(null)

const subs = Object.create(null)
const subs: Record<string, FirestoreSubscription> = Object.create(null)
// bind here the function so it can be resolved anywhere
// this is specially useful for refs
resolve = callOnceWithArg(resolve, () => walkGet(target, key))
const unbind = onSnapshot(
const _unbind = onSnapshot(
document,
(snapshot) => {
if (snapshot.exists()) {
Expand All @@ -413,7 +413,7 @@ export function bindDocument<T>(
)

return (reset?: FirestoreOptions['reset']) => {
unbind()
_unbind()
if (reset !== false) {
const value = typeof reset === 'function' ? reset() : null
ops.set(target, key, value)
Expand Down
18 changes: 13 additions & 5 deletions src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
DocumentSnapshot,
QuerySnapshot,
} from 'firebase/firestore'
import type { Ref } from 'vue-demi'
import type { Ref, ShallowRef } from 'vue-demi'

// FIXME: replace any with unknown or T generics

Expand Down Expand Up @@ -83,15 +83,19 @@ export function isTimestamp(o: any): o is Date {
* Checks if a variable is a Firestore Document Reference
* @param o
*/
export function isDocumentRef(o: any): o is DocumentReference {
export function isDocumentRef<T = DocumentData>(
o: any
): o is DocumentReference<T> {
return isObject(o) && o.type === 'document'
}

/**
* Checks if a variable is a Firestore Collection Reference
* @param o
*/
export function isCollectionRef(o: any): o is CollectionReference {
export function isCollectionRef<T = DocumentData>(
o: any
): o is CollectionReference<T> {
return isObject(o) && o.type === 'collection'
}

Expand Down Expand Up @@ -122,8 +126,12 @@ export interface _RefWithState<T, E = Error> extends Ref<T> {
get error(): Ref<E | undefined>
get pending(): Ref<boolean>

// TODO: is it really void?
get promise(): Promise<void>
get promise(): ShallowRef<Promise<void>>
// TODO: extract type from bindDocument and bindCollection
unbind: () => void
}

/**
* @internal
*/
export type _MaybeRef<T> = T | Ref<T>
83 changes: 77 additions & 6 deletions tests/firestore/collection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import {
doc,
DocumentData,
Query,
where,
} from 'firebase/firestore'
import { expectType, setupFirestoreRefs, tds, firestore } from '../utils'
import { ref, type Ref } from 'vue'
import { computed, nextTick, ref, unref, type Ref } from 'vue'
import { _InferReferenceType, _RefFirestore } from '../../src/firestore'
import {
useCollection,
UseCollectionOptions,
VueFirestoreQueryData,
} from '../../src'
import { _MaybeRef } from '../../src/shared'

describe('Firestore collections', () => {
const { collection, query, addDoc, setDoc, updateDoc, deleteDoc } =
Expand All @@ -25,23 +27,61 @@ describe('Firestore collections', () => {
ref = collection(),
}: {
options?: UseCollectionOptions
ref?: CollectionReference<T>
ref?: _MaybeRef<CollectionReference<T>>
} = {}) {
let data!: _RefFirestore<VueFirestoreQueryData<T>>

const wrapper = mount({
template: 'no',
setup() {
// @ts-expect-error: generic forced
data = useCollection(ref, options)
data = useCollection(
// @ts-expect-error: generic forced
ref,
options
)
const { data: list, pending, error, promise, unbind } = data
return { list, pending, error, promise, unbind }
},
})

return {
wrapper,
listRef: unref(ref),
// non enumerable properties cannot be spread
data: data.data,
pending: data.pending,
error: data.error,
promise: data.promise,
unbind: data.unbind,
}
}

function factoryQuery<T = DocumentData>({
options,
ref,
}: {
options?: UseCollectionOptions
ref?: _MaybeRef<CollectionReference<T> | Query<T>>
} = {}) {
let data!: _RefFirestore<VueFirestoreQueryData<T>>

const wrapper = mount({
template: 'no',
setup() {
// @ts-expect-error: generic forced
data = useCollection(
// @ts-expect-error: generic forced
ref,
options
)
const { data: list, pending, error, promise, unbind } = data
return { list, pending, error, promise, unbind }
},
})

return {
wrapper,
listRef: ref,
// non enumerable properties cannot be spread
data: data.data,
pending: data.pending,
Expand Down Expand Up @@ -167,7 +207,7 @@ describe('Firestore collections', () => {
})

expect(error.value).toBeUndefined()
await expect(promise).rejects.toThrow()
await expect(unref(promise)).rejects.toThrow()
expect(error.value).toBeTruthy()
})

Expand All @@ -177,7 +217,7 @@ describe('Firestore collections', () => {
await addDoc(ref, { name: 'b' })
const { error, promise, data } = factory({ ref })

await expect(promise).resolves.toEqual(expect.anything())
await expect(unref(promise)).resolves.toEqual(expect.anything())
expect(data.value).toContainEqual({ name: 'a' })
expect(data.value).toContainEqual({ name: 'b' })
expect(error.value).toBeUndefined()
Expand Down Expand Up @@ -228,6 +268,37 @@ describe('Firestore collections', () => {
expect(data.value).toEqual([{ name: 'a' }])
})

it('can be bound to a ref of a query', async () => {
const listRef = collection<{ text: string; finished: boolean }>()
const finishedListRef = query(listRef, where('finished', '==', true))
const unfinishedListRef = query(listRef, where('finished', '==', false))
const showFinished = ref(false)
const listToDisplay = computed(() =>
showFinished.value ? finishedListRef : unfinishedListRef
)
await addDoc(listRef, { text: 'task 1', finished: false })
await addDoc(listRef, { text: 'task 2', finished: false })
await addDoc(listRef, { text: 'task 3', finished: true })
await addDoc(listRef, { text: 'task 4', finished: false })

const { wrapper, data, promise } = factoryQuery({
ref: listToDisplay,
})

await promise.value
expect(data.value).toHaveLength(3)
expect(data.value).toContainEqual({ text: 'task 1', finished: false })
expect(data.value).toContainEqual({ text: 'task 2', finished: false })
expect(data.value).toContainEqual({ text: 'task 4', finished: false })

showFinished.value = true
await nextTick()
await promise.value
await nextTick()
expect(data.value).toHaveLength(1)
expect(data.value).toContainEqual({ text: 'task 3', finished: true })
})

tds(() => {
interface TodoI {
text: string
Expand Down

0 comments on commit ee180a7

Please sign in to comment.