Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

General purpose memoization tool #43868

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
2e34ee7
install fast-equals
kacper-mikolajczak Jun 16, 2024
0cbbed0
add array cache POC
kacper-mikolajczak Jun 17, 2024
4af2f05
fix comments and types
kacper-mikolajczak Jun 17, 2024
fbc0d00
remove unnecessary variable
kacper-mikolajczak Jun 17, 2024
af24aa6
add MemoizedFn type
kacper-mikolajczak Jun 17, 2024
f049597
fix array cache
kacper-mikolajczak Jun 17, 2024
6a699b8
fix get method and remove has
kacper-mikolajczak Jun 17, 2024
6e4ca00
fix delete method
kacper-mikolajczak Jun 17, 2024
a8a8849
add tests
kacper-mikolajczak Jun 17, 2024
932d26c
fix types
kacper-mikolajczak Jun 18, 2024
6fb59ab
make monitor false on default
kacper-mikolajczak Jun 19, 2024
3212301
add cache statistics module
kacper-mikolajczak Jun 19, 2024
3dfe8d5
fix cache entries
kacper-mikolajczak Jun 19, 2024
8e26459
add entry identity check
kacper-mikolajczak Jun 19, 2024
a368c07
rename registerStat to track
kacper-mikolajczak Jun 19, 2024
3ea3af0
add cache size to cache and stats
kacper-mikolajczak Jun 19, 2024
5695b2d
Global stats api v1
kacper-mikolajczak Jun 20, 2024
7b59ac5
fix cache size & entries api
kacper-mikolajczak Jun 20, 2024
19e59b1
fix function id for stats
kacper-mikolajczak Jun 20, 2024
383488f
change default equality to deep
kacper-mikolajczak Jun 20, 2024
7858c9d
fix cumulative avg calcs
kacper-mikolajczak Jun 20, 2024
4cf90e6
add monitoring to profiling menu
kacper-mikolajczak Jun 20, 2024
35c842f
rename delete variable name
kacper-mikolajczak Jun 20, 2024
dc8ad8b
use memoize for NumberFormatUtils
kacper-mikolajczak Jun 20, 2024
20a9711
replace lodash memoize
kacper-mikolajczak Jun 20, 2024
1b1ff6f
add monitoringName for anonymous funcitons
kacper-mikolajczak Jun 20, 2024
85a0e91
fix tests
kacper-mikolajczak Jun 20, 2024
df7466a
Improve cache array builder description
kacper-mikolajczak Jun 21, 2024
7b03364
add getKeyIndex comment
kacper-mikolajczak Jun 21, 2024
a815e24
arraCacheBuilder get early return
kacper-mikolajczak Jun 21, 2024
bc9b9ce
remove unused APIs
kacper-mikolajczak Jun 21, 2024
67890fc
use default export for const
kacper-mikolajczak Jun 21, 2024
fa904c0
rename monitoringEnable
kacper-mikolajczak Jun 21, 2024
1b0c9e6
Fix typo in memoize desc
kacper-mikolajczak Jun 21, 2024
e90d63e
MemoizeStats enabled -> isEnabled
kacper-mikolajczak Jun 21, 2024
c6910ac
add explanation to cumulativeAvg
kacper-mikolajczak Jun 21, 2024
dc64819
take out the isMemoizeStatsEntry identity function
kacper-mikolajczak Jun 21, 2024
1f3bd24
fix saveEntry
kacper-mikolajczak Jun 21, 2024
9390547
move eslint-disable
kacper-mikolajczak Jun 21, 2024
65139da
fix tests
kacper-mikolajczak Jun 21, 2024
7da3423
merge main
kacper-mikolajczak Jun 21, 2024
0a06149
fix package-lock.json
kacper-mikolajczak Jun 21, 2024
9226c50
add eslint recommendation
kacper-mikolajczak Jul 1, 2024
607db57
add getSet method to cache
kacper-mikolajczak Jul 1, 2024
4ae9a9c
add trackTime method to statsEntry
kacper-mikolajczak Jul 1, 2024
0228144
fix typo
kacper-mikolajczak Jul 1, 2024
e95e208
refactor keyComparator
kacper-mikolajczak Jul 1, 2024
5e13d40
add maxArgs option
kacper-mikolajczak Jul 7, 2024
d8e82a0
fix lodash memoize instance
kacper-mikolajczak Jul 7, 2024
de4a8aa
Merge branch 'main' into feat/memoization-tool-poc2
kacper-mikolajczak Jul 7, 2024
acbe201
supress TS errors
kacper-mikolajczak Jul 7, 2024
8055ec0
fix constructable
kacper-mikolajczak Jul 8, 2024
dc9850e
add description for getSet method
kacper-mikolajczak Jul 9, 2024
79d19c0
lodash memoize named export restriction
kacper-mikolajczak Jul 9, 2024
4ecb327
add fast-equals license
kacper-mikolajczak Jul 9, 2024
ff557b7
merge main
kacper-mikolajczak Jul 9, 2024
baf7a5f
fix iOS Intl.NumberFormat polyfill
kacper-mikolajczak Jul 9, 2024
50b98d8
NumberFormatUtils ios platform variant
kacper-mikolajczak Jul 10, 2024
7f8aea1
restructure ios variant
kacper-mikolajczak Jul 10, 2024
f1aa456
Merge branch 'main' into feat/memoization-tool-poc2
kacper-mikolajczak Jul 12, 2024
62b4684
fix package-lock typo
kacper-mikolajczak Jul 15, 2024
5c173c3
refactor ArrayCache
kacper-mikolajczak Jul 15, 2024
f7b4d9c
add lines between methods
kacper-mikolajczak Jul 15, 2024
462d70e
remove avgKeyLength stat
kacper-mikolajczak Jul 19, 2024
b1e4b02
add transformKey
kacper-mikolajczak Jul 22, 2024
c218004
make Key generic required
kacper-mikolajczak Jul 22, 2024
79328ce
fix Search memoization
kacper-mikolajczak Jul 22, 2024
ec6251e
Merge branch 'main' into feat/memoization-tool-poc2
kacper-mikolajczak Jul 22, 2024
f710dc3
add comment explainers on existing issues
kacper-mikolajczak Jul 22, 2024
cc8b268
replace lodash in freezeScreenWithLazyLoading
kacper-mikolajczak Jul 23, 2024
768bf3a
ArrayCache tweaks
kacper-mikolajczak Jul 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ const restrictedImportPaths = [
"For 'ExpensiMark', please use '@libs/Parser' instead.",
].join('\n'),
},
{
name: 'lodash/memoize',
kacper-mikolajczak marked this conversation as resolved.
Show resolved Hide resolved
message: "Please use '@src/libs/memoize' instead.",
},
{
name: 'lodash',
importNames: ['memoize'],
message: "Please use '@src/libs/memoize' instead.",
},
];

