diff --git a/lib/GlobalSettings.ts b/lib/GlobalSettings.ts new file mode 100644 index 00000000..68617b7e --- /dev/null +++ b/lib/GlobalSettings.ts @@ -0,0 +1,32 @@ +/** + * Stores settings from Onyx.init globally so they can be made accessible by other parts of the library. + */ + +const globalSettings = { + enablePerformanceMetrics: false, +}; + +type GlobalSettings = typeof globalSettings; + +const listeners = new Set<(settings: GlobalSettings) => unknown>(); +function addGlobalSettingsChangeListener(listener: (settings: GlobalSettings) => unknown) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function notifyListeners() { + listeners.forEach((listener) => listener(globalSettings)); +} + +function setPerformanceMetricsEnabled(enabled: boolean) { + globalSettings.enablePerformanceMetrics = enabled; + notifyListeners(); +} + +function isPerformanceMetricsEnabled() { + return globalSettings.enablePerformanceMetrics; +} + +export {setPerformanceMetricsEnabled, isPerformanceMetricsEnabled, addGlobalSettingsChangeListener}; diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 01ac9fcd..a15db1d5 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -32,6 +32,8 @@ import OnyxUtils from './OnyxUtils'; import logMessages from './logMessages'; import type {Connection} from './OnyxConnectionManager'; import connectionManager from './OnyxConnectionManager'; +import * as GlobalSettings from './GlobalSettings'; +import decorateWithMetrics from './metrics'; /** Initialize the store with actions and listening for storage events */ function init({ @@ -41,7 +43,13 @@ function init({ maxCachedKeysCount = 1000, shouldSyncMultipleInstances = Boolean(global.localStorage), debugSetState = false, + enablePerformanceMetrics = false, }: InitOptions): void { + if (enablePerformanceMetrics) { + GlobalSettings.setPerformanceMetricsEnabled(true); + applyDecorators(); + } + Storage.init(); if (shouldSyncMultipleInstances) { @@ -776,7 +784,27 @@ const Onyx = { clear, init, registerLogger: Logger.registerLogger, -} as const; +}; + +function applyDecorators() { + // We are reassigning the functions directly so that internal function calls are also decorated + /* eslint-disable rulesdir/prefer-actions-set-data */ + // @ts-expect-error Reassign + connect = decorateWithMetrics(connect, 'Onyx.connect'); + // @ts-expect-error Reassign + set = decorateWithMetrics(set, 'Onyx.set'); + // @ts-expect-error Reassign + multiSet = decorateWithMetrics(multiSet, 'Onyx.multiSet'); + // @ts-expect-error Reassign + merge = decorateWithMetrics(merge, 'Onyx.merge'); + // @ts-expect-error Reassign + mergeCollection = decorateWithMetrics(mergeCollection, 'Onyx.mergeCollection'); + // @ts-expect-error Reassign + update = decorateWithMetrics(update, 'Onyx.update'); + // @ts-expect-error Reassign + clear = decorateWithMetrics(clear, 'Onyx.clear'); + /* eslint-enable rulesdir/prefer-actions-set-data */ +} export default Onyx; export type {OnyxUpdate, Mapping, ConnectOptions}; diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index ec966aca..dcbf839e 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -32,6 +32,8 @@ import utils from './utils'; import type {WithOnyxState} from './withOnyx/types'; import type {DeferredTask} from './createDeferredTask'; import createDeferredTask from './createDeferredTask'; +import * as GlobalSettings from './GlobalSettings'; +import decorateWithMetrics from './metrics'; // Method constants const METHOD = { @@ -1418,6 +1420,51 @@ const OnyxUtils = { getEvictionBlocklist, }; -export type {OnyxMethod}; +GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => { + if (!enablePerformanceMetrics) { + return; + } + // We are reassigning the functions directly so that internal function calls are also decorated + + // @ts-expect-error Reassign + initStoreValues = decorateWithMetrics(initStoreValues, 'OnyxUtils.initStoreValues'); + // @ts-expect-error Reassign + maybeFlushBatchUpdates = decorateWithMetrics(maybeFlushBatchUpdates, 'OnyxUtils.maybeFlushBatchUpdates'); + // @ts-expect-error Reassign + batchUpdates = decorateWithMetrics(batchUpdates, 'OnyxUtils.batchUpdates'); + // @ts-expect-error Complex type signature + get = decorateWithMetrics(get, 'OnyxUtils.get'); + // @ts-expect-error Reassign + getAllKeys = decorateWithMetrics(getAllKeys, 'OnyxUtils.getAllKeys'); + // @ts-expect-error Reassign + getCollectionKeys = decorateWithMetrics(getCollectionKeys, 'OnyxUtils.getCollectionKeys'); + // @ts-expect-error Reassign + addAllSafeEvictionKeysToRecentlyAccessedList = decorateWithMetrics(addAllSafeEvictionKeysToRecentlyAccessedList, 'OnyxUtils.addAllSafeEvictionKeysToRecentlyAccessedList'); + // @ts-expect-error Reassign + keysChanged = decorateWithMetrics(keysChanged, 'OnyxUtils.keysChanged'); + // @ts-expect-error Reassign + keyChanged = decorateWithMetrics(keyChanged, 'OnyxUtils.keyChanged'); + // @ts-expect-error Reassign + sendDataToConnection = decorateWithMetrics(sendDataToConnection, 'OnyxUtils.sendDataToConnection'); + // @ts-expect-error Reassign + scheduleSubscriberUpdate = decorateWithMetrics(scheduleSubscriberUpdate, 'OnyxUtils.scheduleSubscriberUpdate'); + // @ts-expect-error Reassign + scheduleNotifyCollectionSubscribers = decorateWithMetrics(scheduleNotifyCollectionSubscribers, 'OnyxUtils.scheduleNotifyCollectionSubscribers'); + // @ts-expect-error Reassign + remove = decorateWithMetrics(remove, 'OnyxUtils.remove'); + // @ts-expect-error Reassign + reportStorageQuota = decorateWithMetrics(reportStorageQuota, 'OnyxUtils.reportStorageQuota'); + // @ts-expect-error Complex type signature + evictStorageAndRetry = decorateWithMetrics(evictStorageAndRetry, 'OnyxUtils.evictStorageAndRetry'); + // @ts-expect-error Reassign + broadcastUpdate = decorateWithMetrics(broadcastUpdate, 'OnyxUtils.broadcastUpdate'); + // @ts-expect-error Reassign + initializeWithDefaultKeyStates = decorateWithMetrics(initializeWithDefaultKeyStates, 'OnyxUtils.initializeWithDefaultKeyStates'); + // @ts-expect-error Complex type signature + multiGet = decorateWithMetrics(multiGet, 'OnyxUtils.multiGet'); + // @ts-expect-error Reassign + subscribeToKey = decorateWithMetrics(subscribeToKey, 'OnyxUtils.subscribeToKey'); +}); +export type {OnyxMethod}; export default OnyxUtils; diff --git a/lib/dependencies/ModuleProxy.ts b/lib/dependencies/ModuleProxy.ts new file mode 100644 index 00000000..597f38a8 --- /dev/null +++ b/lib/dependencies/ModuleProxy.ts @@ -0,0 +1,39 @@ +type ImportType = ReturnType; + +/** + * Create a lazily-imported module proxy. + * This is useful for lazily requiring optional dependencies. + */ +const createModuleProxy = (getModule: () => ImportType): TModule => { + const holder: {module: TModule | undefined} = {module: undefined}; + + const proxy = new Proxy(holder, { + get: (target, property) => { + if (property === '$$typeof') { + // If inlineRequires is enabled, Metro will look up all imports + // with the $$typeof operator. In this case, this will throw the + // `OptionalDependencyNotInstalledError` error because we try to access the module + // even though we are not using it (Metro does it), so instead we return undefined + // to bail out of inlineRequires here. + return undefined; + } + + if (target.module == null) { + // lazy initialize module via require() + // caller needs to make sure the require() call is wrapped in a try/catch + // eslint-disable-next-line no-param-reassign + target.module = getModule() as TModule; + } + return target.module[property as keyof typeof holder.module]; + }, + }); + return proxy as unknown as TModule; +}; + +class OptionalDependencyNotInstalledError extends Error { + constructor(name: string) { + super(`${name} is not installed!`); + } +} + +export {createModuleProxy, OptionalDependencyNotInstalledError}; diff --git a/lib/dependencies/PerformanceProxy/index.native.ts b/lib/dependencies/PerformanceProxy/index.native.ts new file mode 100644 index 00000000..da35d419 --- /dev/null +++ b/lib/dependencies/PerformanceProxy/index.native.ts @@ -0,0 +1,13 @@ +import type performance from 'react-native-performance'; +import {createModuleProxy, OptionalDependencyNotInstalledError} from '../ModuleProxy'; + +const PerformanceProxy = createModuleProxy(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('react-native-performance').default; + } catch { + throw new OptionalDependencyNotInstalledError('react-native-performance'); + } +}); + +export default PerformanceProxy; diff --git a/lib/dependencies/PerformanceProxy/index.ts b/lib/dependencies/PerformanceProxy/index.ts new file mode 100644 index 00000000..3aff0ad4 --- /dev/null +++ b/lib/dependencies/PerformanceProxy/index.ts @@ -0,0 +1,2 @@ +// Use the existing performance API on web +export default performance; diff --git a/lib/metrics.ts b/lib/metrics.ts new file mode 100644 index 00000000..301a5467 --- /dev/null +++ b/lib/metrics.ts @@ -0,0 +1,58 @@ +import PerformanceProxy from './dependencies/PerformanceProxy'; + +const decoratedAliases = new Set(); + +/** + * Capture a measurement between the start mark and now + */ +function measureMarkToNow(startMark: PerformanceMark, detail: Record) { + PerformanceProxy.measure(`${startMark.name} [${startMark.detail.args.toString()}]`, { + start: startMark.startTime, + end: PerformanceProxy.now(), + detail: {...startMark.detail, ...detail}, + }); +} + +function isPromiseLike(value: unknown): value is Promise { + return value != null && typeof value === 'object' && 'then' in value; +} + +/** + * Wraps a function with metrics capturing logic + */ +function decorateWithMetrics(func: (...args: Args) => ReturnType, alias = func.name) { + if (decoratedAliases.has(alias)) { + throw new Error(`"${alias}" is already decorated`); + } + + decoratedAliases.add(alias); + function decorated(...args: Args) { + const mark = PerformanceProxy.mark(alias, {detail: {args, alias}}); + + const originalReturnValue = func(...args); + + if (isPromiseLike(originalReturnValue)) { + /* + * The handlers added here are not affecting the original promise + * They create a separate chain that's not exposed (returned) to the original caller + */ + originalReturnValue + .then((result) => { + measureMarkToNow(mark, {result}); + }) + .catch((error) => { + measureMarkToNow(mark, {error}); + }); + + return originalReturnValue; + } + + measureMarkToNow(mark, {result: originalReturnValue}); + return originalReturnValue; + } + decorated.name = `${alias}_DECORATED`; + + return decorated; +} + +export default decorateWithMetrics; diff --git a/lib/storage/index.ts b/lib/storage/index.ts index c9b797b1..938b615a 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -4,6 +4,8 @@ import PlatformStorage from './platforms'; import InstanceSync from './InstanceSync'; import MemoryOnlyProvider from './providers/MemoryOnlyProvider'; import type StorageProvider from './providers/types'; +import * as GlobalSettings from '../GlobalSettings'; +import decorateWithMetrics from '../metrics'; let provider = PlatformStorage; let shouldKeepInstancesSync = false; @@ -55,7 +57,7 @@ function tryOrDegradePerformance(fn: () => Promise | T, waitForInitializat }); } -const Storage: Storage = { +const storage: Storage = { /** * Returns the storage provider currently in use */ @@ -202,4 +204,22 @@ const Storage: Storage = { }, }; -export default Storage; +GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => { + if (!enablePerformanceMetrics) { + return; + } + + // Apply decorators + storage.getItem = decorateWithMetrics(storage.getItem, 'Storage.getItem'); + storage.multiGet = decorateWithMetrics(storage.multiGet, 'Storage.multiGet'); + storage.setItem = decorateWithMetrics(storage.setItem, 'Storage.setItem'); + storage.multiSet = decorateWithMetrics(storage.multiSet, 'Storage.multiSet'); + storage.mergeItem = decorateWithMetrics(storage.mergeItem, 'Storage.mergeItem'); + storage.multiMerge = decorateWithMetrics(storage.multiMerge, 'Storage.multiMerge'); + storage.removeItem = decorateWithMetrics(storage.removeItem, 'Storage.removeItem'); + storage.removeItems = decorateWithMetrics(storage.removeItems, 'Storage.removeItems'); + storage.clear = decorateWithMetrics(storage.clear, 'Storage.clear'); + storage.getAllKeys = decorateWithMetrics(storage.getAllKeys, 'Storage.getAllKeys'); +}); + +export default storage; diff --git a/lib/types.ts b/lib/types.ts index ea939a9b..4f619e80 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -468,6 +468,12 @@ type InitOptions = { /** Enables debugging setState() calls to connected components */ debugSetState?: boolean; + + /** + * If enabled it will use the performance API to measure the time taken by Onyx operations. + * @default false + */ + enablePerformanceMetrics?: boolean; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index fa460dd7..701e3ac4 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -1,5 +1,6 @@ import {deepEqual, shallowEqual} from 'fast-equals'; import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react'; +import type {DependencyList} from 'react'; import OnyxCache, {TASK} from './OnyxCache'; import type {Connection} from './OnyxConnectionManager'; import connectionManager from './OnyxConnectionManager'; @@ -104,12 +105,18 @@ function getCachedValue(key: TKey, selector?: UseO function useOnyx>( key: TKey, options?: BaseUseOnyxOptions & UseOnyxInitialValueOption & Required>, + dependencies?: DependencyList, ): UseOnyxResult; function useOnyx>( key: TKey, options?: BaseUseOnyxOptions & UseOnyxInitialValueOption>, + dependencies?: DependencyList, ): UseOnyxResult; -function useOnyx>(key: TKey, options?: UseOnyxOptions): UseOnyxResult { +function useOnyx>( + key: TKey, + options?: UseOnyxOptions, + dependencies: DependencyList = [], +): UseOnyxResult { const connectionRef = useRef(null); const previousKey = usePrevious(key); @@ -137,6 +144,12 @@ function useOnyx>(key: TKey // 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); @@ -168,6 +181,19 @@ function useOnyx>(key: TKey ); }, [previousKey, key]); + 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. + if (connectionRef.current === null || isConnectingRef.current || !onStoreChangeFnRef.current) { + return; + } + + shouldGetCachedValueRef.current = true; + onStoreChangeFnRef.current(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...dependencies]); + // Mimics withOnyx's checkEvictableKeys() behavior. const checkEvictableKey = useCallback(() => { if (options?.canEvict === undefined || !connectionRef.current) { @@ -255,9 +281,15 @@ function useOnyx>(key: TKey const subscribe = useCallback( (onStoreChange: () => void) => { + isConnectingRef.current = true; + onStoreChangeFnRef.current = onStoreChange; + connectionRef.current = connectionManager.connect({ key, callback: () => { + 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; @@ -282,6 +314,8 @@ function useOnyx>(key: TKey connectionManager.disconnect(connectionRef.current); isFirstConnectionRef.current = false; + isConnectingRef.current = false; + onStoreChangeFnRef.current = null; }; }, [key, options?.initWithStoredValues, options?.reuseConnection, checkEvictableKey], diff --git a/package-lock.json b/package-lock.json index dec30c70..aae41573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-native-onyx", - "version": "2.0.82", + "version": "2.0.85", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "react-native-onyx", - "version": "2.0.82", + "version": "2.0.85", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index 03b2c8ce..e534dd6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-onyx", - "version": "2.0.82", + "version": "2.0.85", "author": "Expensify, Inc.", "homepage": "https://expensify.com", "description": "State management for React Native", diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 6e74ee4a..2c874469 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -610,6 +610,55 @@ describe('useOnyx', () => { }); }); + describe('dependencies', () => { + it('should return the updated selected value when a external value passed to the dependencies list changes', async () => { + Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, + } as GenericCollection); + + let externalValue = 'ex1'; + + const {result, rerender} = renderHook(() => + useOnyx( + ONYXKEYS.COLLECTION.TEST_KEY, + { + // @ts-expect-error bypass + selector: (entries: OnyxCollection<{id: string; name: string}>) => + Object.entries(entries ?? {}).reduce>>((acc, [key, value]) => { + acc[key] = `${value?.id}_${externalValue}`; + return acc; + }, {}), + }, + [externalValue], + ), + ); + + await act(async () => waitForPromisesToResolve()); + + expect(result.current[0]).toEqual({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: 'entry1_id_ex1', + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: 'entry2_id_ex1', + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: 'entry3_id_ex1', + }); + expect(result.current[1].status).toEqual('loaded'); + + externalValue = 'ex2'; + + await act(async () => { + rerender(undefined); + }); + + expect(result.current[0]).toEqual({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: 'entry1_id_ex2', + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: 'entry2_id_ex2', + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: 'entry3_id_ex2', + }); + expect(result.current[1].status).toEqual('loaded'); + }); + }); + // This test suite must be the last one to avoid problems when running the other tests here. describe('canEvict', () => { const error = (key: string) => `canEvict can't be used on key '${key}'. This key must explicitly be flagged as safe for removal by adding it to Onyx.init({safeEvictionKeys: []}).`;