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/package.json b/package.json index 59866499..774d27f3 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,7 @@ "README.md", "LICENSE.md" ], - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "lib/index.ts", "scripts": { "lint": "eslint .", "typecheck": "tsc --noEmit",