const restrictedImportPatterns = [
Expand Down
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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';
Expand Down Expand Up @@ -53,6 +54,7 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, pathToBeUsed, dis
const [sharePath, setSharePath] = useState('');
const [totalMemory, setTotalMemory] = useState(0);
const [usedMemory, setUsedMemory] = useState(0);
const [memoizeStats, setMemoizeStats] = useState<ReturnType<typeof Memoize.stopMonitoring>>();
const {translate} = useLocalize();

// eslint-disable-next-line @lwc/lwc/no-async-await
Expand All @@ -64,11 +66,13 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, pathToBeUsed, dis
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();
Expand All @@ -87,8 +91,9 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, pathToBeUsed, dis
platform: getPlatform(),
totalMemory: formatBytes(totalMemory, 2),
usedMemory: formatBytes(usedMemory, 2),
memoizeStats,
}),
[totalMemory, usedMemory],
[memoizeStats, totalMemory, usedMemory],
);

useEffect(() => {
Expand Down
9 changes: 4 additions & 5 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
69 changes: 36 additions & 33 deletions src/libs/EmojiUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {Str} from 'expensify-common';
import memoize from 'lodash/memoize';
kacper-mikolajczak marked this conversation as resolved.
Show resolved Hide resolved
import Onyx from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import * as Emojis from '@assets/emojis';
Expand All @@ -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};
Expand Down Expand Up @@ -73,42 +73,45 @@ const getLocalizedEmojiName = (name: string, lang: OnyxEntry<Locale>): 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
Expand Down
47 changes: 25 additions & 22 deletions src/libs/LocaleDigitUtils.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CONST.LOCALES>;
Expand All @@ -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) {
kacper-mikolajczak marked this conversation as resolved.
Show resolved Hide resolved
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof CONST.LOCALES>, 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<typeof CONST.LOCALES>, 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};
10 changes: 10 additions & 0 deletions src/libs/NumberFormatUtils/intlPolyfill.ios.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/libs/NumberFormatUtils/intlPolyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const initPolyfill = () => {};
export default initPolyfill;
4 changes: 2 additions & 2 deletions src/libs/UnreadIndicatorUpdater/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,7 +34,7 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection<Report>, cur
);
}

const memoizedGetUnreadReportsForUnreadIndicator = memoize(getUnreadReportsForUnreadIndicator);
const memoizedGetUnreadReportsForUnreadIndicator = memoize(getUnreadReportsForUnreadIndicator, {maxArgs: 1});

const triggerUnreadUpdate = debounce(() => {
const currentReportID = navigationRef?.isReady?.() ? Navigation.getTopmostReportId() ?? '-1' : '-1';
Expand Down
2 changes: 1 addition & 1 deletion src/libs/freezeScreenWithLazyLoading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import memoize from 'lodash/memoize';
import React from 'react';
import memoize from './memoize';
import FreezeWrapper from './Navigation/FreezeWrapper';

function FrozenScreen<TProps extends React.JSX.IntrinsicAttributes>(WrappedComponent: React.ComponentType<TProps>) {
Expand Down
85 changes: 85 additions & 0 deletions src/libs/memoize/cache/ArrayCache.ts
Original file line number Diff line number Diff line change
@@ -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<K, V>(config: CacheConfig<K>): Cache<K, V> {
kacper-mikolajczak marked this conversation as resolved.
Show resolved Hide resolved
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) {
kacper-mikolajczak marked this conversation as resolved.
Show resolved Hide resolved
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;
Loading
Loading