Skip to content

Commit 90bd7f5

Browse files
committed
fix(database): correctly set pending when hydrating and during racing conditions
1 parent 6f08cc0 commit 90bd7f5

File tree

3 files changed

+88
-21
lines changed

3 files changed

+88
-21
lines changed

src/database/useDatabaseRef.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,31 +63,38 @@ export function _useDatabaseRef(
6363
}
6464

6565
// set the initial value from SSR even if the ref comes from outside
66-
// TODO: firebase app name
6766
data.value = getInitialValue(
6867
initialSourceValue,
6968
options.ssrKey,
7069
data.value,
7170
useFirebaseApp()
7271
)
7372

73+
// if no initial value is found (ssr), we should set pending to true
74+
let shouldStartAsPending = data.value === undefined // no initial value
75+
7476
const error = ref<Error>()
75-
const pending = ref(true)
77+
const pending = ref(false)
7678
// force the type since its value is set right after and undefined isn't possible
7779
const promise = shallowRef() as ShallowRef<Promise<unknown | null>>
7880
const hasCurrentScope = getCurrentScope()
7981
let removePendingPromise = noop
8082

8183
function bindDatabaseRef() {
82-
let referenceValue = unref(reference)
84+
const referenceValue = unref(reference)
8385

84-
const p = new Promise<unknown | null>((resolve, reject) => {
86+
const newPromise = new Promise<unknown | null>((resolve, reject) => {
8587
if (!referenceValue) {
8688
unbind = noop
8789
// resolve to avoid an ever pending promise
8890
return resolve(null)
8991
}
9092

93+
pending.value = shouldStartAsPending
94+
// the very first time we bind, if we hydrated the value, we don't set loading to true
95+
// this way we ensure, all subsequent calls to bindDatabaseRef will set pending to true
96+
shouldStartAsPending = true
97+
9198
if (Array.isArray(data.value)) {
9299
unbind = bindAsArray(
93100
data as Ref<any>,
@@ -100,22 +107,28 @@ export function _useDatabaseRef(
100107
unbind = bindAsObject(data, referenceValue, resolve, reject, options)
101108
}
102109
})
103-
104-
promise.value = p
105-
106-
p.catch((reason) => {
107-
error.value = reason
108-
}).finally(() => {
109-
pending.value = false
110-
})
110+
.catch((reason) => {
111+
if (promise.value === newPromise) {
112+
error.value = reason
113+
}
114+
return Promise.reject(reason) // propagate the error
115+
})
116+
.finally(() => {
117+
// ensure the current promise is still valid
118+
if (promise.value === newPromise) {
119+
pending.value = false
120+
}
121+
})
122+
123+
// we set the promise here to ensure that pending is set right after if the user awaits the promise
124+
promise.value = newPromise
111125
}
112126

113127
let stopWatcher = noop
114128
if (isRef(reference)) {
115-
stopWatcher = watch(reference, bindDatabaseRef, { immediate: true })
116-
} else {
117-
bindDatabaseRef()
129+
stopWatcher = watch(reference, bindDatabaseRef)
118130
}
131+
bindDatabaseRef()
119132

120133
// only add the first promise to the pending ones
121134
if (initialSourceValue) {
@@ -137,6 +150,8 @@ export function _useDatabaseRef(
137150
unbind(reset)
138151
}
139152

153+
// TODO: warn if the data has already any property set (use a symbol to check in dev)
154+
140155
return Object.defineProperties(data as _RefDatabase<unknown>, {
141156
// allow destructuring without interfering with the ref itself
142157
data: { get: () => data },

src/ssr/initialState.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface SSRStore {
3131
}
3232

3333
// @internal
34-
const initialStatesMap = new WeakMap<FirebaseApp, SSRStore>()
34+
export const _initialStatesMap = new WeakMap<FirebaseApp, SSRStore>()
3535

3636
/**
3737
* Allows getting the initial state set during SSR on the client.
@@ -45,14 +45,14 @@ export function useSSRInitialState(
4545
firebaseApp: FirebaseApp
4646
): SSRStore {
4747
// get initial state based on the current firebase app
48-
if (!initialStatesMap.has(firebaseApp)) {
49-
initialStatesMap.set(
48+
if (!_initialStatesMap.has(firebaseApp)) {
49+
_initialStatesMap.set(
5050
firebaseApp,
5151
initialState || { f: {}, r: {}, s: {}, u: {} }
5252
)
5353
}
5454

55-
return initialStatesMap.get(firebaseApp)!
55+
return _initialStatesMap.get(firebaseApp)!
5656
}
5757

5858
export function getInitialValue(

tests/database/objects.spec.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import { mount } from '@vue/test-utils'
2-
import { describe, expect, it } from 'vitest'
2+
import { beforeEach, describe, expect, it } from 'vitest'
33
import {
44
UseDatabaseRefOptions,
55
useDatabaseObject,
66
VueDatabaseDocumentData,
77
_RefDatabase,
8+
useSSRInitialState,
89
} from '../../src'
9-
import { expectType, tds, setupDatabaseRefs, database } from '../utils'
10+
import {
11+
expectType,
12+
tds,
13+
setupDatabaseRefs,
14+
database,
15+
firebaseApp,
16+
} from '../utils'
1017
import { computed, nextTick, ref, shallowRef, unref, type Ref } from 'vue'
1118
import { DatabaseReference, get, ref as _databaseRef } from 'firebase/database'
1219
import { _MaybeRef, _Nullable } from '../../src/shared'
1320
import { mockWarn } from '../vitest-mock-warn'
21+
import { _initialStatesMap } from '../../src/ssr/initialState'
1422

1523
describe('Database objects', () => {
1624
const { databaseRef, set, update, remove } = setupDatabaseRefs()
@@ -46,6 +54,11 @@ describe('Database objects', () => {
4654
}
4755
}
4856

57+
beforeEach(() => {
58+
// delete any ssr state
59+
_initialStatesMap.delete(firebaseApp)
60+
})
61+
4962
it('binds an object', async () => {
5063
const { wrapper, itemRef } = factory()
5164

@@ -74,6 +87,45 @@ describe('Database objects', () => {
7487
expect(wrapper.vm.item).toBe(null)
7588
})
7689

90+
it('sets pending while loading', async () => {
91+
const itemRef = shallowRef(databaseRef('a'))
92+
const { pending, promise } = factory({ ref: itemRef })
93+
94+
expect(pending.value).toBe(true)
95+
await promise.value
96+
expect(pending.value).toBe(false)
97+
98+
// set the target to a new ref so it can be loaded again
99+
itemRef.value = databaseRef('b')
100+
101+
await nextTick() // for the watcher to trigger
102+
expect(pending.value).toBe(true)
103+
await promise.value
104+
expect(pending.value).toBe(false)
105+
})
106+
107+
it('sets pending to false if there is an initial value (ssr)', async () => {
108+
const itemRef = shallowRef(databaseRef())
109+
useSSRInitialState({ r: { a: 1 }, f: {}, s: {}, u: {} }, firebaseApp)
110+
const { pending, promise } = factory({
111+
ref: itemRef,
112+
options: { ssrKey: 'a' },
113+
})
114+
115+
expect(pending.value).toBe(false)
116+
await promise.value
117+
expect(pending.value).toBe(false)
118+
})
119+
120+
it('skips setting pending if the object is an empty ref', async () => {
121+
const itemRef = shallowRef()
122+
const { pending, promise } = factory({ ref: itemRef })
123+
124+
expect(pending.value).toBe(false)
125+
await promise.value
126+
expect(pending.value).toBe(false)
127+
})
128+
77129
it('retrieves an object with $value for primitives', async () => {
78130
const itemRef = databaseRef()
79131
await set(itemRef, 24)

0 commit comments

Comments
 (0)