diff --git a/.eslintrc.js b/.eslintrc.js index c85d991c9c75..cb1219533278 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -66,6 +66,15 @@ const restrictedImportPaths = [ "For 'ExpensiMark', please use '@libs/Parser' instead.", ].join('\n'), }, + { + name: 'lodash/memoize', + message: "Please use '@src/libs/memoize' instead.", + }, + { + name: 'lodash', + importNames: ['memoize'], + message: "Please use '@src/libs/memoize' instead.", + }, ]; const restrictedImportPatterns = [ diff --git a/package-lock.json b/package-lock.json index d537e6efabf8..222764a49643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26469,6 +26469,8 @@ }, "node_modules/fast-equals": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", "license": "MIT" }, "node_modules/fast-glob": { diff --git a/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx index 12282bf64445..d9e106ec3f52 100644 --- a/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx +++ b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx @@ -12,6 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import toggleProfileTool from '@libs/actions/ProfilingTool'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; +import {Memoize} from '@libs/memoize'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -55,6 +56,7 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = const [sharePath, setSharePath] = useState(''); const [totalMemory, setTotalMemory] = useState(0); const [usedMemory, setUsedMemory] = useState(0); + const [memoizeStats, setMemoizeStats] = useState>(); const {translate} = useLocalize(); // eslint-disable-next-line @lwc/lwc/no-async-await @@ -66,11 +68,13 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = const amountOfUsedMemory = await DeviceInfo.getUsedMemory(); setTotalMemory(amountOfTotalMemory); setUsedMemory(amountOfUsedMemory); + setMemoizeStats(Memoize.stopMonitoring()); }, []); const onToggleProfiling = useCallback(() => { const shouldProfiling = !isProfilingInProgress; if (shouldProfiling) { + Memoize.startMonitoring(); startProfiling(); } else { stop(); @@ -89,8 +93,9 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = platform: getPlatform(), totalMemory: formatBytes(totalMemory, 2), usedMemory: formatBytes(usedMemory, 2), + memoizeStats, }), - [totalMemory, usedMemory], + [memoizeStats, totalMemory, usedMemory], ); useEffect(() => { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 6b47364345c7..ae38baf8f264 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,6 +1,5 @@ import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; -import lodashMemoize from 'lodash/memoize'; import React, {useCallback, useEffect, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; @@ -13,6 +12,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import * as SearchActions from '@libs/actions/Search'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; +import memoize from '@libs/memoize'; import * as ReportUtils from '@libs/ReportUtils'; import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; @@ -74,15 +74,14 @@ function Search({query, policyIDs, sortBy, sortOrder, isMobileSelectionModeActiv [isLargeScreenWidth], ); - const getItemHeightMemoized = lodashMemoize( - (item: TransactionListItemType | ReportListItemType) => getItemHeight(item), - (item) => { + const getItemHeightMemoized = memoize((item: TransactionListItemType | ReportListItemType) => getItemHeight(item), { + transformKey: ([item]) => { // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ // in addition the same items might be displayed as part of different Search screens ("Expenses", "All", "Finished") const screenSizeHash = isLargeScreenWidth ? 'L' : 'N'; return `${hash}-${item.keyForList}-${screenSizeHash}`; }, - ); + }); // save last non-empty search results to avoid ugly flash of loading screen when hash changes and onyx returns empty data if (currentSearchResults?.data && currentSearchResults !== lastSearchResultsRef.current) { diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 64e3019cdf81..b96f6ac8cdb1 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -1,5 +1,4 @@ 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'; @@ -11,6 +10,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}; @@ -93,42 +93,45 @@ const getLocalizedEmojiName = (name: string, lang: OnyxEntry): string => /** * Get the unicode code of an emoji in base 16. */ -const getEmojiUnicode = memoize((input: string) => { - if (input.length === 0) { - return ''; - } +const getEmojiUnicode = memoize( + (input: string) => { + if (input.length === 0) { + return ''; + } - if (input.length === 1) { - return input - .charCodeAt(0) - .toString() - .split(' ') - .map((val) => parseInt(val, 10).toString(16)) - .join(' '); - } + if (input.length === 1) { + return input + .charCodeAt(0) + .toString() + .split(' ') + .map((val) => parseInt(val, 10).toString(16)) + .join(' '); + } - const pairs = []; - - // Some Emojis in UTF-16 are stored as a pair of 2 Unicode characters (e.g. Flags) - // The first char is generally between the range U+D800 to U+DBFF called High surrogate - // & the second char between the range U+DC00 to U+DFFF called low surrogate - // More info in the following links: - // 1. https://docs.microsoft.com/en-us/windows/win32/intl/surrogates-and-supplementary-characters - // 2. https://thekevinscott.com/emojis-in-javascript/ - for (let i = 0; i < input.length; i++) { - if (input.charCodeAt(i) >= 0xd800 && input.charCodeAt(i) <= 0xdbff) { - // high surrogate - if (input.charCodeAt(i + 1) >= 0xdc00 && input.charCodeAt(i + 1) <= 0xdfff) { - // low surrogate - pairs.push((input.charCodeAt(i) - 0xd800) * 0x400 + (input.charCodeAt(i + 1) - 0xdc00) + 0x10000); + const pairs = []; + + // Some Emojis in UTF-16 are stored as a pair of 2 Unicode characters (e.g. Flags) + // The first char is generally between the range U+D800 to U+DBFF called High surrogate + // & the second char between the range U+DC00 to U+DFFF called low surrogate + // More info in the following links: + // 1. https://docs.microsoft.com/en-us/windows/win32/intl/surrogates-and-supplementary-characters + // 2. https://thekevinscott.com/emojis-in-javascript/ + for (let i = 0; i < input.length; i++) { + if (input.charCodeAt(i) >= 0xd800 && input.charCodeAt(i) <= 0xdbff) { + // high surrogate + if (input.charCodeAt(i + 1) >= 0xdc00 && input.charCodeAt(i + 1) <= 0xdfff) { + // low surrogate + pairs.push((input.charCodeAt(i) - 0xd800) * 0x400 + (input.charCodeAt(i + 1) - 0xdc00) + 0x10000); + } + } else if (input.charCodeAt(i) < 0xd800 || input.charCodeAt(i) > 0xdfff) { + // modifiers and joiners + pairs.push(input.charCodeAt(i)); } - } else if (input.charCodeAt(i) < 0xd800 || input.charCodeAt(i) > 0xdfff) { - // modifiers and joiners - pairs.push(input.charCodeAt(i)); } - } - return pairs.map((val) => parseInt(String(val), 10).toString(16)).join(' '); -}); + return pairs.map((val) => parseInt(String(val), 10).toString(16)).join(' '); + }, + {monitoringName: 'getEmojiUnicode'}, +); /** * Function to remove Skin Tone and utf16 surrogates from Emoji diff --git a/src/libs/LocaleDigitUtils.ts b/src/libs/LocaleDigitUtils.ts index 156e58c59033..a024276819c1 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,28 +13,31 @@ const INDEX_DECIMAL = 10; const INDEX_MINUS_SIGN = 11; const INDEX_GROUP = 12; -const getLocaleDigits = _.memoize((locale: Locale): string[] => { - const localeDigits = [...STANDARD_DIGITS]; - for (let i = 0; i <= 9; i++) { - localeDigits[i] = NumberFormatUtils.format(locale, i); - } - NumberFormatUtils.formatToParts(locale, 1000000.5).forEach((part) => { - switch (part.type) { - case 'decimal': - localeDigits[INDEX_DECIMAL] = part.value; - break; - case 'minusSign': - localeDigits[INDEX_MINUS_SIGN] = part.value; - break; - case 'group': - localeDigits[INDEX_GROUP] = part.value; - break; - default: - break; +const getLocaleDigits = memoize( + (locale: Locale): string[] => { + const localeDigits = [...STANDARD_DIGITS]; + for (let i = 0; i <= 9; i++) { + localeDigits[i] = NumberFormatUtils.format(locale, i); } - }); - return localeDigits; -}); + NumberFormatUtils.formatToParts(locale, 1000000.5).forEach((part) => { + switch (part.type) { + case 'decimal': + localeDigits[INDEX_DECIMAL] = part.value; + break; + case 'minusSign': + localeDigits[INDEX_MINUS_SIGN] = part.value; + break; + case 'group': + localeDigits[INDEX_GROUP] = part.value; + break; + default: + break; + } + }); + return localeDigits; + }, + {monitoringName: 'getLocaleDigits'}, +); /** * Gets the locale digit corresponding to a standard digit. diff --git a/src/libs/NumberFormatUtils.ts b/src/libs/NumberFormatUtils/index.ts similarity index 54% rename from src/libs/NumberFormatUtils.ts rename to src/libs/NumberFormatUtils/index.ts index bf42e25cbb48..57ad6c63039b 100644 --- a/src/libs/NumberFormatUtils.ts +++ b/src/libs/NumberFormatUtils/index.ts @@ -1,12 +1,18 @@ import type {ValueOf} from 'type-fest'; +import memoize from '@libs/memoize'; import type CONST from '@src/CONST'; +import initPolyfill from './intlPolyfill'; + +initPolyfill(); + +const MemoizedNumberFormat = memoize(Intl.NumberFormat, {maxSize: 10}); function format(locale: ValueOf, number: number, options?: Intl.NumberFormatOptions): string { - return new Intl.NumberFormat(locale, options).format(number); + return new MemoizedNumberFormat(locale, options).format(number); } function formatToParts(locale: ValueOf, number: number, options?: Intl.NumberFormatOptions): Intl.NumberFormatPart[] { - return new Intl.NumberFormat(locale, options).formatToParts(number); + return new MemoizedNumberFormat(locale, options).formatToParts(number); } export {format, formatToParts}; diff --git a/src/libs/NumberFormatUtils/intlPolyfill.ios.ts b/src/libs/NumberFormatUtils/intlPolyfill.ios.ts new file mode 100644 index 000000000000..4745284c0b61 --- /dev/null +++ b/src/libs/NumberFormatUtils/intlPolyfill.ios.ts @@ -0,0 +1,10 @@ +import intlPolyfill from '@libs/IntlPolyfill'; + +// On iOS, polyfills from `additionalSetup` are applied after memoization, which results in incorrect cache entry of `Intl.NumberFormat` (e.g. lacking `formatToParts` method). +// To fix this, we need to apply the polyfill manually before memoization. +// For further information, see: https://github.com/Expensify/App/pull/43868#issuecomment-2217637217 +const initPolyfill = () => { + intlPolyfill(); +}; + +export default initPolyfill; diff --git a/src/libs/NumberFormatUtils/intlPolyfill.ts b/src/libs/NumberFormatUtils/intlPolyfill.ts new file mode 100644 index 000000000000..31fedd6a01b6 --- /dev/null +++ b/src/libs/NumberFormatUtils/intlPolyfill.ts @@ -0,0 +1,2 @@ +const initPolyfill = () => {}; +export default initPolyfill; diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index 2546225bd6ea..d6a65ee85aac 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -1,6 +1,6 @@ import debounce from 'lodash/debounce'; -import memoize from 'lodash/memoize'; import type {OnyxCollection} from 'react-native-onyx'; +import memoize from '@libs/memoize'; import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; @@ -34,7 +34,7 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, cur ); } -const memoizedGetUnreadReportsForUnreadIndicator = memoize(getUnreadReportsForUnreadIndicator); +const memoizedGetUnreadReportsForUnreadIndicator = memoize(getUnreadReportsForUnreadIndicator, {maxArgs: 1}); const triggerUnreadUpdate = debounce(() => { const currentReportID = navigationRef?.isReady?.() ? Navigation.getTopmostReportId() ?? '-1' : '-1'; diff --git a/src/libs/freezeScreenWithLazyLoading.tsx b/src/libs/freezeScreenWithLazyLoading.tsx index 01da7d7fda58..21a2681b08c1 100644 --- a/src/libs/freezeScreenWithLazyLoading.tsx +++ b/src/libs/freezeScreenWithLazyLoading.tsx @@ -1,5 +1,5 @@ -import memoize from 'lodash/memoize'; import React from 'react'; +import memoize from './memoize'; import FreezeWrapper from './Navigation/FreezeWrapper'; function FrozenScreen(WrappedComponent: React.ComponentType) { diff --git a/src/libs/memoize/cache/ArrayCache.ts b/src/libs/memoize/cache/ArrayCache.ts new file mode 100644 index 000000000000..058efefdb1aa --- /dev/null +++ b/src/libs/memoize/cache/ArrayCache.ts @@ -0,0 +1,85 @@ +import type {Cache, CacheConfig} from './types'; + +/** + * Builder of the cache using `Array` primitive under the hood. It is an LRU cache, where the most recently accessed elements are at the end of the array, and the least recently accessed elements are at the front. + * @param config - Cache configuration, check `CacheConfig` type for more details. + * @returns + */ +function ArrayCache(config: CacheConfig): Cache { + const cache: Array<[K, V]> = []; + + const {maxSize, keyComparator} = config; + + /** + * Returns the index of the key in the cache array. + * We search the array backwards because the most recently added entries are at the end, and our heuristic follows the principles of an LRU cache - that the most recently added entries are most likely to be used again. + */ + function getKeyIndex(key: K): number { + for (let i = cache.length - 1; i >= 0; i--) { + if (keyComparator(cache[i][0], key)) { + return i; + } + } + return -1; + } + + return { + get(key) { + const index = getKeyIndex(key); + + if (index === -1) { + return undefined; + } + + const [entry] = cache.splice(index, 1); + cache.push(entry); + return {value: entry[1]}; + }, + + set(key, value) { + const index = getKeyIndex(key); + + if (index !== -1) { + cache.splice(index, 1); + } + + cache.push([key, value]); + + if (cache.length > maxSize) { + cache.shift(); + } + }, + + getSet(key, valueProducer) { + const index = getKeyIndex(key); + + if (index !== -1) { + const [entry] = cache.splice(index, 1); + cache.push(entry); + return {value: entry[1]}; + } + + const value = valueProducer(); + + cache.push([key, value]); + + if (cache.length > maxSize) { + cache.shift(); + } + + return {value}; + }, + + snapshot: { + keys: () => cache.map((entry) => entry[0]), + values: () => cache.map((entry) => entry[1]), + entries: () => [...cache], + }, + + get size() { + return cache.length; + }, + }; +} + +export default ArrayCache; diff --git a/src/libs/memoize/cache/types.ts b/src/libs/memoize/cache/types.ts new file mode 100644 index 000000000000..fef88c7bc36b --- /dev/null +++ b/src/libs/memoize/cache/types.ts @@ -0,0 +1,26 @@ +type CacheConfig = { + maxSize: number; + keyComparator: (k1: K, k2: K) => boolean; +}; + +type BoxedValue = {value: V}; + +type Cache = { + get: (key: K) => BoxedValue | undefined; + set: (key: K, value: V) => void; + /** + * Get the value for the key if it exists, otherwise set the value to the result of the valueProducer and return it. + * @param key The key to get or set + * @param valueProducer The function to produce the value if the key does not exist + * @returns The value for the key + */ + getSet: (key: K, valueProducer: () => V) => BoxedValue; + snapshot: { + keys: () => K[]; + values: () => V[]; + entries: () => Array<[K, V]>; + }; + size: number; +}; + +export type {CacheConfig, Cache, BoxedValue}; diff --git a/src/libs/memoize/const.ts b/src/libs/memoize/const.ts new file mode 100644 index 000000000000..a9e255131db4 --- /dev/null +++ b/src/libs/memoize/const.ts @@ -0,0 +1,10 @@ +import type {Options} from './types'; + +const DEFAULT_OPTIONS = { + maxSize: Infinity, + equality: 'deep', + monitor: false, + cache: 'array', +} satisfies Options<() => unknown, number, unknown>; + +export default DEFAULT_OPTIONS; diff --git a/src/libs/memoize/index.ts b/src/libs/memoize/index.ts new file mode 100644 index 000000000000..4a14296061a2 --- /dev/null +++ b/src/libs/memoize/index.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import type {Constructor} from 'type-fest'; +import ArrayCache from './cache/ArrayCache'; +import {MemoizeStats} from './stats'; +import type {ClientOptions, MemoizedFn, MemoizeFnPredicate, Stats} from './types'; +import {getEqualityComparator, mergeOptions, truncateArgs} from './utils'; + +/** + * Global memoization class. Use it to orchestrate memoization (e.g. start/stop global monitoring). + */ +class Memoize { + static isMonitoringEnabled = false; + + private static memoizedList: Array<{id: string; memoized: Stats}> = []; + + static registerMemoized(id: string, memoized: Stats) { + this.memoizedList.push({id, memoized}); + } + + static startMonitoring() { + if (this.isMonitoringEnabled) { + return; + } + this.isMonitoringEnabled = true; + Memoize.memoizedList.forEach(({memoized}) => { + memoized.startMonitoring(); + }); + } + + static stopMonitoring() { + if (!this.isMonitoringEnabled) { + return; + } + this.isMonitoringEnabled = false; + return Memoize.memoizedList.map(({id, memoized}) => ({id, stats: memoized.stopMonitoring()})); + } +} + +/** + * Wraps a function with a memoization layer. Useful for caching expensive calculations. + * @param fn - Function to memoize + * @param opts - Options for the memoization layer, for more details see `ClientOptions` type. + * @returns Memoized function with a cache API attached to it. + */ +function memoize['length']>(fn: Fn, opts?: ClientOptions) { + const options = mergeOptions(opts); + + const cache = ArrayCache>({maxSize: options.maxSize, keyComparator: getEqualityComparator(options)}); + + const stats = new MemoizeStats(options.monitor || Memoize.isMonitoringEnabled); + + const memoized = function memoized(...args: Parameters): ReturnType { + const constructable = !!new.target; + + const truncatedArgs = truncateArgs(args, options.maxArgs); + + const key = options.transformKey ? options.transformKey(truncatedArgs) : (truncatedArgs as Key); + + const statsEntry = stats.createEntry(); + statsEntry.track('didHit', true); + + const retrievalTimeStart = performance.now(); + const cached = cache.getSet(key, () => { + const fnTimeStart = performance.now(); + // If the function is constructable, we need to call it with the `new` keyword + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const result = constructable ? new (fn as unknown as Constructor, Parameters>)(...args) : fn(...args); + statsEntry.trackTime('fnTime', fnTimeStart); + statsEntry.track('didHit', false); + + return result; + }); + // Subtract the time it took to run the function from the total retrieval time + statsEntry.trackTime('cacheRetrievalTime', retrievalTimeStart + (statsEntry.get('fnTime') ?? 0)); + + statsEntry.track('cacheSize', cache.size); + statsEntry.save(); + + return cached.value; + }; + + /** + * Cache API attached to the memoized function. Currently there is an issue with typing cache keys, but the functionality works as expected. + */ + memoized.cache = cache; + + memoized.startMonitoring = () => stats.startMonitoring(); + memoized.stopMonitoring = () => stats.stopMonitoring(); + + Memoize.registerMemoized(options.monitoringName ?? fn.name, memoized); + + return memoized as MemoizedFn; +} + +export default memoize; + +export {Memoize}; diff --git a/src/libs/memoize/stats.ts b/src/libs/memoize/stats.ts new file mode 100644 index 000000000000..043ecc92b216 --- /dev/null +++ b/src/libs/memoize/stats.ts @@ -0,0 +1,114 @@ +import Log from '@libs/Log'; + +type MemoizeStatsEntry = { + didHit: boolean; + cacheRetrievalTime: number; + fnTime?: number; + cacheSize: number; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isMemoizeStatsEntry(entry: any): entry is MemoizeStatsEntry { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return entry.didHit !== undefined && entry.cacheRetrievalTime !== undefined; +} + +class MemoizeStats { + private calls = 0; + + private hits = 0; + + private avgCacheRetrievalTime = 0; + + private avgFnTime = 0; + + private cacheSize = 0; + + isEnabled = false; + + constructor(enabled: boolean) { + this.isEnabled = enabled; + } + + // See https://en.wikipedia.org/wiki/Moving_average#Cumulative_average + private calculateCumulativeAvg(avg: number, length: number, value: number) { + const result = (avg * (length - 1) + value) / length; + // If the length is 0, we return the average. For example, when calculating average cache retrieval time, hits may be 0, and in that case we want to return the current average cache retrieval time + return Number.isFinite(result) ? result : avg; + } + + private cumulateEntry(entry: MemoizeStatsEntry) { + this.calls++; + this.hits += entry.didHit ? 1 : 0; + + this.cacheSize = entry.cacheSize; + + this.avgCacheRetrievalTime = this.calculateCumulativeAvg(this.avgCacheRetrievalTime, this.hits, entry.cacheRetrievalTime); + + if (entry.fnTime !== undefined) { + this.avgFnTime = this.calculateCumulativeAvg(this.avgFnTime, this.calls - this.hits, entry.fnTime); + } + } + + private saveEntry(entry: Partial) { + if (!this.isEnabled) { + return; + } + + if (!isMemoizeStatsEntry(entry)) { + Log.warn('MemoizeStats:saveEntry: Invalid entry', entry); + return; + } + + return this.cumulateEntry(entry); + } + + createEntry() { + // If monitoring is disabled, return a dummy object that does nothing + if (!this.isEnabled) { + return { + track: () => {}, + get: () => {}, + save: () => {}, + trackTime: () => {}, + }; + } + + const entry: Partial = {}; + + return { + track:

