Skip to content

Commit 4929283

Browse files
authored
Make sure Firestore hooks return undefined when data does not exist (#446)
More context in discussion #438
1 parent e4fd84e commit 4929283

8 files changed

+148
-96
lines changed

docs/reference/classes/SuspenseSubject.SuspenseSubject-1.md

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/reference/modules/useObservable.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
"babel-jest": "^26.6.3",
8383
"babel-plugin-minify-replace": "^0.5.0",
8484
"eslint-plugin-no-only-tests": "^2.6.0",
85-
"firebase": "^9.0.0",
85+
"firebase": "^9.0.1",
8686
"firebase-tools": "^9.16.0",
8787
"globalthis": "^1.0.1",
8888
"husky": "^4.3.0",

src/SuspenseSubject.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,16 @@ export class SuspenseSubject<T> extends Subject<T> {
4848
return this._hasValue || !!this._error;
4949
}
5050

51-
get value(): T | undefined {
51+
get value(): T {
5252
// TODO figure out how to reset the cache here, if I _reset() here before throwing
5353
// it doesn't seem to work.
5454
// As it is now, this will burn the cache entry until the timeout fires.
5555
if (this._error) {
5656
throw this._error;
57+
} else if (!this.hasValue) {
58+
throw Error('Can only get value if SuspenseSubject has a value');
5759
}
58-
return this._value;
60+
return this._value as T;
5961
}
6062

6163
get firstEmission(): Promise<void> {

src/useObservable.ts

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -63,55 +63,75 @@ export interface ObservableStatus<T> {
6363
firstValuePromise: Promise<void>;
6464
}
6565

66+
function reducerFactory<T>(observable: SuspenseSubject<T>) {
67+
return function reducer(state: ObservableStatus<T>, action: 'value' | 'error' | 'complete'): ObservableStatus<T> {
68+
// always make sure these values are in sync with the observable
69+
const newState = {
70+
...state,
71+
hasEmitted: state.hasEmitted || observable.hasValue,
72+
error: observable.ourError,
73+
firstValuePromise: observable.firstEmission,
74+
};
75+
if (observable.hasValue) {
76+
newState.data = observable.value;
77+
}
78+
79+
switch (action) {
80+
case 'value':
81+
newState.status = 'success';
82+
return newState;
83+
case 'error':
84+
newState.status = 'error';
85+
return newState;
86+
case 'complete':
87+
newState.isComplete = true;
88+
return newState;
89+
default:
90+
throw new Error(`invalid action "${action}"`);
91+
}
92+
};
93+
}
94+
6695
export function useObservable<T = unknown>(observableId: string, source: Observable<T>, config: ReactFireOptions = {}): ObservableStatus<T> {
96+
// Register the observable with the cache
6797
if (!observableId) {
6898
throw new Error('cannot call useObservable without an observableId');
6999
}
70100
const observable = preloadObservable(source, observableId);
71101

102+
// Suspend if suspense is enabled and no initial data exists
72103
const hasInitialData = config.hasOwnProperty('initialData') || config.hasOwnProperty('startWithValue');
73-
104+
const hasData = observable.hasValue || hasInitialData;
74105
const suspenseEnabled = useSuspenseEnabledFromConfigAndContext(config.suspense);
75-
76-
if (suspenseEnabled === true && !observable.hasValue && (!config?.initialData ?? !config?.startWithValue)) {
106+
if (suspenseEnabled === true && !hasData) {
77107
throw observable.firstEmission;
78108
}
79109

80-
const [latest, setValue] = React.useState(() => (observable.hasValue ? observable.value : config.initialData ?? config.startWithValue));
81-
const [isComplete, setIsComplete] = React.useState(false);
82-
const [hasError, setHasError] = React.useState(false);
110+
const initialState: ObservableStatus<T> = {
111+
status: hasData ? 'success' : 'loading',
112+
hasEmitted: hasData,
113+
isComplete: false,
114+
data: observable.hasValue ? observable.value : config?.initialData ?? config?.startWithValue,
115+
error: observable.ourError,
116+
firstValuePromise: observable.firstEmission,
117+
};
118+
const [status, dispatch] = React.useReducer<React.Reducer<ObservableStatus<T>, 'value' | 'error' | 'complete'>>(reducerFactory<T>(observable), initialState);
119+
83120
React.useEffect(() => {
84121
const subscription = observable.subscribe({
85-
next: (v) => {
86-
setValue(() => v);
122+
next: () => {
123+
dispatch('value');
87124
},
88125
error: (e) => {
89-
setHasError(true);
126+
dispatch('error');
90127
throw e;
91128
},
92129
complete: () => {
93-
setIsComplete(true);
130+
dispatch('complete');
94131
},
95132
});
96133
return () => subscription.unsubscribe();
97134
}, [observable]);
98135

99-
let status: ObservableStatus<T>['status'];
100-
101-
if (hasError) {
102-
status = 'error';
103-
} else if (observable.hasValue || hasInitialData) {
104-
status = 'success';
105-
} else {
106-
status = 'loading';
107-
}
108-
109-
return {
110-
status,
111-
hasEmitted: observable.hasValue || hasInitialData,
112-
isComplete: isComplete,
113-
data: latest,
114-
error: observable.ourError,
115-
firstValuePromise: observable.firstEmission,
116-
};
136+
return status;
117137
}

test/firestore.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,23 @@ describe('Firestore', () => {
8585
expect(data.a).toEqual(mockData.a);
8686
expect(data.id).toBeDefined();
8787
});
88+
89+
it('returns undefined if document does not exist', async () => {
90+
const collectionRef = collection(db, randomString());
91+
const docIdThatExists = randomString();
92+
const docIdThatDoesNotExist = randomString();
93+
await setDoc(doc(collectionRef, docIdThatExists), { a: randomString() });
94+
95+
// reference a doc that doesn't exist
96+
const ref = doc(collectionRef, docIdThatDoesNotExist);
97+
98+
const { result, waitFor } = renderHook(() => useFirestoreDocData<any>(ref, { idField: 'id' }), { wrapper: Provider });
99+
100+
await waitFor(() => result.current.status === 'success');
101+
102+
expect(result.current.status).toEqual('success');
103+
expect(result.current.data).toBeUndefined();
104+
});
88105
});
89106

90107
describe('useFirestoreDocOnce', () => {

test/useObservable.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ describe('useObservable', () => {
6363
expect(result.current.data).toEqual(startVal);
6464
});
6565

66+
it('emits even if data is undefined', async () => {
67+
const observable$: Subject<any> = new Subject();
68+
69+
const { result } = renderHook(() => useObservable('test-undefined', observable$, { suspense: false }));
70+
71+
expect(result.current.status).toEqual('loading');
72+
73+
actOnHook(() => observable$.next(undefined));
74+
75+
expect(result.current.status).toEqual('success');
76+
expect(result.current.data).toBeUndefined();
77+
});
78+
6679
it('does not show stale data after navigating away', async () => {
6780
const startVal = 'start';
6881
const newVal = 'anotherValue';

0 commit comments

Comments
 (0)