diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 47ec8e00a6f..489a19c3db7 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -12,3 +12,4 @@ export {useRenderForcingRef} from './useRenderForcingRef' export {useProvidedStateOrCreate} from './useProvidedStateOrCreate' export {useMenuInitialFocus} from './useMenuInitialFocus' export {useMnemonics} from './useMnemonics' +export {useCombinedRefs} from './useCombinedRefs' diff --git a/src/hooks/useCombinedRefs.ts b/src/hooks/useCombinedRefs.ts index 8eb341ea1a6..ab221ac7782 100644 --- a/src/hooks/useCombinedRefs.ts +++ b/src/hooks/useCombinedRefs.ts @@ -1,5 +1,34 @@ -import {ForwardedRef, useRef} from 'react' -import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' +import {ForwardedRef, useCallback, useEffect, useMemo, useRef} from 'react' + +/** + * Ref that can perform a side effect on change while also providing access to the current + * value through `.current`. + */ +const useObservableRef = (initialValue: T, onChange: (value: T) => void) => { + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + + return useMemo( + () => + new Proxy>( + {current: initialValue}, + { + set(target, prop, value) { + if (prop === 'current') { + target[prop] = value + onChangeRef.current(value) + return true + } + return false + } + } + ), + // This dependency array MUST be empty because ref objects are guarunteed to be constant + // and we don't need to track initialValue + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) +} /** * Creates a ref by combining multiple constituent refs. The ref returned by this hook @@ -10,31 +39,24 @@ import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' * @param refs */ export function useCombinedRefs(...refs: (ForwardedRef | null | undefined)[]) { - const combinedRef = useRef(null) - - useLayoutEffect(() => { - function setRefs(current: T | null = null) { + const syncRefs = useCallback( + (value: T | null) => { for (const ref of refs) { - if (!ref) { - return - } + if (!ref) continue + if (typeof ref === 'function') { - ref(current) + ref(value ?? null) } else { - ref.current = current + ref.current = value ?? null } } - } + }, + [refs] + ) - setRefs(combinedRef.current) + const targetRef = useObservableRef(null, syncRefs) - return () => { - // ensure the refs get updated on unmount - // eslint-disable-next-line react-hooks/exhaustive-deps - setRefs(combinedRef.current) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [...refs, combinedRef.current]) + useEffect(() => syncRefs(targetRef.current), [syncRefs, targetRef]) - return combinedRef + return targetRef }