-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathuseObservableState.ts
More file actions
321 lines (298 loc) · 10.7 KB
/
Copy pathuseObservableState.ts
File metadata and controls
321 lines (298 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
import {
type DependencyList,
useCallback,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { BehaviorSubject, type Subscription } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import type { Observable, StatefulObservable } from '../types/index.js';
/**
* Options for {@link useObservableState}.
*
* @template TType - The initial value type (may be `undefined`).
*/
type ObservableStateOptions<TType = undefined> = {
/**
* Value exposed until the observable emits its first value.
*
* The initial value is read when the internal state stream is created and is
* not treated as a subscription dependency. Passing a new object literal on
* later renders does not recreate the subscription.
*/
initial?: TType;
/**
* Teardown function added to the active observable subscription.
*
* The latest callback is used when a subscription is created, but changing the
* callback alone does not recreate the subscription.
*/
teardown?: VoidFunction;
/**
* React-style dependency list that controls when the observable subscription
* is recreated.
*
* When omitted, the hook follows the `subject` reference. Passing `[]` keeps
* the first subject for the component lifetime. Passing custom dependencies
* lets callers recreate non-memoized subjects during render while only
* resubscribing when the selected dependency values change.
*/
deps?: DependencyList;
};
/**
* Return type of {@link useObservableState}.
*
* @template TType - The value type.
* @template TError - The error type.
*/
export type ObservableStateReturnType<TType, TError = unknown> = {
/** The most recently emitted value. */
value: TType;
/** The most recent error, or `null`. */
error: TError | null;
/** Whether the observable has completed. */
complete: boolean;
};
/**
* Internal snapshot model used by `useSyncExternalStore`.
*
* @template TType - Observable value type.
* @template TError - Observable error type.
*/
type ObservableStateSnapshot<TType, TError> = ObservableStateReturnType<TType | undefined, TError>;
/**
* Type guard for observables exposing a synchronous `value` property.
*
* @template TType - Observable value type.
* @param subject - Observable candidate.
* @returns `true` when the observable exposes a `value` field.
*/
function hasStatefulValue<TType>(
subject: Observable<TType> | StatefulObservable<TType>,
): subject is StatefulObservable<TType> {
return 'value' in (subject as object);
}
/**
* Resolves the initial value for an observable snapshot.
*
* @template TType - Observable value type.
* @param subject - Observable source.
* @param initial - Optional initial value supplied by the caller.
* @returns A resolved initial value, or `undefined`.
*/
function resolveInitialValue<TType>(
subject: Observable<TType> | StatefulObservable<TType>,
initial: TType | undefined,
): TType | undefined {
return (
/** caller-provided initial takes precedence */
initial ??
/** fall back to subject's synchronous current value if available */
(hasStatefulValue(subject) ? subject.value : undefined)
);
}
/**
* Compares React-style dependency lists with `Object.is` semantics.
*
* @param previous - Previous dependency list, or `null` before the first subscription.
* @param next - Next dependency list to compare.
* @returns `true` when the dependency lists are equivalent.
*/
function areDependenciesEqual(previous: DependencyList | null, next: DependencyList): boolean {
// Compare lengths first, then each entry with Object.is to avoid unnecessary resubscription
return (
previous !== null &&
previous.length === next.length &&
previous.every((value, index) => Object.is(value, next[index]))
);
}
/**
* Compares observable state snapshots by the fields exposed from the hook.
*
* @template TType - Observable value type.
* @template TError - Observable error type.
* @param previous - Previous observable state snapshot.
* @param next - Next observable state snapshot.
* @returns `true` when the hook state is unchanged.
*/
function areObservableStateSnapshotsEqual<TType, TError>(
previous: ObservableStateSnapshot<TType, TError>,
next: ObservableStateSnapshot<TType, TError>,
): boolean {
return (
Object.is(previous.value, next.value) &&
Object.is(previous.error, next.error) &&
previous.complete === next.complete
);
}
/**
* Subscribes to an {@link Observable} and returns its state.
*
* The initial `value` is `undefined` until the first emission.
*
* @param subject - Observable to subscribe to. Must have a stable reference.
*/
export function useObservableState<S, TError = unknown>(
subject: Observable<S>,
): ObservableStateReturnType<S | undefined, TError>;
/**
* Subscribes to an {@link Observable} and returns its state.
*
* @param subject - Observable to subscribe to. Must have a stable reference.
* @param opt - Options including an optional `initial` value shown before the first emission.
*/
export function useObservableState<
TType,
TError = unknown,
TInitial extends TType | undefined = undefined,
>(
subject: Observable<TType>,
opt: ObservableStateOptions<TInitial>,
): ObservableStateReturnType<TType | TInitial, TError>;
/**
* Subscribes to a {@link StatefulObservable} (e.g. `BehaviorSubject`, `FlowSubject`)
* and returns its state.
*
* The current `value` is read synchronously from the subject on mount,
* so the initial state is never `undefined`.
*
* @param subject - Stateful observable to subscribe to. Must have a stable reference.
*/
export function useObservableState<TType, TError = unknown>(
subject: StatefulObservable<TType>,
): ObservableStateReturnType<TType, TError>;
/**
* Subscribes to a {@link StatefulObservable} (e.g. `BehaviorSubject`, `FlowSubject`)
* and returns its state.
*
* @param subject - Stateful observable to subscribe to. Must have a stable reference.
* @param opt - Options including an optional `initial` override and `teardown` callback.
*/
export function useObservableState<TType, TError = unknown>(
subject: StatefulObservable<TType>,
options: ObservableStateOptions<TType>,
): ObservableStateReturnType<TType, TError>;
/**
* Subscribes to an observable and returns its latest emitted value as React
* state. The component re-renders on every emission, error, or completion.
*
* Internally wraps `useSyncExternalStore` to guarantee tear-down safety and
* concurrent-mode compatibility. The subscription is created once per unique
* `subject` reference and cleaned up automatically when the component unmounts
* or the subject changes.
*
* By default, the subscription follows the `subject` reference, matching the
* original hook contract: callers should pass a stable observable or expect a
* resubscription when the observable reference changes. When an observable is
* intentionally recreated during render, pass `opt.deps` to describe the real
* lifecycle of the subscription. For example, `deps: []` subscribes to the
* first subject for the lifetime of the component, while `deps: [id]`
* resubscribes only when `id` changes.
*
* `opt.initial` and `opt.teardown` do **not** need to be memoized and do not
* trigger a subscription recreation on their own.
*
* @param subject - The observable to subscribe to. Must have a stable reference.
* @param opt - Optional initial value and teardown callback.
* @returns An object with `value`, `error`, and `complete` reflecting the latest observable state.
* @template S - The value type emitted by the observable.
* @template E - The error type the observable may emit.
*
* @example
* ```tsx
* // Stable subject — created outside the component or wrapped in useMemo.
* const subject = useMemo(() => new BehaviorSubject(0), []);
*
* function Counter() {
* const { value, error, complete } = useObservableState(subject, { initial: 0 });
*
* if (error) return <p>Error: {String(error)}</p>;
* if (complete) return <p>Done: {value}</p>;
* return <p>Count: {value}</p>;
* }
* ```
*
* @example
* ```tsx
* // Cannot memoize the observable instance? Describe the real subscription lifetime.
* function Widget({ stream, widgetId }: { stream: Observable<number>; widgetId: string }) {
* const { value } = useObservableState(stream, { deps: [widgetId] });
* return <p>{value}</p>;
* }
* ```
*
* @example
* ```tsx
* // Subscribe to the first observable for the component lifetime.
* function StaticWidget({ stream }: { stream: Observable<number> }) {
* const { value } = useObservableState(stream, { deps: [] });
* return <p>{value}</p>;
* }
* ```
*/
export function useObservableState<S, E = unknown>(
subject: Observable<S> | StatefulObservable<S>,
opt?: ObservableStateOptions<S>,
): ObservableStateReturnType<S | undefined, E> {
const { initial, teardown, deps } = opt ?? {};
const subscribedRef = useRef<Subscription | null>(null);
const depsRef = useRef<DependencyList | null>(null);
const teardownRef = useRef(teardown);
teardownRef.current = teardown;
const [stream] = useState(
() =>
new BehaviorSubject({
value: resolveInitialValue(subject, initial),
error: null,
complete: false,
} as ObservableStateSnapshot<S, E>),
);
useLayoutEffect(() => {
const subscriptionDeps = deps ?? [subject];
// Skip resubscribing when the dependency list hasn't actually changed
if (areDependenciesEqual(depsRef.current, subscriptionDeps)) {
return;
}
depsRef.current = subscriptionDeps;
// Tear down any prior subscription before creating a new one
if (subscribedRef.current) {
subscribedRef.current.unsubscribe();
}
subscribedRef.current = subject.subscribe({
next: (value) => {
stream.next({ value, error: null, complete: false });
},
error: (error) => {
stream.next({ value: stream.value?.value, error, complete: false });
},
complete: () => {
stream.next({
value: stream.value?.value,
error: stream.value?.error ?? null,
complete: true,
});
},
});
subscribedRef.current.add(teardownRef.current);
});
useLayoutEffect(() => {
return () => {
subscribedRef.current?.unsubscribe();
};
}, []);
const onStoreChange = useCallback(
(cb: VoidFunction) => {
// Only notify React when the exposed snapshot fields actually change
const subscription: Subscription = stream
.pipe(distinctUntilChanged(areObservableStateSnapshotsEqual))
.subscribe(cb);
return () => subscription.unsubscribe();
},
[stream],
);
const getSnapshot = useCallback(() => stream.value as ObservableStateSnapshot<S, E>, [stream]);
return useSyncExternalStore(onStoreChange, getSnapshot);
}
export default useObservableState;