(cacheProp: P, value: MemoizeStatsEntry[P]) => { + entry[cacheProp] = value; + }, + trackTime:

>(cacheProp: P, startTime: number, endTime = performance.now()) => { + entry[cacheProp] = endTime - startTime; + }, + get:

(cacheProp: P) => entry[cacheProp], + save: () => this.saveEntry(entry), + }; + } + + startMonitoring() { + this.isEnabled = true; + this.calls = 0; + this.hits = 0; + this.avgCacheRetrievalTime = 0; + this.avgFnTime = 0; + this.cacheSize = 0; + } + + stopMonitoring() { + this.isEnabled = false; + + return { + calls: this.calls, + hits: this.hits, + avgCacheRetrievalTime: this.avgCacheRetrievalTime, + avgFnTime: this.avgFnTime, + cacheSize: this.cacheSize, + }; + } +} + +export type {MemoizeStatsEntry}; +export {MemoizeStats}; diff --git a/src/libs/memoize/types.ts b/src/libs/memoize/types.ts new file mode 100644 index 000000000000..c32bcd2bfd9b --- /dev/null +++ b/src/libs/memoize/types.ts @@ -0,0 +1,38 @@ +import type {TakeFirst} from '@src/types/utils/TupleOperations'; +import type {Cache} from './cache/types'; +import type {MemoizeStats} from './stats'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type MemoizeFnPredicate = (...args: any[]) => any; + +type KeyComparator = (k1: Key, k2: Key) => boolean; + +type InternalOptions = { + cache: 'array'; +}; + +type Options = { + maxSize: number; + equality: 'deep' | 'shallow' | KeyComparator; + monitor: boolean; + maxArgs?: MaxArgs; + monitoringName?: string; + /** + * Function to transform the arguments into a key, which is used to reference the result in the cache. + * When called with constructable (e.g. class, `new` keyword) functions, it won't get proper types for `truncatedArgs` + * Any viable fixes are welcome! + * @param truncatedArgs - Tuple of arguments passed to the memoized function (truncated to `maxArgs`). Does not work with constructable (see description). + * @returns - Key to use for caching + */ + transformKey?: (truncatedArgs: TakeFirst, MaxArgs>) => Key; +} & InternalOptions; + +type ClientOptions = Partial, keyof InternalOptions>>; + +type Stats = Pick; + +type MemoizedFn = Fn & { + cache: Cache>; +} & Stats; + +export type {Options, ClientOptions, MemoizeFnPredicate, Stats, KeyComparator, MemoizedFn}; diff --git a/src/libs/memoize/utils.ts b/src/libs/memoize/utils.ts new file mode 100644 index 000000000000..d11a55da7b04 --- /dev/null +++ b/src/libs/memoize/utils.ts @@ -0,0 +1,55 @@ +import {deepEqual, shallowEqual} from 'fast-equals'; +import type {TakeFirst} from '@src/types/utils/TupleOperations'; +import DEFAULT_OPTIONS from './const'; +import type {ClientOptions, KeyComparator, MemoizeFnPredicate, Options} from './types'; + +function getEqualityComparator(opts: Options): KeyComparator { + // Use the custom equality comparator if it is provided + if (typeof opts.equality === 'function') { + return opts.equality; + } + + if (opts.equality === 'shallow') { + return shallowEqual; + } + + return deepEqual; +} + +function mergeOptions(options?: ClientOptions): Options { + if (!options) { + return DEFAULT_OPTIONS; + } + return {...DEFAULT_OPTIONS, ...options}; +} +function truncateArgs(args: T, maxArgs?: MaxArgs): TakeFirst { + // Hot paths are declared explicitly to avoid the overhead of the slice method + + if (maxArgs === undefined) { + return args as unknown as TakeFirst; + } + + if (maxArgs >= args.length) { + return args as unknown as TakeFirst; + } + + if (maxArgs === 0) { + return []; + } + + if (maxArgs === 1) { + return [args[0]]; + } + + if (maxArgs === 2) { + return [args[0], args[1]] as unknown as TakeFirst; + } + + if (maxArgs === 3) { + return [args[0], args[1], args[2]] as unknown as TakeFirst; + } + + return args.slice(0, maxArgs) as unknown as TakeFirst; +} + +export {mergeOptions, getEqualityComparator, truncateArgs}; diff --git a/src/types/utils/TupleOperations.ts b/src/types/utils/TupleOperations.ts new file mode 100644 index 000000000000..1fc755c7e603 --- /dev/null +++ b/src/types/utils/TupleOperations.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// Based on: https://stackoverflow.com/questions/67605122/obtain-a-slice-of-a-typescript-parameters-tuple +type TupleSplit = O['length'] extends N + ? [O, T] + : T extends readonly [infer F, ...infer R] + ? TupleSplit + : [O, T]; + +type TakeFirst = TupleSplit[0]; + +export type {TupleSplit, TakeFirst}; diff --git a/tests/unit/memoizeTest.ts b/tests/unit/memoizeTest.ts new file mode 100644 index 000000000000..b3d3b5306a7e --- /dev/null +++ b/tests/unit/memoizeTest.ts @@ -0,0 +1,87 @@ +import memoize from '../../src/libs/memoize'; + +describe('memoize test', () => { + it('should return the memoized result', () => { + const add = (a: number, b: number) => a + b; + const memoizedAdd = memoize(add); + + const result1 = memoizedAdd(2, 3); + const result2 = memoizedAdd(2, 3); + + expect(result1).toBe(5); + expect(result2).toBe(5); + }); + + it('should not call original function if the same arguments are passed', () => { + const fn = jest.fn(); + const memoizedFn = memoize(fn); + + memoizedFn(2, 3); + memoizedFn(2, 3); + memoizedFn(2, 3); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should recompute the result if different arguments are passed', () => { + const fn = jest.fn(); + const memoizedFn = memoize(fn); + + memoizedFn(4, 20); + memoizedFn('r2d2'); + + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should handle deep comparison', () => { + const fn = jest.fn(); + const memoizedFn = memoize(fn, {equality: 'deep'}); + + memoizedFn({a: 1, b: 'test'}, {c: 3, d: 'test'}); + memoizedFn({a: 1, b: 'test'}, {c: 3, d: 'test'}); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should handle custom key comparator', () => { + const fn = jest.fn(); + const memoizedFn = memoize(fn, {equality: () => true}); + + memoizedFn(1, 2); + memoizedFn(1, 3); + memoizedFn(1, 4); + memoizedFn(1, 5); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should handle cache eviction', () => { + const fn = jest.fn(); + const memoizedFn = memoize(fn, {maxSize: 1}); + + memoizedFn(1, 2); + memoizedFn(1, 3); + memoizedFn(1, 2); + memoizedFn(1, 3); + + expect(fn).toHaveBeenCalledTimes(4); + }); + + it('should return cache snapshot', () => { + const fn = (a: number, b: number) => a + b; + const memoizedFn = memoize(fn); + + memoizedFn(1, 2); + memoizedFn(2, 3); + + expect(memoizedFn.cache.snapshot.keys()).toEqual([ + [1, 2], + [2, 3], + ]); + expect(memoizedFn.cache.snapshot.values()).toEqual([3, 5]); + expect(memoizedFn.cache.snapshot.entries()).toEqual([ + [[1, 2], 3], + [[2, 3], 5], + ]); + }); +});