Skip to content

Commit

Permalink
feat(ssr): database and firestore
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Nov 15, 2022
1 parent 57cdd82 commit eca3031
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 153 deletions.
31 changes: 20 additions & 11 deletions src/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
unref,
watch,
isRef,
getCurrentInstance,
onServerPrefetch,
} from 'vue-demi'
import { DatabaseReference, getDatabase, Query } from 'firebase/database'
import {
Expand All @@ -34,6 +36,7 @@ import {
} from './utils'
import { addPendingPromise } from '../ssr/plugin'
import { useFirebaseApp } from '../app'
import { getInitialValue } from '../ssr/initialState'

export { databasePlugin } from './optionsApi'

Expand All @@ -45,23 +48,24 @@ const ops: OperationsType = {
remove: (array, index) => array.splice(index, 1),
}

export interface UseDatabaseRefOptions extends _DatabaseRefOptions {
target?: Ref<unknown>
}
export interface UseDatabaseRefOptions extends _DatabaseRefOptions {}

export function _useDatabaseRef(
reference: _MaybeRef<_Nullable<DatabaseReference | Query>>,
localOptions: UseDatabaseRefOptions = {}
) {
const options = Object.assign({}, rtdbOptions, localOptions)
let _unbind!: UnbindWithReset
const options = Object.assign({}, rtdbOptions, localOptions)
const initialSourceValue = unref(reference)

const data = options.target || ref<unknown | null>()
// set the initial value from SSR even if the ref comes from outside
data.value = getInitialValue(initialSourceValue, options.ssrKey, data.value)

const data = options.target || ref<unknown | null>(options.initialValue)
const error = ref<Error>()
const pending = ref(true)
// force the type since its value is set right after and undefined isn't possible
const promise = shallowRef() as ShallowRef<Promise<unknown | null>>
let isPromiseAdded = false
const hasCurrentScope = getCurrentScope()
let removePendingPromise = noop

Expand Down Expand Up @@ -100,11 +104,6 @@ export function _useDatabaseRef(
}
})

// only add the first promise to the pending ones
if (!isPromiseAdded && referenceValue) {
removePendingPromise = addPendingPromise(p, referenceValue)
isPromiseAdded = true
}
promise.value = p

