diff --git a/ts/components/Input.tsx b/ts/components/Input.tsx index fc1d0eb83..e9bdf5e98 100644 --- a/ts/components/Input.tsx +++ b/ts/components/Input.tsx @@ -15,7 +15,7 @@ import classNames from 'classnames'; import * as grapheme from '../util/grapheme'; import { LocalizerType } from '../types/Util'; import { getClassNamesFor } from '../util/getClassNamesFor'; -import { multiRef } from '../util/multiRef'; +import { refMerger } from '../util/refMerger'; export type PropsType = { countLength?: (value: string) => number; @@ -173,7 +173,7 @@ export const Input = forwardRef< onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder, - ref: multiRef( + ref: refMerger( ref, innerRef ), diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx index 2261f5f54..df16c4487 100644 --- a/ts/components/Modal.tsx +++ b/ts/components/Modal.tsx @@ -12,6 +12,7 @@ import { Theme } from '../util/theme'; import { getClassNamesFor } from '../util/getClassNamesFor'; import { useAnimated } from '../hooks/useAnimated'; import { useHasWrapped } from '../hooks/useHasWrapped'; +import { useRefMerger } from '../hooks/useRefMerger'; type PropsType = { children: ReactNode; @@ -85,6 +86,8 @@ export function ModalWindow({ }: Readonly): JSX.Element { const modalRef = useRef(null); + const refMerger = useRefMerger(); + const bodyRef = useRef(null); const [scrolled, setScrolled] = useState(false); const [hasOverflow, setHasOverflow] = useState(false); @@ -155,10 +158,7 @@ export function ModalWindow({ const scrollTop = bodyRef.current?.scrollTop || 0; setScrolled(scrollTop > 2); }} - ref={bodyEl => { - measureRef(bodyEl); - bodyRef.current = bodyEl; - }} + ref={refMerger(measureRef, bodyRef)} > {children} diff --git a/ts/components/Tooltip.tsx b/ts/components/Tooltip.tsx index 73d08f03f..7a610964f 100644 --- a/ts/components/Tooltip.tsx +++ b/ts/components/Tooltip.tsx @@ -6,7 +6,7 @@ import classNames from 'classnames'; import { noop } from 'lodash'; import { Manager, Reference, Popper } from 'react-popper'; import { Theme, themeClassName } from '../util/theme'; -import { multiRef } from '../util/multiRef'; +import { refMerger } from '../util/refMerger'; import { offsetDistanceModifier } from '../util/popperUtil'; type EventWrapperPropsType = { @@ -52,7 +52,7 @@ const TooltipEventWrapper = React.forwardRef< (ref, wrapperRef)} + ref={refMerger(ref, wrapperRef)} > {children} diff --git a/ts/components/_util.ts b/ts/components/_util.ts index bdedc2354..fa18650d2 100644 --- a/ts/components/_util.ts +++ b/ts/components/_util.ts @@ -1,30 +1,6 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { MutableRefObject, Ref } from 'react'; -import { isFunction } from 'lodash'; -import memoizee from 'memoizee'; - export function cleanId(id: string): string { return id.replace(/[^\u0020-\u007e\u00a0-\u00ff]/g, '_'); } - -// Memoizee makes this difficult. -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export const createRefMerger = () => - memoizee( - (...refs: Array>) => { - return (t: T) => { - refs.forEach(r => { - if (isFunction(r)) { - r(t); - } else if (r) { - // Using a MutableRefObject as intended - // eslint-disable-next-line no-param-reassign - (r as MutableRefObject).current = t; - } - }); - }; - }, - { length: false, max: 1 } - ); diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 9515dee4e..2bd33cb1c 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -62,7 +62,7 @@ import { ConversationColorType, CustomColorType, } from '../../types/Colors'; -import { createRefMerger } from '../_util'; +import { createRefMerger } from '../../util/refMerger'; import { emojiToData } from '../emoji/lib'; import type { SmartReactionPicker } from '../../state/smart/ReactionPicker'; import { getCustomColorStyle } from '../../util/getCustomColorStyle'; diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx index a73bd6bc6..3f1deb30e 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -13,7 +13,7 @@ import Measure, { MeasuredComponentProps } from 'react-measure'; import { LocalizerType } from '../../../../types/Util'; import { assert } from '../../../../util/assert'; import { getOwn } from '../../../../util/getOwn'; -import { multiRef } from '../../../../util/multiRef'; +import { refMerger } from '../../../../util/refMerger'; import { useRestoreFocus } from '../../../../hooks/useRestoreFocus'; import { missingCaseError } from '../../../../util/missingCaseError'; import { filterAndSortConversationsByTitle } from '../../../../util/filterAndSortConversations'; @@ -146,7 +146,7 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ confirmAdds(); } }} - ref={multiRef(inputRef, focusRef)} + ref={refMerger(inputRef, focusRef)} value={searchTerm} /> {Boolean(selectedContacts.length) && ( diff --git a/ts/hooks/useRefMerger.ts b/ts/hooks/useRefMerger.ts new file mode 100644 index 000000000..3b559fb53 --- /dev/null +++ b/ts/hooks/useRefMerger.ts @@ -0,0 +1,8 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useMemo } from 'react'; +import { createRefMerger } from '../util/refMerger'; + +export const useRefMerger = (): ReturnType => + useMemo(createRefMerger, []); diff --git a/ts/util/multiRef.ts b/ts/util/refMerger.ts similarity index 61% rename from ts/util/multiRef.ts rename to ts/util/refMerger.ts index 76768d021..9802be051 100644 --- a/ts/util/multiRef.ts +++ b/ts/util/refMerger.ts @@ -1,9 +1,18 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { Ref } from 'react'; +import type { MutableRefObject, Ref } from 'react'; +import memoizee from 'memoizee'; -export function multiRef(...refs: Array>): (topLevelRef: T) => void { +/** + * Merges multiple refs. + * + * Returns a new function each time, which may cause unnecessary re-renders. Try + * `createRefMerger` if you want to cache the function. + */ +export function refMerger( + ...refs: Array> +): (topLevelRef: T) => void { return (el: T) => { refs.forEach(ref => { // This is a simplified version of [what React does][0] to set a ref. @@ -15,8 +24,12 @@ export function multiRef(...refs: Array>): (topLevelRef: T) => void { // not be `readonly`. That's why we do this cast. See [the React source][1]. // [1]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/shared/ReactTypes.js#L78-L80 // eslint-disable-next-line no-param-reassign - (ref as React.MutableRefObject).current = el; + (ref as MutableRefObject).current = el; } }); }; } + +export function createRefMerger(): typeof refMerger { + return memoizee(refMerger, { length: false, max: 1 }); +}