reactfire 4.2.3 → 4.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react';
2
+ import { useSyncExternalStore } from 'use-sync-external-store/shim';
2
3
  import { Observable } from 'rxjs';
3
4
  import { SuspenseSubject } from './SuspenseSubject';
4
5
  import { useSuspenseEnabledFromConfigAndContext } from './firebaseApp';
@@ -16,17 +17,17 @@ if (!(globalThis as any as ReactFireGlobals)._reactFirePreloadedObservables) {
16
17
  // Starts listening to an Observable.
17
18
  // Call this once you know you're going to render a
18
19
  // child that will consume the observable
19
- export function preloadObservable<T>(source: Observable<T>, id: string) {
20
+ export function preloadObservable<T>(source: Observable<T>, id: string, suspenseEnabled = false) {
20
21
  if (preloadedObservables.has(id)) {
21
22
  return preloadedObservables.get(id) as SuspenseSubject<T>;
22
23
  } else {
23
- const observable = new SuspenseSubject(source, DEFAULT_TIMEOUT);
24
+ const observable = new SuspenseSubject(source, DEFAULT_TIMEOUT, suspenseEnabled);
24
25
  preloadedObservables.set(id, observable);
25
26
  return observable;
26
27
  }
27
28
  }
28
29
 
29
- export interface ObservableStatus<T> {
30
+ interface ObservableStatusBase<T> {
30
31
  /**
31
32
  * The loading status.
32
33
  *
@@ -52,7 +53,7 @@ export interface ObservableStatus<T> {
52
53
  *
53
54
  * 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`.
54
55
  */
55
- data: T;
56
+ data: T | undefined;
56
57
  /**
57
58
  * Any error that may have occurred in the underlying observable
58
59
  */
@@ -63,75 +64,92 @@ export interface ObservableStatus<T> {
63
64
  firstValuePromise: Promise<void>;
64
65
  }
65
66
 
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
- }
67
+ export interface ObservableStatusSuccess<T> extends ObservableStatusBase<T> {
68
+ status: 'success';
69
+ data: T;
70
+ }
78
71
 
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
- };
72
+ export interface ObservableStatusError<T> extends ObservableStatusBase<T> {
73
+ status: 'error';
74
+ isComplete: true;
75
+ error: Error;
76
+ }
77
+
78
+ export interface ObservableStatusLoading<T> extends ObservableStatusBase<T> {
79
+ status: 'loading';
80
+ data: undefined;
81
+ hasEmitted: false;
93
82
  }
94
83
 
84
+ export type ObservableStatus<T> = ObservableStatusLoading<T> | ObservableStatusError<T> | ObservableStatusSuccess<T>;
85
+
95
86
  export function useObservable<T = unknown>(observableId: string, source: Observable<T>, config: ReactFireOptions = {}): ObservableStatus<T> {
96
- // Register the observable with the cache
97
87
  if (!observableId) {
98
88
  throw new Error('cannot call useObservable without an observableId');
99
89
  }
100
- const observable = preloadObservable(source, observableId);
90
+
91
+ const suspenseEnabled = useSuspenseEnabledFromConfigAndContext(config.suspense);
92
+
93
+ // Register the observable with the cache
94
+ const observable = preloadObservable(source, observableId, suspenseEnabled);
101
95
 
102
96
  // Suspend if suspense is enabled and no initial data exists
103
97
  const hasInitialData = config.hasOwnProperty('initialData') || config.hasOwnProperty('startWithValue');
104
98
  const hasData = observable.hasValue || hasInitialData;
105
- const suspenseEnabled = useSuspenseEnabledFromConfigAndContext(config.suspense);
106
99
  if (suspenseEnabled === true && !hasData) {
107
100
  throw observable.firstEmission;
108
101
  }
109
102
 
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
-
120
- React.useEffect(() => {
121
- const subscription = observable.subscribe({
122
- next: () => {
123
- dispatch('value');
124
- },
125
- error: (e) => {
126
- dispatch('error');
127
- throw e;
128
- },
129
- complete: () => {
130
- dispatch('complete');
131
- },
132
- });
133
- return () => subscription.unsubscribe();
103
+ const subscribe = React.useCallback((onStoreChange: () => void) => {
104
+ const subscription = observable.subscribe({
105
+ next: () => {
106
+ onStoreChange();
107
+ },
108
+ error: (e) => {
109
+ onStoreChange();
110
+ throw e;
111
+ },
112
+ complete: () => {
113
+ onStoreChange();
114
+ },
115
+ });
116
+
117
+ return () => {
118
+ subscription.unsubscribe();
119
+ }
120
+ }, [observable])
121
+
122
+ const getSnapshot = React.useCallback<() => ObservableStatus<T>>(() => {
123
+ return observable.immutableStatus;
134
124
  }, [observable]);
135
125
 
136
- return status;
126
+ const update = useSyncExternalStore(subscribe, getSnapshot);
127
+
128
+ // Return a new object with initialData overlaid rather than mutating the shared
129
+ // _immutableStatus reference, which is the same object across all components
130
+ // using the same observableId.
131
+ if (!observable.hasValue && hasData) {
132
+ const initialDataValue = config?.initialData ?? config?.startWithValue;
133
+
134
+ // In suspense mode, throw errors so React Error Boundaries can catch them.
135
+ // In non-suspense mode, surface errors via status so consumers can handle them locally.
136
+ if (suspenseEnabled && update.error) {
137
+ throw update.error;
138
+ }
139
+
140
+ return {
141
+ ...update,
142
+ data: initialDataValue,
143
+ status: 'success',
144
+ hasEmitted: true,
145
+ } as ObservableStatus<T>;
146
+ }
147
+
148
+ // throw an error if there is an error
149
+ // TODO(jhuleatt) this is the current, tested-for, behavior. But do we actually want it?
150
+ if (update.error) {
151
+ throw update.error;
152
+ }
153
+
154
+ return update;
137
155
  }