diff --git a/package-lock.json b/package-lock.json index 7d3e59157afa7..c2ff57cffb7ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "lodash": "4.17.21", "lottie-react-native": "6.5.1", "mapbox-gl": "^2.15.0", + "moize": "^6.1.6", "onfido-sdk-ui": "14.15.0", "process": "^0.11.10", "pusher-js": "8.3.0", @@ -28344,6 +28345,11 @@ } } }, + "node_modules/micro-memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/micro-memoize/-/micro-memoize-4.1.2.tgz", + "integrity": "sha512-+HzcV2H+rbSJzApgkj0NdTakkC+bnyeiUxgT6/m7mjcz1CmM22KYFKp+EVj1sWe4UYcnriJr5uqHQD/gMHLD+g==" + }, "node_modules/micromatch": { "version": "4.0.5", "license": "MIT", @@ -28548,6 +28554,20 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, + "node_modules/moize": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/moize/-/moize-6.1.6.tgz", + "integrity": "sha512-vSKdIUO61iCmTqhdoIDrqyrtp87nWZUmBPniNjO0fX49wEYmyDO4lvlnFXiGcaH1JLE/s/9HbiK4LSHsbiUY6Q==", + "dependencies": { + "fast-equals": "^3.0.1", + "micro-memoize": "^4.1.2" + } + }, + "node_modules/moize/node_modules/fast-equals": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-3.0.3.tgz", + "integrity": "sha512-NCe8qxnZFARSHGztGMZOO/PC1qa5MIFB5Hp66WdzbCRAz8U8US3bx1UTgLS49efBQPcUtO9gf5oVEY8o7y/7Kg==" + }, "node_modules/mrmime": { "version": "1.0.1", "dev": true, diff --git a/package.json b/package.json index 599b9c7e438b2..ced7db8f78019 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "lodash": "4.17.21", "lottie-react-native": "6.5.1", "mapbox-gl": "^2.15.0", + "moize": "^6.1.6", "onfido-sdk-ui": "14.15.0", "process": "^0.11.10", "pusher-js": "8.3.0", diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index f96bdd573cfeb..9bbb523ab3070 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -1,6 +1,5 @@ import {getUnixTime} from 'date-fns'; import {Str} from 'expensify-common'; -import memoize from 'lodash/memoize'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import * as Emojis from '@assets/emojis'; @@ -12,6 +11,7 @@ import type {ReportActionReaction, UsersReactions} from '@src/types/onyx/ReportA import type IconAsset from '@src/types/utils/IconAsset'; import type EmojiTrie from './EmojiTrie'; import type {SupportedLanguage} from './EmojiTrie'; +import memoize from './memoize'; type HeaderIndice = {code: string; index: number; icon: IconAsset}; type EmojiSpacer = {code: string; spacer: boolean}; diff --git a/src/libs/LocaleDigitUtils.ts b/src/libs/LocaleDigitUtils.ts index 156e58c590335..b14604c35e044 100644 --- a/src/libs/LocaleDigitUtils.ts +++ b/src/libs/LocaleDigitUtils.ts @@ -1,8 +1,8 @@ -import _ from 'lodash'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import * as Localize from './Localize'; +import memoize from './memoize'; import * as NumberFormatUtils from './NumberFormatUtils'; type Locale = ValueOf; @@ -13,7 +13,7 @@ const INDEX_DECIMAL = 10; const INDEX_MINUS_SIGN = 11; const INDEX_GROUP = 12; -const getLocaleDigits = _.memoize((locale: Locale): string[] => { +const getLocaleDigits = memoize((locale: Locale): string[] => { const localeDigits = [...STANDARD_DIGITS]; for (let i = 0; i <= 9; i++) { localeDigits[i] = NumberFormatUtils.format(locale, i); diff --git a/src/libs/NumberFormatUtils.ts b/src/libs/NumberFormatUtils.ts index bf42e25cbb484..8c4ee54341c5c 100644 --- a/src/libs/NumberFormatUtils.ts +++ b/src/libs/NumberFormatUtils.ts @@ -1,12 +1,18 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; +import memoize from './memoize'; + +const numberFormatter = memoize(Intl.NumberFormat, { + equalityCheck: 'deep', + maxSize: 10, +}); function format(locale: ValueOf, number: number, options?: Intl.NumberFormatOptions): string { - return new Intl.NumberFormat(locale, options).format(number); + return numberFormatter(locale, options).format(number); } function formatToParts(locale: ValueOf, number: number, options?: Intl.NumberFormatOptions): Intl.NumberFormatPart[] { - return new Intl.NumberFormat(locale, options).formatToParts(number); + return numberFormatter(locale, options).formatToParts(number); } export {format, formatToParts}; diff --git a/src/libs/memoize/cacheBuilder/array.ts b/src/libs/memoize/cacheBuilder/array.ts new file mode 100644 index 0000000000000..625ead9294433 --- /dev/null +++ b/src/libs/memoize/cacheBuilder/array.ts @@ -0,0 +1,33 @@ +import moize from 'moize'; + +type ArrayCacheConfig = { + cacheMode: 'array'; + equalityCheck: 'deep' | 'shallow'; + maxSize: number; +}; + +function arrayCacheBuilder(f: (...args: Args[]) => RT, c: ArrayCacheConfig) { + // If cacheMode is array we proceed with moize + const moizeConfig = { + isDeepEqual: c.equalityCheck === 'deep', + isShallowEqual: c.equalityCheck === 'shallow', + maxSize: c.maxSize, + }; + + const moized = moize(f, moizeConfig); + + moized.snapshot = () => { + const keys = moized.cache.snapshot.keys; + const values = moized.cache.snapshot.values as Args[]; + + if (!keys?.length) { + return []; + } + + return keys.map((k, i) => [k, values[i]]); + }; + + return moized; +} + +export default arrayCacheBuilder; diff --git a/src/libs/memoize/cacheBuilder/index.ts b/src/libs/memoize/cacheBuilder/index.ts new file mode 100644 index 0000000000000..21231da4b7dd5 --- /dev/null +++ b/src/libs/memoize/cacheBuilder/index.ts @@ -0,0 +1,4 @@ +import ArrayCacheBuilder from './array'; +import MapCacheBuilder from './map'; + +export {ArrayCacheBuilder, MapCacheBuilder}; diff --git a/src/libs/memoize/cacheBuilder/map.ts b/src/libs/memoize/cacheBuilder/map.ts new file mode 100644 index 0000000000000..c9edd0016590f --- /dev/null +++ b/src/libs/memoize/cacheBuilder/map.ts @@ -0,0 +1,51 @@ +type MapCacheConfig = { + cacheMode: 'map'; + equalityCheck: 'deep'; + maxSize: number; +}; + +function mapCacheBuilder unknown, Key = Parameters, Val = ReturnType>(f: Fn, c: MapCacheConfig) { + const cache = new Map(); + + // FIXME - how to type this? + function memoized(...args: Key) { + // FIXME - this undermines the effect of using map as a cache struct - serialization might be causing perf and stability issues even with fast map lookup: + // - the added overhead is dependent on size of arguments provided not cache size so that's a plus + // - is serialization by JSON.stringify safe to use? How about complex objects, not serializable structs and order of parameters? + const key = JSON.stringify(args); + + if (cache.has(key)) { + return cache.get(key); + } + + const result = f(...args) as Val; + cache.set(key, result); + + if (cache.size > c.maxSize) { + // Maps should keep insertion order, so we can safely delete the first key + cache.delete(cache.keys().next().value as string); + } + + return result; + } + + memoized.set = (args: Key, value: Val) => { + const key = JSON.stringify(args); + cache.set(key, value); + }; + + memoized.get = (args: Key) => { + const key = JSON.stringify(args); + cache.get(key); + }; + + memoized.clear = () => { + cache.clear(); + }; + + memoized.snapshot = () => Array.from(cache.entries()); + + return memoized; +} + +export default mapCacheBuilder; diff --git a/src/libs/memoize/index.ts b/src/libs/memoize/index.ts new file mode 100644 index 0000000000000..6697d2e22c180 --- /dev/null +++ b/src/libs/memoize/index.ts @@ -0,0 +1,3 @@ +import memoize from './memoize'; + +export default memoize; diff --git a/src/libs/memoize/memoize.ts b/src/libs/memoize/memoize.ts new file mode 100644 index 0000000000000..d42d268919bf1 --- /dev/null +++ b/src/libs/memoize/memoize.ts @@ -0,0 +1,35 @@ +import * as cacheBuilders from './cacheBuilder'; +import type {ExternalMemoizeConfig, MemoizeConfig, MemoizedInterface, MemoizeFnPredicate} from './types'; + +const DEFAULT_CONFIG = { + cacheMode: 'array', + equalityCheck: 'shallow', + maxSize: Infinity, +} as const; + +/** + * Utility which returns a memoized version of a function. If same arguments are passed to the memoized function, its return value will be retrieved from cache instead of calculating it from scratch. + * + * Possible options for cache structure: + * - `map` - uses `Map` primitive under the hood (only `deep` comparison of keys possible) + * - `array` - uses `Array` primitive under the hood (slower lookup but enables `shallow` comparison) + * @param f - Function you want to memoize + * @param config - See `MemoizeConfig` type for more details + * @returns Memoized function. Just use it and your function input should be memoized. Static methods added for direct cache manipulation. See `MemoizedInterface` type for more details. + */ +function memoize(f: Fn, config: ExternalMemoizeConfig = DEFAULT_CONFIG): MemoizedInterface { + // Make sure default values are provided + const preparedConfig = (config !== DEFAULT_CONFIG ? {...DEFAULT_CONFIG, ...config} : config) as MemoizeConfig; + + switch (preparedConfig.cacheMode) { + case 'map': + // FIXME - How to properly type this? + return cacheBuilders.MapCacheBuilder(f, preparedConfig); + + default: + // FIXME - How to properly type this? + return cacheBuilders.ArrayCacheBuilder(f, preparedConfig); + } +} + +export default memoize; diff --git a/src/libs/memoize/types.ts b/src/libs/memoize/types.ts new file mode 100644 index 0000000000000..6cd16780bc5f1 --- /dev/null +++ b/src/libs/memoize/types.ts @@ -0,0 +1,39 @@ +/** + * Internal cache structure type. + * - `array` - it operates on an array, which enables both `shallow` and `deep` comparison of keys. Check `ArrayCacheConfig` for more details. + * - `map` - it operates on a map, which enables faster lookup but requires `deep` comparison of keys only. Check `MapCacheConfig` for more details. + */ +type CacheMode = 'array' | 'map'; + +type MapCacheConfig = { + cacheMode: 'map'; + equalityCheck: 'deep'; +}; + +type ArrayCacheConfig = { + cacheMode: 'array'; + equalityCheck: 'shallow' | 'deep'; +}; + +type CacheConfig = MapCacheConfig | ArrayCacheConfig; + +type MemoizeConfig = { + maxSize: number; +} & CacheConfig; + +type ExternalMemoizeConfig = Partial; + +// Anys are needed as this is only a predicate passed to extends clause +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type MemoizeFnPredicate = (...args: any[]) => any; + +type CacheBuilder = (f: Fn, config: C) => MemoizedInterface; + +type MemoizedInterface, Val = ReturnType> = Fn & { + get: (key: Key) => Val | undefined; + set: (key: Key, value: Val) => void; + clear: () => void; + snapshot: () => Array<[Key, Val]>; +}; + +export type {MemoizeConfig, ExternalMemoizeConfig, CacheMode, MemoizedInterface, CacheBuilder, MemoizeFnPredicate};