Skip to content

Commit

Permalink
Merge pull request #43868 from kacper-mikolajczak/feat/memoization-to…
Browse files Browse the repository at this point in the history
…ol-poc2

General purpose memoization tool
  • Loading branch information
roryabraham authored Jul 23, 2024
2 parents bf770dd + 768bf3a commit a78d124
Show file tree
Hide file tree
Showing 20 changed files with 629 additions and 66 deletions.
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',
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.

7 changes: 6 additions & 1 deletion src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ReturnType<typeof Memoize.stopMonitoring>>();
const {translate} = useLocalize();

// eslint-disable-next-line @lwc/lwc/no-async-await
Expand All @@ -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();
Expand All @@ -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(() => {
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';
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 @@ -93,42 +93,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) {
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> {
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;
Loading

0 comments on commit a78d124

Please sign in to comment.