Skip to content

Commit 1b0e226

Browse files
committed
refactor(ssr): better ssr handling
1 parent f8875ef commit 1b0e226

File tree

5 files changed

+125
-28
lines changed

5 files changed

+125
-28
lines changed

src/firestore/index.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getFirestore,
99
} from 'firebase/firestore'
1010
import {
11+
getCurrentInstance,
1112
getCurrentScope,
1213
isRef,
1314
onScopeDispose,
@@ -31,6 +32,7 @@ import {
3132
_Nullable,
3233
_RefWithState,
3334
} from '../shared'
35+
import { getInitialValue } from '../ssr/initialState'
3436
import { addPendingPromise } from '../ssr/plugin'
3537
import { firestoreUnbinds } from './optionsApi'
3638
import {
@@ -51,6 +53,12 @@ export interface _UseFirestoreRefOptions extends FirestoreRefOptions {
5153
* Use the `target` ref instead of creating one.
5254
*/
5355
target?: Ref<unknown>
56+
57+
/**
58+
* Optional key to handle SSR hydration. **Necessary for Queries** or when the same source is used in multiple places
59+
* with different converters.
60+
*/
61+
ssrKey?: string
5462
}
5563

5664
/**
@@ -68,14 +76,16 @@ export function _useFirestoreRef(
6876
) {
6977
let _unbind: UnbindWithReset = noop
7078
const options = Object.assign({}, firestoreOptions, localOptions)
79+
const initialSourceValue = unref(docOrCollectionRef)
7180

81+
const data = options.target || ref<unknown | null>()
82+
// set the initial value from SSR even if the ref comes from outside
83+
data.value = getInitialValue('f', options.ssrKey, initialSourceValue)
7284
// TODO: allow passing pending and error refs as option for when this is called using the options api
73-
const data = options.target || ref<unknown | null>(options.initialValue)
7485
const pending = ref(true)
7586
const error = ref<FirestoreError>()
7687
// force the type since its value is set right after and undefined isn't possible
7788
const promise = shallowRef() as ShallowRef<Promise<unknown | null>>
78-
let isPromiseAdded = false
7989
const hasCurrentScope = getCurrentScope()
8090
let removePendingPromise = noop
8191

@@ -112,12 +122,6 @@ export function _useFirestoreRef(
112122
)
113123
})
114124

115-
// only add the first promise to the pending ones
116-
if (!isPromiseAdded && docRefValue) {
117-
// TODO: is there a way to make this only for the first render?
118-
removePendingPromise = addPendingPromise(p, docRefValue)
119-
isPromiseAdded = true
120-
}
121125
promise.value = p
122126

123127
p.catch((reason: FirestoreError) => {
@@ -136,6 +140,12 @@ export function _useFirestoreRef(
136140
bindFirestoreRef()
137141
}
138142

143+
// only add the first promise to the pending ones
144+
// TODO: can we make this tree shakeable?
145+
if (initialSourceValue) {
146+
removePendingPromise = addPendingPromise(promise.value, initialSourceValue)
147+
}
148+
139149
// TODO: SSR serialize the values for Nuxt to expose them later and use them
140150
// as initial values while specifying a wait: true to only swap objects once
141151
// Firebase has done its initial sync. Also, on server, you don't need to
@@ -145,9 +155,11 @@ export function _useFirestoreRef(
145155
// TODO: warn else
146156
if (hasCurrentScope) {
147157
onScopeDispose(unbind)
148-
// wait for the promise during SSR
149-
// TODO: configurable
150-
onServerPrefetch(() => promise.value)
158+
if (getCurrentInstance()) {
159+
// wait for the promise during SSR
160+
// TODO: configurable
161+
onServerPrefetch(() => promise.value)
162+
}
151163
}
152164

153165
// TODO: rename to stop

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export { useCurrentUser, VueFireAuth, useFirebaseAuth } from './auth'
5151

5252
// SSR
5353
export { usePendingPromises } from './ssr/plugin'
54+
export { useSSRInitialState } from './ssr/initialState'
5455

5556
// App Check
5657
export { VueFireAppCheck, useAppCheckToken } from './app-check'

src/shared.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type { Ref, ShallowRef } from 'vue-demi'
1111

1212
export const noop = () => {}
1313

14+
export const isClient = typeof window !== 'undefined'
15+
1416
// FIXME: replace any with unknown or T generics
1517

1618
export interface OperationsType {
@@ -124,21 +126,9 @@ export function isFirestoreDataReference<T = unknown>(
124126
return isDocumentRef(source) || isCollectionRef(source)
125127
}
126128

127-
// The Firestore SDK has an undocumented _query
128-
// object that has a method to generate a hash for a query,
129-
// which we need for useObservable
130-
// https://github.com/firebase/firebase-js-sdk/blob/5beb23cd47312ffc415d3ce2ae309cc3a3fde39f/packages/firestore/src/core/query.ts#L221
131-
// @internal
132-
export interface _FirestoreQueryWithId<T = DocumentData>
133-
extends FirestoreQuery<T> {
134-
_query: {
135-
canonicalId(): string
136-
}
137-
}
138-
139129
export function isFirestoreQuery(
140130
source: unknown
141-
): source is _FirestoreQueryWithId<unknown> {
131+
): source is FirestoreQuery<unknown> {
142132
return isObject(source) && source.type === 'query'
143133
}
144134

src/ssr/initialState.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { FirebaseApp } from 'firebase/app'
2+
import {
3+
CollectionReference,
4+
DocumentReference,
5+
Query as FirestoreQuery,
6+
} from 'firebase/firestore'
7+
import { InjectionKey } from 'vue'
8+
import { useFirebaseApp } from '../app'
9+
import { isFirestoreQuery, _Nullable } from '../shared'
10+
11+
export interface SSRStore {
12+
// firestore data
13+
f: Record<string, unknown>
14+
// rtdb data
15+
r: Record<string, unknown>
16+
17+
// storage
18+
s: Record<string, unknown>
19+
20+
// auth data
21+
u: Record<string, unknown>
22+
}
23+
24+
// @internal
25+
const initialStatesMap = new WeakMap<FirebaseApp, SSRStore>()
26+
27+
/**
28+
* Allows getting the initial state set during SSR on the client.
29+
*
30+
* @param initialState - the initial state to set for the firebase app during SSR. Pass undefined to not set it
31+
* @param firebaseApp - the firebase app to get the initial state for
32+
* @returns the initial states for the current firebaseApp
33+
*/
34+
export function useSSRInitialState(
35+
initialState?: SSRStore,
36+
firebaseApp: FirebaseApp = useFirebaseApp()
37+
): SSRStore {
38+
// get initial state based on the current firebase app
39+
if (!initialStatesMap.has(firebaseApp)) {
40+
initialStatesMap.set(
41+
firebaseApp,
42+
initialState || { f: {}, r: {}, s: {}, u: {} }
43+
)
44+
}
45+
46+
return initialStatesMap.get(firebaseApp)!
47+
}
48+
49+
type FirestoreDataSource =
50+
| DocumentReference<unknown>
51+
| CollectionReference<unknown>
52+
| FirestoreQuery<unknown>
53+
54+
export function getInitialValue(
55+
type: 'f' | 'r',
56+
ssrKey?: string | undefined,
57+
dataSource?: _Nullable<FirestoreDataSource>
58+
) {
59+
const initialState: Record<string, unknown> = useSSRInitialState()[type] || {}
60+
const key = ssrKey || getFirestoreSourceKey(dataSource)
61+
62+
// TODO: warn for queries on the client if there are other keys and this is during hydration
63+
64+
// returns undefined if no key, otherwise initial state or undefined
65+
// undefined should be treated as no initial state
66+
return key && initialState[key]
67+
}
68+
69+
export function setInitialValue(
70+
type: 'f' | 'r',
71+
value: unknown,
72+
ssrKey?: string | undefined,
73+
dataSource?: _Nullable<FirestoreDataSource>
74+
) {
75+
const initialState: Record<string, unknown> = useSSRInitialState()[type]
76+
const key = ssrKey || getFirestoreSourceKey(dataSource)
77+
78+
if (key) {
79+
initialState[key] = value
80+
}
81+
}
82+
83+
function getFirestoreSourceKey(
84+
source: _Nullable<FirestoreDataSource>
85+
): string | undefined {
86+
return !source || isFirestoreQuery(source) ? undefined : source.path
87+
}

src/ssr/plugin.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from 'firebase/firestore'
88
import { useFirebaseApp, _FirebaseAppInjectionKey } from '../app'
99
import { getDataSourcePath, noop } from '../shared'
10+
import { setInitialValue } from './initialState'
1011

1112
export const appPendingPromises = new WeakMap<
1213
FirebaseApp,
@@ -33,17 +34,23 @@ export function addPendingPromise(
3334
}
3435
const pendingPromises = appPendingPromises.get(app)!
3536

36-
ssrKey = getDataSourcePath(dataSource)
37-
if (ssrKey) {
38-
pendingPromises.set(ssrKey, promise)
37+
const key = ssrKey || getDataSourcePath(dataSource)
38+
if (key) {
39+
pendingPromises.set(key, promise)
40+
41+
// TODO: skip this outside of SSR
42+
promise.then((value) => {
43+
// TODO: figure out 'f', probably based on the type of dataSource
44+
setInitialValue('f', value, key! /* dataSource */)
45+
})
3946
} else {
4047
// TODO: warn if in SSR context in other contexts than vite
4148
if (process.env.NODE_ENV !== 'production' /* && import.meta.env?.SSR */) {
4249
console.warn('[VueFire]: Could not get the path of the data source')
4350
}
4451
}
4552

46-
return ssrKey ? () => pendingPromises.delete(ssrKey!) : noop
53+
return key ? () => pendingPromises.delete(key!) : noop
4754
}
4855

4956
/**

0 commit comments

Comments
 (0)