Skip to content

Commit

Permalink
feat: wait on server for data
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Nov 10, 2022
1 parent 751a635 commit 947a325
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 35 deletions.
3 changes: 1 addition & 2 deletions playground/src/stores/counter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { ref, computed } from 'vue'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { doc, setDoc, updateDoc } from 'firebase/firestore'
import { useFirestore } from '@/firebase'
import { useDocument } from 'vuefire'
import { useDocument, useFirestore } from 'vuefire'

export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
Expand Down
4 changes: 2 additions & 2 deletions src/app/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FirebaseApp, getApp } from 'firebase/app'
import { getCurrentScope, inject, InjectionKey } from 'vue'
import { getCurrentInstance, getCurrentScope, inject, InjectionKey } from 'vue'

// @internal
export const _FirebaseAppInjectionKey: InjectionKey<FirebaseApp> =
Expand All @@ -14,7 +14,7 @@ export const _FirebaseAppInjectionKey: InjectionKey<FirebaseApp> =
export function useFirebaseApp(name?: string): FirebaseApp {
// TODO: warn no current scope
return (
(getCurrentScope() &&
(getCurrentInstance() &&
inject(
_FirebaseAppInjectionKey,
// avoid the inject not found warning
Expand Down
5 changes: 5 additions & 0 deletions src/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getCurrentScope,
isRef,
onScopeDispose,
onServerPrefetch,
ref,
Ref,
ShallowRef,
Expand Down Expand Up @@ -99,6 +100,7 @@ export function _useFirestoreRef(
)
}

// FIXME: force once on server
_unbind = (isDocumentRef(docRefValue) ? bindDocument : bindCollection)(
// @ts-expect-error: cannot type with the ternary
data,
Expand Down Expand Up @@ -143,6 +145,9 @@ export function _useFirestoreRef(
// TODO: warn else
if (hasCurrentScope) {
onScopeDispose(unbind)
// wait for the promise during SSR
// TODO: configurable
onServerPrefetch(() => promise.value)
}

// TODO: rename to stop
Expand Down
36 changes: 36 additions & 0 deletions src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,42 @@ export function isFirestoreDataReference<T = unknown>(
return isDocumentRef(source) || isCollectionRef(source)
}

// The Firestore SDK has an undocumented _query
// object that has a method to generate a hash for a query,
// which we need for useObservable
// https://github.com/firebase/firebase-js-sdk/blob/5beb23cd47312ffc415d3ce2ae309cc3a3fde39f/packages/firestore/src/core/query.ts#L221
// @internal
export interface _FirestoreQueryWithId<T = DocumentData>
extends FirestoreQuery<T> {
_query: {
canonicalId(): string
}
}

export function isFirestoreQuery(
source: unknown
): source is _FirestoreQueryWithId<unknown> {
return isObject(source) && source.type === 'query'
}

export function getDataSourcePath(
source:
| DocumentReference<unknown>
| FirestoreQuery<unknown>
| CollectionReference<unknown>
| DatabaseQuery
): string | null {
return isFirestoreDataReference(source)
? source.path
: isDatabaseReference(source)
? // gets a path like /users/1?orderByKey=true
source.toString()
: isFirestoreQuery(source)
? // internal id
null // FIXME: find a way to get the canonicalId that no longer exists
: null
}

export function isDatabaseReference(
source: any
): source is DatabaseReference | DatabaseQuery {
Expand Down
48 changes: 20 additions & 28 deletions src/ssr/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import {
DocumentReference,
Query as FirestoreQuery,
} from 'firebase/firestore'
import type { App } from 'vue'
import { useFirebaseApp, _FirebaseAppInjectionKey } from '../app'
import { isDatabaseReference, isFirestoreDataReference, noop } from '../shared'
import { getDataSourcePath, noop } from '../shared'

export function VueFireSSR(app: App, firebaseApp: FirebaseApp) {
app.provide(_FirebaseAppInjectionKey, firebaseApp)
}

const appPendingPromises = new WeakMap<
export const appPendingPromises = new WeakMap<
FirebaseApp,
Map<string, Promise<unknown>>
>()

export function clearPendingPromises(app: FirebaseApp) {
appPendingPromises.delete(app)
}

export function addPendingPromise(
promise: Promise<unknown>,
// TODO: should this just be ssrKey? and let functions infer the path?
Expand All @@ -38,43 +37,36 @@ export function addPendingPromise(
if (ssrKey) {
pendingPromises.set(ssrKey, promise)
} else {
// TODO: warn if in SSR context
// throw new Error('Could not get the path of the data source')
// TODO: warn if in SSR context in other contexts than vite
if (process.env.NODE_ENV !== 'production' /* && import.meta.env?.SSR */) {
console.warn('[VueFire]: Could not get the path of the data source')
}
}

return ssrKey ? () => pendingPromises.delete(ssrKey!) : noop
}

function getDataSourcePath(
source:
| DocumentReference<unknown>
| FirestoreQuery<unknown>
| CollectionReference<unknown>
| DatabaseQuery
): string | null {
return isFirestoreDataReference(source)
? source.path
: isDatabaseReference(source)
? source.toString()
: null
}

/**
* Allows awaiting for all pending data sources. Useful to wait for SSR
*
* @param name - optional name of teh firebase app
* @returns - a Promise that resolves with an array of all the resolved pending promises
*/
export function usePendingPromises(name?: string) {
const app = useFirebaseApp(name)
export function usePendingPromises(app?: FirebaseApp) {
app = app || useFirebaseApp()
const pendingPromises = appPendingPromises.get(app)
return pendingPromises
const p = pendingPromises
? Promise.all(
Array.from(pendingPromises).map(([key, promise]) =>
promise.then((data) => [key, data] as const)
)
)
: Promise.resolve([])

// consume the promises
appPendingPromises.delete(app)

return p
}

export function getInitialData(
Expand All @@ -84,13 +76,13 @@ export function getInitialData(
const pendingPromises = appPendingPromises.get(app)

if (!pendingPromises) {
if (__DEV__) {
if (process.env.NODE_ENV !== 'production') {
console.warn('[VueFire]: No initial data found.')
}
return Promise.resolve({})
}

return usePendingPromises(app.name).then((keyData) =>
return usePendingPromises(app).then((keyData) =>
keyData.reduce((initialData, [key, data]) => {
initialData[key] = data
return initialData
Expand Down
2 changes: 1 addition & 1 deletion tests/firestore/collection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ describe(
showFinished.value = null

const { data, promise } = factory({
// @ts-expect-error
// @ts-expect-error: this one is a query
ref: listToDisplay,
})
await promise.value
Expand Down
2 changes: 0 additions & 2 deletions tests/firestore/refs-in-documents.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, it, expect, afterEach } from 'vitest'
import {
CollectionReference,
doc as originalDoc,
DocumentData,
DocumentReference,
Expand All @@ -11,7 +10,6 @@ import { unref } from 'vue'
import { _InferReferenceType, _RefFirestore } from '../../src/firestore'
import {
UseDocumentOptions,
usePendingPromises,
VueFirestoreQueryData,
useDocument,
} from '../../src'
Expand Down
147 changes: 147 additions & 0 deletions tests/firestore/ssr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* @vitest-environment node
*/
import { mount } from '@vue/test-utils'
import { beforeEach, describe, it, expect, afterEach } from 'vitest'
import {
CollectionReference,
doc as originalDoc,
DocumentData,
DocumentReference,
} from 'firebase/firestore'
import { setupFirestoreRefs, sleep, firebaseApp } from '../utils'
import { onServerPrefetch, ShallowUnwrapRef, unref } from 'vue'
import { _InferReferenceType, _RefFirestore } from '../../src/firestore'
import {
UseDocumentOptions,
UseCollectionOptions,
usePendingPromises,
VueFirestoreQueryData,
useDocument,
useCollection,
} from '../../src'
import { _MaybeRef, _Nullable } from '../../src/shared'
import { Component, createSSRApp, inject, ref, computed, customRef } from 'vue'
import { renderToString, ssrInterpolate } from '@vue/server-renderer'
import { clearPendingPromises, getInitialData } from '../../src/ssr/plugin'

describe('Firestore refs in documents', async () => {
const { collection, query, addDoc, setDoc, updateDoc, deleteDoc, doc } =
setupFirestoreRefs()

beforeEach(() => {
clearPendingPromises(firebaseApp)
})

function createMyApp<T>(
setup: () => T,
render: (ctx: ShallowUnwrapRef<Awaited<T>>) => unknown
) {
const App = {
ssrRender(ctx: any, push: any, _parent: any) {
push(`<p>${ssrInterpolate(render(ctx))}</p>`)
},
setup,
}

const app = createSSRApp(App)

return { app }
}

function factoryCollection<T = DocumentData>({
options,
ref = collection(),
}: {
options?: UseCollectionOptions
ref?: _MaybeRef<_Nullable<CollectionReference<T>>>
} = {}) {
let data!: _RefFirestore<VueFirestoreQueryData<T>>

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

return {
wrapper,
// to simplify types
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 factoryDoc<T = DocumentData>({
options,
ref,
}: {
options?: UseDocumentOptions
ref?: _MaybeRef<DocumentReference<T>>
} = {}) {
let data!: _RefFirestore<VueFirestoreQueryData<T>>

const wrapper = mount({
template: 'no',
setup() {
// @ts-expect-error: generic forced
data =
// split for ts
useDocument(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,
}
}

it('can await within setup', async () => {
const docRef = doc<{ name: string }>()
await setDoc(docRef, { name: 'a' })
const { app } = createMyApp(
async () => {
const { data, promise } = useDocument(docRef)
await promise.value
return { data }
},
({ data }) => data.name
)

expect(await renderToString(app)).toBe(`<p>a</p>`)
})

it('can await outside of setup', async () => {
const docRef = doc<{ name: string }>()
await setDoc(docRef, { name: 'hello' })
const { app } = createMyApp(
() => {
const data = useDocument(docRef)
return { data }
},
({ data }) => data.name
)

expect(await renderToString(app)).toBe(`<p>hello</p>`)
})
})

0 comments on commit 947a325

Please sign in to comment.