p.catch((reason) => {
Expand All @@ -127,8 +126,18 @@ export function _useDatabaseRef(
bindDatabaseRef()
}

// only add the first promise to the pending ones
if (initialSourceValue) {
removePendingPromise = addPendingPromise(promise.value, initialSourceValue)
}

if (hasCurrentScope) {
onScopeDispose(unbind)

// wait for the promise on SSR
if (getCurrentInstance()) {
onServerPrefetch(() => promise.value)
}
}

// TODO: rename to stop
Expand Down
2 changes: 0 additions & 2 deletions src/database/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ import {
// TODO: rename to match where it's used
export interface _DatabaseRefOptions extends _DataSourceOptions {
serialize?: DatabaseSnapshotSerializer

initialValue?: unknown
}

export interface _GlobalDatabaseRefOptions extends _DatabaseRefOptions {
Expand Down
20 changes: 2 additions & 18 deletions src/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,7 @@ export const ops: OperationsType = {
remove: (array, index) => array.splice(index, 1),
}

export interface _UseFirestoreRefOptions extends FirestoreRefOptions {
/**
* Use the `target` ref instead of creating one.
*/
target?: Ref<unknown>

/**
* Optional key to handle SSR hydration. **Necessary for Queries** or when the same source is used in multiple places
* with different converters.
*/
ssrKey?: string
}
export interface _UseFirestoreRefOptions extends FirestoreRefOptions {}

/**
* Internal version of `useDocument()` and `useCollection()`.
Expand All @@ -80,12 +69,7 @@ export function _useFirestoreRef(

const data = options.target || ref<unknown | null>()
// set the initial value from SSR even if the ref comes from outside
data.value = getInitialValue(
'f',
options.ssrKey,
initialSourceValue,
data.value
)
data.value = getInitialValue(initialSourceValue, options.ssrKey, data.value)
// TODO: allow passing pending and error refs as option for when this is called using the options api
const pending = ref(true)
const error = ref<FirestoreError>()
Expand Down
2 changes: 0 additions & 2 deletions src/firestore/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ export interface FirestoreRefOptions extends _DataSourceOptions {
*/
maxRefDepth?: number

initialValue?: unknown

snapshotOptions?: SnapshotOptions

/**
Expand Down
13 changes: 12 additions & 1 deletion src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function isFirestoreDataReference<T = unknown>(

export function isFirestoreQuery(
source: unknown
): source is FirestoreQuery<unknown> {
): source is FirestoreQuery<unknown> & { path: undefined } { // makes some types so much easier
return isObject(source) && source.type === 'query'
}

Expand Down Expand Up @@ -197,6 +197,17 @@ export type _MaybeRef<T> = T | Ref<T>
* @internal
*/
export interface _DataSourceOptions {
/**
* Use the `target` ref instead of creating one.
*/
target?: Ref<unknown>

/**
* Optional key to handle SSR hydration. **Necessary for Queries** or when the same source is used in multiple places
* with different converters.
*/
ssrKey?: string

/**
* If true, the data will be reset when the data source is unbound. Pass a function to specify a custom reset value.
*/
Expand Down
67 changes: 46 additions & 21 deletions src/ssr/initialState.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { FirebaseApp } from 'firebase/app'
import { DatabaseReference, Query as DatabaseQuery } from 'firebase/database'
import {
CollectionReference,
DocumentReference,
Query as FirestoreQuery,
} from 'firebase/firestore'
import { InjectionKey } from 'vue'
import { useFirebaseApp } from '../app'
import { isFirestoreQuery, _Nullable } from '../shared'
import {
isDatabaseReference,
isFirestoreDataReference,
isFirestoreQuery,
_Nullable,
} from '../shared'

export interface SSRStore {
// firestore data
Expand Down Expand Up @@ -52,37 +58,56 @@ type FirestoreDataSource =
| FirestoreQuery<unknown>

export function getInitialValue(
type: 'f' | 'r',
ssrKey?: string | undefined,
dataSource?: _Nullable<FirestoreDataSource>,
fallbackValue?: unknown
dataSource: _Nullable<
FirestoreDataSource | DatabaseReference | DatabaseQuery
>,
ssrKey: string | undefined,
fallbackValue: unknown
) {
const initialState: Record<string, unknown> = useSSRInitialState()[type] || {}
const key = ssrKey || getFirestoreSourceKey(dataSource)
if (!dataSource) return fallbackValue

const [sourceType, path] = getDataSourceInfo(dataSource)
if (!sourceType) return fallbackValue

const initialState: Record<string, unknown> =
useSSRInitialState()[sourceType] || {}
const key = ssrKey || path

// TODO: warn for queries on the client if there are other keys and this is during hydration

// returns undefined if no key, otherwise initial state or undefined
// undefined should be treated as no initial state
// returns the fallback value if no key, otherwise initial state
return key && key in initialState ? initialState[key] : fallbackValue
}

export function setInitialValue(
type: 'f' | 'r',
value: unknown,
ssrKey?: string | undefined,
dataSource?: _Nullable<FirestoreDataSource>
export function deferInitialValueSetup(
dataSource: _Nullable<
FirestoreDataSource | DatabaseReference | DatabaseQuery
>,
ssrKey: string | undefined | null,
promise: Promise<unknown>
) {
const initialState: Record<string, unknown> = useSSRInitialState()[type]
const key = ssrKey || getFirestoreSourceKey(dataSource)
if (!dataSource) return

const [sourceType, path] = getDataSourceInfo(dataSource)
if (!sourceType) return

const initialState: Record<string, unknown> = useSSRInitialState()[sourceType]
const key = ssrKey || path

if (key) {
initialState[key] = value
promise.then((value) => {
initialState[key] = value
})
return key
}
}

function getFirestoreSourceKey(
source: _Nullable<FirestoreDataSource>
): string | undefined {
return !source || isFirestoreQuery(source) ? undefined : source.path
function getDataSourceInfo(
dataSource: FirestoreDataSource | DatabaseReference | DatabaseQuery
) {
return isFirestoreDataReference(dataSource) || isFirestoreQuery(dataSource)
? (['f', dataSource.path] as const)
: isDatabaseReference(dataSource)
? (['r', dataSource.toString()] as const)
: []
}
12 changes: 3 additions & 9 deletions src/ssr/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from 'firebase/firestore'
import { useFirebaseApp, _FirebaseAppInjectionKey } from '../app'
import { getDataSourcePath, noop } from '../shared'
import { setInitialValue } from './initialState'
import { deferInitialValueSetup } from './initialState'

export const appPendingPromises = new WeakMap<
FirebaseApp,
Expand All @@ -20,7 +20,6 @@ export function clearPendingPromises(app: FirebaseApp) {

export function addPendingPromise(
promise: Promise<unknown>,
// TODO: should this just be ssrKey? and let functions infer the path?
dataSource:
| DocumentReference<unknown>
| FirestoreQuery<unknown>
Expand All @@ -34,15 +33,10 @@ export function addPendingPromise(
}
const pendingPromises = appPendingPromises.get(app)!

const key = ssrKey || getDataSourcePath(dataSource)
// TODO: skip this outside of SSR
const key = deferInitialValueSetup(dataSource, ssrKey, promise)
if (key) {
pendingPromises.set(key, promise)

// TODO: skip this outside of SSR
promise.then((value) => {
// TODO: figure out 'f', probably based on the type of dataSource
setInitialValue('f', value, key! /* dataSource */)
})
} else {
// TODO: warn if in SSR context in other contexts than vite
if (process.env.NODE_ENV !== 'production' /* && import.meta.env?.SSR */) {
Expand Down
65 changes: 65 additions & 0 deletions tests/database/ssr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, it, expect } from 'vitest'
import { setupFirestoreRefs, firebaseApp, setupDatabaseRefs } from '../utils'
import { ShallowUnwrapRef } from 'vue'
import { _InferReferenceType, _RefFirestore } from '../../src/firestore'
import { useDocument, useObject } from '../../src'
import { _MaybeRef, _Nullable } from '../../src/shared'
import { createSSRApp } from 'vue'
import { renderToString, ssrInterpolate } from '@vue/server-renderer'
import { clearPendingPromises } from '../../src/ssr/plugin'

describe('Database SSR', async () => {
const { databaseRef, set } = setupDatabaseRefs()

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 }
}

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

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

it('works without await', async () => {
const docRef = databaseRef()
await set(docRef, { name: 'hello' })
const { app } = createMyApp(
() => {
const data = useObject<{ name: string }>(docRef)
return { data }
},
({ data }) => data!.name
)

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

0 comments on commit eca3031

Please sign in to comment.