forked from Expensify/react-native-onyx
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuseOnyx.ts
More file actions
413 lines (336 loc) · 19.3 KB
/
useOnyx.ts
File metadata and controls
413 lines (336 loc) · 19.3 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
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
import {deepEqual, shallowEqual} from 'fast-equals';
import {useCallback, useEffect, useMemo, useRef, useSyncExternalStore} from 'react';
import type {DependencyList} from 'react';
import OnyxCache, {TASK} from './OnyxCache';
import type {Connection} from './OnyxConnectionManager';
import connectionManager from './OnyxConnectionManager';
import OnyxUtils from './OnyxUtils';
import * as GlobalSettings from './GlobalSettings';
import type {CollectionKeyBase, OnyxKey, OnyxValue} from './types';
import usePrevious from './usePrevious';
import decorateWithMetrics from './metrics';
import * as Logger from './Logger';
import onyxSnapshotCache from './OnyxSnapshotCache';
import useLiveRef from './useLiveRef';
type UseOnyxSelector<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>> = (data: OnyxValue<TKey> | undefined) => TReturnValue;
type UseOnyxOptions<TKey extends OnyxKey, TReturnValue> = {
/**
* Determines if this key in this subscription is safe to be evicted.
*/
canEvict?: boolean;
/**
* If set to `false`, then no data will be prefilled into the component.
*/
initWithStoredValues?: boolean;
/**
* If set to `true`, data will be retrieved from cache during the first render even if there is a pending merge for the key.
*/
allowStaleData?: boolean;
/**
* If set to `false`, the connection won't be reused between other subscribers that are listening to the same Onyx key
* with the same connect configurations.
*/
reuseConnection?: boolean;
/**
* If set to `true`, the key can be changed dynamically during the component lifecycle.
*/
allowDynamicKey?: boolean;
/**
* If the component calling this is the one loading the data by calling an action, then you should set this to `true`.
*
* If the component calling this does not load the data then you should set it to `false`, which means that if the data
* is not there, it will log an alert, as it means we are using data that no one loaded and that's most probably a bug.
*/
canBeMissing?: boolean;
/**
* This will be used to subscribe to a subset of an Onyx key's data.
* Using this setting on `useOnyx` can have very positive performance benefits because the component will only re-render
* when the subset of data changes. Otherwise, any change of data on any property would normally
* cause the component to re-render (and that can be expensive from a performance standpoint).
* @see `useOnyx` cannot return `null` and so selector will replace `null` with `undefined` to maintain compatibility.
*/
selector?: UseOnyxSelector<TKey, TReturnValue>;
};
type FetchStatus = 'loading' | 'loaded';
type ResultMetadata<TValue> = {
status: FetchStatus;
sourceValue?: NonNullable<TValue> | undefined;
};
type UseOnyxResult<TValue> = [NonNullable<TValue> | undefined, ResultMetadata<TValue>];
function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
key: TKey,
options?: UseOnyxOptions<TKey, TReturnValue>,
dependencies: DependencyList = [],
): UseOnyxResult<TReturnValue> {
const connectionRef = useRef<Connection | null>(null);
const previousKey = usePrevious(key);
const currentDependenciesRef = useLiveRef(dependencies);
const selector = options?.selector;
// Create memoized version of selector for performance
const memoizedSelector = useMemo(() => {
if (!selector) {
return null;
}
let lastInput: OnyxValue<TKey> | undefined;
let lastOutput: TReturnValue;
let lastDependencies: DependencyList = [];
let hasComputed = false;
return (input: OnyxValue<TKey> | undefined): TReturnValue => {
const currentDependencies = currentDependenciesRef.current;
// Recompute if input changed, dependencies changed, or first time
const dependenciesChanged = !shallowEqual(lastDependencies, currentDependencies);
if (!hasComputed || lastInput !== input || dependenciesChanged) {
// Only proceed if we have a valid selector
if (selector) {
const newOutput = selector(input);
// Deep equality mode: only update if output actually changed
if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) {
lastInput = input;
lastOutput = newOutput;
lastDependencies = [...currentDependencies];
hasComputed = true;
}
}
}
return lastOutput;
};
}, [currentDependenciesRef, selector]);
// Stores the previous cached value as it's necessary to compare with the new value in `getSnapshot()`.
// We initialize it to `null` to simulate that we don't have any value from cache yet.
const previousValueRef = useRef<TReturnValue | undefined | null>(null);
// Stores the newest cached value in order to compare with the previous one and optimize `getSnapshot()` execution.
const newValueRef = useRef<TReturnValue | undefined | null>(null);
const lastConnectedKeyRef = useRef<TKey>(key);
// Stores the previously result returned by the hook, containing the data from cache and the fetch status.
// We initialize it to `undefined` and `loading` fetch status to simulate the initial result when the hook is loading from the cache.
// However, if `initWithStoredValues` is `false` we set the fetch status to `loaded` since we want to signal that data is ready.
const resultRef = useRef<UseOnyxResult<TReturnValue>>([
undefined,
{
status: options?.initWithStoredValues === false ? 'loaded' : 'loading',
},
]);
// Indicates if it's the first Onyx connection of this hook or not, as we don't want certain use cases
// in `getSnapshot()` to be satisfied several times.
const isFirstConnectionRef = useRef(true);
// Indicates if the hook is connecting to an Onyx key.
const isConnectingRef = useRef(false);
// Stores the `onStoreChange()` function, which can be used to trigger a `getSnapshot()` update when desired.
const onStoreChangeFnRef = useRef<(() => void) | null>(null);
// Indicates if we should get the newest cached value from Onyx during `getSnapshot()` execution.
const shouldGetCachedValueRef = useRef(true);
// Inside useOnyx.ts, we need to track the sourceValue separately
const sourceValueRef = useRef<NonNullable<TReturnValue> | undefined>(undefined);
// Cache the options key to avoid regenerating it every getSnapshot call
const cacheKey = useMemo(
() =>
onyxSnapshotCache.registerConsumer({
selector: options?.selector,
initWithStoredValues: options?.initWithStoredValues,
allowStaleData: options?.allowStaleData,
canBeMissing: options?.canBeMissing,
}),
[options?.selector, options?.initWithStoredValues, options?.allowStaleData, options?.canBeMissing],
);
useEffect(() => () => onyxSnapshotCache.deregisterConsumer(key, cacheKey), [key, cacheKey]);
useEffect(() => {
if (lastConnectedKeyRef.current === key) {
return;
}
lastConnectedKeyRef.current = key;
shouldGetCachedValueRef.current = true;
previousValueRef.current = null;
resultRef.current = [undefined, {status: options?.initWithStoredValues === false ? 'loaded' : 'loading'}];
}, [key, options?.initWithStoredValues]);
useEffect(() => {
// These conditions will ensure we can only handle dynamic collection member keys from the same collection.
if (options?.allowDynamicKey || previousKey === key) {
return;
}
try {
const previousCollectionKey = OnyxUtils.splitCollectionMemberKey(previousKey)[0];
const collectionKey = OnyxUtils.splitCollectionMemberKey(key)[0];
if (OnyxUtils.isCollectionMemberKey(previousCollectionKey, previousKey) && OnyxUtils.isCollectionMemberKey(collectionKey, key) && previousCollectionKey === collectionKey) {
return;
}
} catch (e) {
throw new Error(
`'${previousKey}' key can't be changed to '${key}'. useOnyx() only supports dynamic keys if they are both collection member keys from the same collection e.g. from 'collection_id1' to 'collection_id2'.`,
);
}
throw new Error(
`'${previousKey}' key can't be changed to '${key}'. useOnyx() only supports dynamic keys if they are both collection member keys from the same collection e.g. from 'collection_id1' to 'collection_id2'.`,
);
}, [previousKey, key, options?.allowDynamicKey]);
// Track previous dependencies to prevent infinite loops
const previousDependenciesRef = useRef<DependencyList>([]);
useEffect(() => {
// This effect will only run if the `dependencies` array changes. If it changes it will force the hook
// to trigger a `getSnapshot()` update by calling the stored `onStoreChange()` function reference, thus
// re-running the hook and returning the latest value to the consumer.
// Deep equality check to prevent infinite loops when dependencies array reference changes
// but content remains the same
if (shallowEqual(previousDependenciesRef.current, dependencies)) {
return;
}
previousDependenciesRef.current = dependencies;
if (connectionRef.current === null || isConnectingRef.current || !onStoreChangeFnRef.current) {
return;
}
// Invalidate cache when dependencies change so selector runs with new closure values
onyxSnapshotCache.invalidateForKey(key);
shouldGetCachedValueRef.current = true;
onStoreChangeFnRef.current();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...dependencies]);
const checkEvictableKey = useCallback(() => {
if (options?.canEvict === undefined || !connectionRef.current) {
return;
}
if (!OnyxCache.isEvictableKey(key)) {
throw new Error(`canEvict can't be used on key '${key}'. This key must explicitly be flagged as safe for removal by adding it to Onyx.init({evictableKeys: []}).`);
}
if (options.canEvict) {
connectionManager.removeFromEvictionBlockList(connectionRef.current);
} else {
connectionManager.addToEvictionBlockList(connectionRef.current);
}
}, [key, options?.canEvict]);
const getSnapshot = useCallback(() => {
// Check if we have any cache for this Onyx key
// Don't use cache for first connection with initWithStoredValues: false
// Also don't use cache during active data updates (when shouldGetCachedValueRef is true)
if (!(isFirstConnectionRef.current && options?.initWithStoredValues === false) && !shouldGetCachedValueRef.current) {
const cachedResult = onyxSnapshotCache.getCachedResult<UseOnyxResult<TReturnValue>>(key, cacheKey);
if (cachedResult !== undefined) {
resultRef.current = cachedResult;
return cachedResult;
}
}
let isOnyxValueDefined = true;
// We return the initial result right away during the first connection if `initWithStoredValues` is set to `false`.
if (isFirstConnectionRef.current && options?.initWithStoredValues === false) {
const result = resultRef.current;
// Store result in snapshot cache
onyxSnapshotCache.setCachedResult<UseOnyxResult<TReturnValue>>(key, cacheKey, result);
return result;
}
// We get the value from cache while the first connection to Onyx is being made or if the key has changed,
// so we can return any cached value right away. For the case where the key has changed, If we don't return the cached value right away, then the UI will show the incorrect (previous) value for a brief period which looks like a UI glitch to the user. After the connection is made, we only
// update `newValueRef` when `Onyx.connect()` callback is fired.
if (isFirstConnectionRef.current || shouldGetCachedValueRef.current || key !== previousKey) {
// Gets the value from cache and maps it with selector. It changes `null` to `undefined` for `useOnyx` compatibility.
const value = OnyxUtils.tryGetCachedValue(key) as OnyxValue<TKey>;
const selectedValue = memoizedSelector ? memoizedSelector(value) : value;
newValueRef.current = (selectedValue ?? undefined) as TReturnValue | undefined;
// This flag is `false` when the original Onyx value (without selector) is not defined yet.
// It will be used later to check if we need to log an alert that the value is missing.
isOnyxValueDefined = value !== null && value !== undefined;
// We set this flag to `false` again since we don't want to get the newest cached value every time `getSnapshot()` is executed,
// and only when `Onyx.connect()` callback is fired.
shouldGetCachedValueRef.current = false;
}
const hasCacheForKey = OnyxCache.hasCacheForKey(key);
// Since the fetch status can be different given the use cases below, we define the variable right away.
let newFetchStatus: FetchStatus | undefined;
// If we have pending merge operations for the key during the first connection, we set the new value to `undefined`
// and fetch status to `loading` to simulate that it is still being loaded until we have the most updated data.
// If `allowStaleData` is `true` this logic will be ignored and cached value will be used, even if it's stale data.
if (isFirstConnectionRef.current && OnyxUtils.hasPendingMergeForKey(key) && !options?.allowStaleData) {
newValueRef.current = undefined;
newFetchStatus = 'loading';
}
// Optimized equality checking:
// - Memoized selectors already handle deep equality internally, so we can use fast reference equality
// - Non-selector cases use shallow equality for object reference checks
// - Normalize null to undefined to ensure consistent comparison (both represent "no value")
let areValuesEqual: boolean;
if (memoizedSelector) {
const normalizedPrevious = previousValueRef.current ?? undefined;
const normalizedNew = newValueRef.current ?? undefined;
areValuesEqual = normalizedPrevious === normalizedNew;
} else {
areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current);
}
// We update the cached value and the result in the following conditions:
// We will update the cached value and the result in any of the following situations:
// - The previously cached value is different from the new value.
// - The previously cached value is `null` (not set from cache yet) and we have cache for this key
// OR we have a pending `Onyx.clear()` task (if `Onyx.clear()` is running cache might not be available anymore
// so we update the cached value/result right away in order to prevent infinite loading state issues).
const shouldUpdateResult = !areValuesEqual || (previousValueRef.current === null && (hasCacheForKey || OnyxCache.hasPendingTask(TASK.CLEAR)));
if (shouldUpdateResult) {
previousValueRef.current = newValueRef.current;
// If the new value is `null` we default it to `undefined` to ensure the consumer gets a consistent result from the hook.
newFetchStatus = newFetchStatus ?? 'loaded';
resultRef.current = [
previousValueRef.current ?? undefined,
{
status: newFetchStatus,
sourceValue: sourceValueRef.current,
},
];
// If `canBeMissing` is set to `false` and the Onyx value of that key is not defined,
// we log an alert so it can be acknowledged by the consumer. Additionally, we won't log alerts
// if there's a `Onyx.clear()` task in progress.
if (options?.canBeMissing === false && newFetchStatus === 'loaded' && !isOnyxValueDefined && !OnyxCache.hasPendingTask(TASK.CLEAR)) {
Logger.logAlert(`useOnyx returned no data for key with canBeMissing set to false for key ${key}`, {showAlert: true});
}
}
if (newFetchStatus !== 'loading') {
onyxSnapshotCache.setCachedResult<UseOnyxResult<TReturnValue>>(key, cacheKey, resultRef.current);
}
return resultRef.current;
}, [options?.initWithStoredValues, options?.allowStaleData, options?.canBeMissing, key, memoizedSelector, cacheKey]);
const subscribe = useCallback(
(onStoreChange: () => void) => {
isConnectingRef.current = true;
onStoreChangeFnRef.current = onStoreChange;
connectionRef.current = connectionManager.connect<CollectionKeyBase>({
key,
callback: (value, callbackKey, sourceValue) => {
isConnectingRef.current = false;
onStoreChangeFnRef.current = onStoreChange;
// Signals that the first connection was made, so some logics in `getSnapshot()`
// won't be executed anymore.
isFirstConnectionRef.current = false;
// Signals that we want to get the newest cached value again in `getSnapshot()`.
shouldGetCachedValueRef.current = true;
// sourceValue is unknown type, so we need to cast it to the correct type.
sourceValueRef.current = sourceValue as NonNullable<TReturnValue>;
// Invalidate snapshot cache for this key when data changes
onyxSnapshotCache.invalidateForKey(key);
// Finally, we signal that the store changed, making `getSnapshot()` be called again.
onStoreChange();
},
initWithStoredValues: options?.initWithStoredValues,
waitForCollectionCallback: OnyxUtils.isCollectionKey(key) as true,
reuseConnection: options?.reuseConnection,
});
checkEvictableKey();
return () => {
if (!connectionRef.current) {
return;
}
connectionManager.disconnect(connectionRef.current);
isFirstConnectionRef.current = false;
isConnectingRef.current = false;
onStoreChangeFnRef.current = null;
};
},
[key, options?.initWithStoredValues, options?.reuseConnection, checkEvictableKey],
);
const getSnapshotDecorated = useMemo(() => {
if (!GlobalSettings.isPerformanceMetricsEnabled()) {
return getSnapshot;
}
return decorateWithMetrics(getSnapshot, 'useOnyx.getSnapshot');
}, [getSnapshot]);
useEffect(() => {
checkEvictableKey();
}, [checkEvictableKey]);
const result = useSyncExternalStore<UseOnyxResult<TReturnValue>>(subscribe, getSnapshotDecorated);
return result;
}
export default useOnyx;
export type {FetchStatus, ResultMetadata, UseOnyxResult, UseOnyxOptions, UseOnyxSelector};