reactfire
Version:
Firebase library for React
137 lines (118 loc) • 4.76 kB
text/typescript
import * as React from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { Observable } from 'rxjs';
import { SuspenseSubject } from './SuspenseSubject';
import { useSuspenseEnabledFromConfigAndContext } from './firebaseApp';
import { ReactFireGlobals, ReactFireOptions } from './';
const DEFAULT_TIMEOUT = 30_000;
// Since we're side-effect free, we need to ensure our observable cache is global
const preloadedObservables: Map<string, SuspenseSubject<any>> = (globalThis as any as ReactFireGlobals)._reactFirePreloadedObservables || new Map();
if (!(globalThis as any as ReactFireGlobals)._reactFirePreloadedObservables) {
(globalThis as any as ReactFireGlobals)._reactFirePreloadedObservables = preloadedObservables;
}
// Starts listening to an Observable.
// Call this once you know you're going to render a
// child that will consume the observable
export function preloadObservable<T>(source: Observable<T>, id: string, suspenseEnabled = false) {
if (preloadedObservables.has(id)) {
return preloadedObservables.get(id) as SuspenseSubject<T>;
} else {
const observable = new SuspenseSubject(source, DEFAULT_TIMEOUT, suspenseEnabled);
preloadedObservables.set(id, observable);
return observable;
}
}
export interface ObservableStatus<T> {
/**
* The loading status.
*
* - `loading`: Waiting for the first value from an observable
* - `error`: Something went wrong. Check `ObservableStatus.error` for more details
* - `success`: The hook has emitted at least one value
*
* If `initialData` is passed in, this will skip `loading` and go straight to `success`.
*/
status: 'loading' | 'error' | 'success';
/**
* Indicates whether the hook has emitted a value at some point
*
* If `initialData` is passed in, this will be `true`.
*/
hasEmitted: boolean; // has received at least one value
/**
* If this is `true`, the hook will be emitting no further items.
*/
isComplete: boolean;
/**
* The most recent value.
*
* If `initialData` is passed in, the first value of `data` will be the valuea provided in `initialData` **UNLESS** the underlying observable is ready, in which case it will skip `initialData`.
*/
data: T;
/**
* Any error that may have occurred in the underlying observable
*/
error: Error | undefined;
/**
* Promise that resolves after first emit from observable
*/
firstValuePromise: Promise<void>;
}
export function useObservable<T = unknown>(observableId: string, source: Observable<T>, config: ReactFireOptions = {}): ObservableStatus<T> {
if (!observableId) {
throw new Error('cannot call useObservable without an observableId');
}
const suspenseEnabled = useSuspenseEnabledFromConfigAndContext(config.suspense);
// Register the observable with the cache
const observable = preloadObservable(source, observableId, suspenseEnabled);
// Suspend if suspense is enabled and no initial data exists
const hasInitialData = config.hasOwnProperty('initialData') || config.hasOwnProperty('startWithValue');
const hasData = observable.hasValue || hasInitialData;
if (suspenseEnabled === true && !hasData) {
throw observable.firstEmission;
}
const subscribe = React.useCallback((onStoreChange: () => void) => {
const subscription = observable.subscribe({
next: () => {
onStoreChange();
},
error: (e) => {
onStoreChange();
throw e;
},
complete: () => {
onStoreChange();
},
});
return () => {
subscription.unsubscribe();
}
}, [observable])
const getSnapshot = React.useCallback<() => ObservableStatus<T>>(() => {
return observable.immutableStatus;
}, [observable]);
const update = useSyncExternalStore(subscribe, getSnapshot);
// Return a new object with initialData overlaid rather than mutating the shared
// _immutableStatus reference, which is the same object across all components
// using the same observableId.
if (!observable.hasValue && hasData) {
const initialDataValue = config?.initialData ?? config?.startWithValue;
// In suspense mode, throw errors so React Error Boundaries can catch them.
// In non-suspense mode, surface errors via status so consumers can handle them locally.
if (suspenseEnabled && update.error) {
throw update.error;
}
return {
...update,
data: initialDataValue,
status: 'success',
hasEmitted: true,
} as ObservableStatus<T>;
}
// throw an error if there is an error
// TODO(jhuleatt) this is the current, tested-for, behavior. But do we actually want it?
if (update.error) {
throw update.error;
}
return update;
}