From 49c07947895788b4ac794941ff4eec40d0597451 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 10 Dec 2021 18:33:20 +0100 Subject: [PATCH] [Perf] Composer: Avoid duplicated calculations for various instances of `useEmojiTooltip` --- src/@types/global.d.ts | 4 +- .../middle/composer/hooks/useEmojiTooltip.ts | 161 ++++++++++-------- src/util/memoized.ts | 19 +++ src/util/schedulers.ts | 5 - 4 files changed, 113 insertions(+), 76 deletions(-) create mode 100644 src/util/memoized.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 5ac7696b3..da96dcf95 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -22,8 +22,8 @@ declare namespace React { type AnyLiteral = Record; type AnyClass = new (...args: any[]) => any; -type AnyFunction = (...args: any) => any; -type AnyToVoidFunction = (...args: any) => void; +type AnyFunction = (...args: any[]) => any; +type AnyToVoidFunction = (...args: any[]) => void; type NoneToVoidFunction = () => void; type EmojiCategory = { diff --git a/src/components/middle/composer/hooks/useEmojiTooltip.ts b/src/components/middle/composer/hooks/useEmojiTooltip.ts index 7070a931d..31e36fecc 100644 --- a/src/components/middle/composer/hooks/useEmojiTooltip.ts +++ b/src/components/middle/composer/hooks/useEmojiTooltip.ts @@ -1,5 +1,5 @@ import { - useCallback, useEffect, useMemo, useState, + useCallback, useEffect, useState, } from '../../../../lib/teact/teact'; import { EDITABLE_INPUT_ID } from '../../../../config'; @@ -12,8 +12,16 @@ import focusEditableElement from '../../../../util/focusEditableElement'; import { buildCollectionByKey, flatten, mapValues, pickTruthy, unique, } from '../../../../util/iteratees'; +import memoized from '../../../../util/memoized'; import useFlag from '../../../../hooks/useFlag'; +interface Library { + keywords: string[]; + byKeyword: Record; + names: string[]; + byName: Record; +} + let emojiDataPromise: Promise; let emojiRawData: EmojiRawData; let emojiData: EmojiData; @@ -22,6 +30,10 @@ let RE_EMOJI_SEARCH: RegExp; const EMOJIS_LIMIT = 36; const FILTER_MIN_LENGTH = 2; +const prepareRecentEmojisMemo = memoized(prepareRecentEmojis); +const prepareLibraryMemo = memoized(prepareLibrary); +const searchInLibraryMemo = memoized(searchInLibrary); + try { RE_EMOJI_SEARCH = new RegExp('(^|\\s):[-+_:\\p{L}\\p{N}]*$', 'gui'); } catch (e) { @@ -40,27 +52,10 @@ export default function useEmojiTooltip( isDisabled = false, ) { const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); - const [byId, setById] = useState | undefined>(); - const [keywords, setKeywords] = useState(); - const [byKeyword, setByKeyword] = useState>({}); - const [names, setNames] = useState(); - const [byName, setByName] = useState>({}); const [shouldForceInsertEmoji, setShouldForceInsertEmoji] = useState(false); - const [filteredEmojis, setFilteredEmojis] = useState(MEMO_EMPTY_ARRAY); - const recentEmojis = useMemo( - () => { - if (!byId || !recentEmojiIds.length) { - return []; - } - - return Object.values(pickTruthy(byId, recentEmojiIds)); - }, - [byId, recentEmojiIds], - ); - // Initialize data on first render. useEffect(() => { if (isDisabled) return; @@ -77,44 +72,7 @@ export default function useEmojiTooltip( }, [isDisabled]); useEffect(() => { - if (!byId || isDisabled) { - return; - } - - const emojis = Object.values(byId); - - const byNative = buildCollectionByKey(emojis, 'native'); - const baseEmojisByKeyword = baseEmojiKeywords - ? mapValues(baseEmojiKeywords, (natives) => { - return Object.values(pickTruthy(byNative, natives)); - }) - : {}; - const emojisByKeyword = emojiKeywords - ? mapValues(emojiKeywords, (natives) => { - return Object.values(pickTruthy(byNative, natives)); - }) - : {}; - - setByKeyword({ ...baseEmojisByKeyword, ...emojisByKeyword }); - setKeywords([...Object.keys(baseEmojisByKeyword), ...Object.keys(emojisByKeyword)]); - - const emojisByName = emojis.reduce((result, emoji) => { - emoji.names.forEach((name) => { - if (!result[name]) { - result[name] = []; - } - - result[name].push(emoji); - }); - - return result; - }, {} as Record); - setByName(emojisByName); - setNames(Object.keys(emojisByName)); - }, [isDisabled, baseEmojiKeywords, byId, emojiKeywords]); - - useEffect(() => { - if (!isAllowed || !html || !byId || !keywords || !keywords.length) { + if (!isAllowed || !html || !byId || isDisabled) { unmarkIsOpen(); return; } @@ -128,34 +86,28 @@ export default function useEmojiTooltip( const forceSend = code.length > 2 && code.endsWith(':'); const filter = code.substr(1, forceSend ? code.length - 2 : undefined); - let matched: Emoji[] = []; + let matched: Emoji[] = MEMO_EMPTY_ARRAY; setShouldForceInsertEmoji(forceSend); if (!filter) { - matched = recentEmojis; + matched = prepareRecentEmojisMemo(byId, recentEmojiIds, EMOJIS_LIMIT); } else if (filter.length >= FILTER_MIN_LENGTH) { - const matchedKeywords = keywords.filter((keyword) => keyword.startsWith(filter)).sort(); - matched = matched.concat(flatten(Object.values(pickTruthy(byKeyword, matchedKeywords)))); - - // Also search by names, which is useful for non-English languages - const matchedNames = names.filter((name) => name.startsWith(filter)); - matched = matched.concat(flatten(Object.values(pickTruthy(byName, matchedNames)))); - - matched = unique(matched); + const library = prepareLibraryMemo(byId, baseEmojiKeywords, emojiKeywords); + matched = searchInLibraryMemo(library, filter, EMOJIS_LIMIT); } if (matched.length) { if (!forceSend) { markIsOpen(); } - setFilteredEmojis(matched.slice(0, EMOJIS_LIMIT)); + setFilteredEmojis(matched); } else { unmarkIsOpen(); } }, [ - byId, byKeyword, keywords, byName, names, html, isAllowed, markIsOpen, - recentEmojis, unmarkIsOpen, setShouldForceInsertEmoji, + byId, html, isAllowed, markIsOpen, recentEmojiIds, unmarkIsOpen, setShouldForceInsertEmoji, + isDisabled, baseEmojiKeywords, emojiKeywords, ]); const insertEmoji = useCallback((textEmoji: string, isForce?: boolean) => { @@ -201,3 +153,74 @@ async function ensureEmojiData() { return emojiDataPromise; } + +function prepareRecentEmojis(byId: Record, recentEmojiIds: string[], limit: number) { + if (!byId || !recentEmojiIds.length) { + return MEMO_EMPTY_ARRAY; + } + + return Object.values(pickTruthy(byId, recentEmojiIds)).slice(0, limit); +} + +function prepareLibrary( + byId: Record, + baseEmojiKeywords?: Record, + emojiKeywords?: Record, +): Library { + const emojis = Object.values(byId); + + const byNative = buildCollectionByKey(emojis, 'native'); + const baseEmojisByKeyword = baseEmojiKeywords + ? mapValues(baseEmojiKeywords, (natives) => { + return Object.values(pickTruthy(byNative, natives)); + }) + : {}; + const emojisByKeyword = emojiKeywords + ? mapValues(emojiKeywords, (natives) => { + return Object.values(pickTruthy(byNative, natives)); + }) + : {}; + + const byKeyword = { ...baseEmojisByKeyword, ...emojisByKeyword }; + const keywords = ([] as string[]).concat(Object.keys(baseEmojisByKeyword), Object.keys(emojisByKeyword)); + + const byName = emojis.reduce((result, emoji) => { + emoji.names.forEach((name) => { + if (!result[name]) { + result[name] = []; + } + + result[name].push(emoji); + }); + + return result; + }, {} as Record); + + const names = Object.keys(byName); + + return { + byKeyword, + keywords, + byName, + names, + }; +} + +function searchInLibrary(library: Library, filter: string, limit: number) { + const { + byKeyword, keywords, byName, names, + } = library; + + let matched: Emoji[] = MEMO_EMPTY_ARRAY; + + const matchedKeywords = keywords.filter((keyword) => keyword.startsWith(filter)).sort(); + matched = matched.concat(flatten(Object.values(pickTruthy(byKeyword!, matchedKeywords)))); + + // Also search by names, which is useful for non-English languages + const matchedNames = names.filter((name) => name.startsWith(filter)); + matched = matched.concat(flatten(Object.values(pickTruthy(byName, matchedNames)))); + + matched = unique(matched); + + return matched.slice(0, limit); +} diff --git a/src/util/memoized.ts b/src/util/memoized.ts new file mode 100644 index 000000000..db868ed4f --- /dev/null +++ b/src/util/memoized.ts @@ -0,0 +1,19 @@ +import { areSortedArraysEqual } from './iteratees'; + +const cache = new WeakMap(); + +export default function memoized(fn: T) { + return (...args: Parameters): ReturnType => { + const cached = cache.get(fn); + if (cached && areSortedArraysEqual(cached.lastArgs, args)) { + return cached.lastResult; + } + + const result = fn(...args); + cache.set(fn, { lastArgs: args, lastResult: result }); + return result; + }; +} diff --git a/src/util/schedulers.ts b/src/util/schedulers.ts index 6422ec2bc..7db98a9c9 100644 --- a/src/util/schedulers.ts +++ b/src/util/schedulers.ts @@ -16,14 +16,12 @@ export function debounce( clearTimeout(waitingTimeout); waitingTimeout = undefined; } else if (shouldRunFirst) { - // @ts-ignore fn(...args); } // eslint-disable-next-line no-restricted-globals waitingTimeout = self.setTimeout(() => { if (shouldRunLast) { - // @ts-ignore fn(...args); } @@ -48,7 +46,6 @@ export function throttle( if (!interval) { if (shouldRunFirst) { isPending = false; - // @ts-ignore fn(...args); } @@ -62,7 +59,6 @@ export function throttle( } isPending = false; - // @ts-ignore fn(...args); }, ms); } @@ -97,7 +93,6 @@ export function throttleWith(schedulerFn: Scheduler schedulerFn(() => { waiting = false; - // @ts-ignore fn(...args); }); }