diff --git a/docusaurus/pages/useCombobox_compiled.jsx b/docusaurus/pages/useCombobox_compiled.jsx new file mode 100644 index 000000000..57078b9ac --- /dev/null +++ b/docusaurus/pages/useCombobox_compiled.jsx @@ -0,0 +1,255 @@ +/* eslint-disable */ +// this file is a compiled version of `useCombobox.jsx`. +// It was created via [the React Compiler Playground](https://playground.react.dev/#N4Igzg9grgTgxgUxALhASwLYAcIwC4AEAVAQIZgEBKCpchAZjBBgQOQw12sA6Adn5hz4CwAlDAIAwswBGEOQA8CAXwKNmbAHSaA9Np1h4PXoNyFRcCABtcFVepat9UPGithjp4U4MALUhwAJppwYB58fAgKQoSBCPSkUFYMULx0aBC8BAAiTFiBEADuvNIYcooAFACUInwEBJa8YIQA2mi8WC4AkngIGGAANAQSeF0d3b39ALoEALxUnHia4ggAynikvRWWNjBgVXUNmc21WfUEaGAA8lgIvAOH9QDmCHgAKhBPT1YIAEIueEyAAU8oNHgQXngADKkGQIKwgiBYMFnZ6vACydygiORD1RENeY06eBxKPOBF8aCevisVN8vUCYziCjx5MhPT6pNZ5wkPzoCEZk259V5CDoHIw3NU8xWpXKEAUFWA4LQkzAyAu41GauFBEyRJcADVSFYoFJ-LwXhqlZricbTQgVDVZgA+U7kkWErUSsAVcHkna2TT0Ny9GAVVV9OYu-0eyMYTSAqFFBAwSTkBDVTTNAJ4MAAdVVvgjWvtZsTEGThVT6Yk1SquvJDdjB3xyilrfqHDwsCyfvxAB5AmgAG4NKzkMAAOVIGAQs24IEaG3aqcXMfx9QHE7hVljIrwAE8frNgMrNx76Jk8PmEHS8BrWHIrHEYKxGx7AzANaL+YKowA-MM8JigyEoEI+MgTnAADW777ioygIcA2iaJCMK7qS1RIReG4eucki+BAEASGQWQgXOvAPrGA46Du8J4R6Q6jox+EDu0xIIQex7zmeBBYKQgTDpaj4ACxYAorCIVxIioeyWpYVUOH4eSgSbKQAC0vTNGggQLkusjyAqGkcS4i4ITorFMTIAKZDJzQ8aeogCUJ7RPGJEkEAAHBJUnKMpKn1AEaCafRVj6YCXw-AQlFQOZF6qepWkIDpemLpYZRGQoWmfN8CAaTZeCAvwIAySh2iQh8UV-LZvCKQF+FWfhwCXDcdwEEBA4ugAZAAxF5ACsACcADMADctFuhqXV9YNQ0DRNlkNUxOiFcVTXnAOa12QlPJHiefEucJ7lsOJSg+ZJ0m7UFMAhRpYX6XAPwBMBfKuHZpXXQQakbMlqWPc9MAFbV8WBfUmSSLSsGntU0avaBEoVLwSRWEpCEbeSfUAIwAAx4wA7GNCG0dtvAbbRw4juTSSyRVGJYvV46TjOc76bF67Ia1tz8F9QGmdqfRgJoGCkFgFQRkKmrMs6br9mDm20jJ5IOQd57y-hR1uR5kkfvLMi0DBTxMKkgTSLsyBKyplLUrS1JgbwzJzLM8ztI7QGsL1MhxKJ9D0FJGom-Eq6BLrKn+Zb9QwQgh6ngABgAJC1kzKInrtRMosfLfL5VoYSkxYWr6vkvGoeBWnLIR+crA-Zp2muIErAarHBTFGAlL0HgJmTBpqcO+nselx6yho199QYypSd9FnKm0bS4-1FUnZgxqyNWFY0+0UkVkUyxhytjhIADAZvAhk8KAgIXOg6BlWBuJsGS8OiEBxBqi4mnun3KIf4BEYUYxhrwE0YAUAJHcAgZQQA) + +import {c as _c} from 'react-compiler-runtime' +import * as React from 'react' + +import {useCombobox} from '../../src' +import {colors} from '../utils' +import './shared.css' + +export default function DropdownCombobox() { + const $ = _c(44) + const [inputItems, setInputItems] = React.useState(colors) + let t0 + if ($[0] === Symbol.for('react.memo_cache_sentinel')) { + t0 = t1 => { + const {inputValue} = t1 + setInputItems( + colors.filter(item => + item.toLowerCase().startsWith(inputValue.toLowerCase()), + ), + ) + } + $[0] = t0 + } else { + t0 = $[0] + } + let t1 + if ($[1] !== inputItems) { + t1 = {items: inputItems, onInputValueChange: t0} + $[1] = inputItems + $[2] = t1 + } else { + t1 = $[2] + } + const { + isOpen, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + selectedItem, + selectItem, + } = useCombobox(t1) + + const t2 = selectedItem ? selectedItem : 'black' + let t3 + if ($[3] !== t2) { + t3 = {fontWeight: 'bolder', color: t2} + $[3] = t2 + $[4] = t3 + } else { + t3 = $[4] + } + let t4 + if ($[5] !== getLabelProps) { + t4 = getLabelProps() + $[5] = getLabelProps + $[6] = t4 + } else { + t4 = $[6] + } + let t5 + if ($[7] !== t3 || $[8] !== t4) { + t5 = ( + + ) + $[7] = t3 + $[8] = t4 + $[9] = t5 + } else { + t5 = $[9] + } + let t6 + if ($[10] === Symbol.for('react.memo_cache_sentinel')) { + t6 = {padding: '4px'} + $[10] = t6 + } else { + t6 = $[10] + } + let t7 + if ($[11] !== getInputProps) { + t7 = getInputProps() + $[11] = getInputProps + $[12] = t7 + } else { + t7 = $[12] + } + let t8 + if ($[13] !== t7) { + t8 = + $[13] = t7 + $[14] = t8 + } else { + t8 = $[14] + } + let t9 + if ($[15] === Symbol.for('react.memo_cache_sentinel')) { + t9 = {padding: '4px 8px'} + $[15] = t9 + } else { + t9 = $[15] + } + let t10 + if ($[16] !== getToggleButtonProps) { + t10 = getToggleButtonProps() + $[16] = getToggleButtonProps + $[17] = t10 + } else { + t10 = $[17] + } + let t11 + if ($[18] !== isOpen) { + t11 = isOpen ? <>↑ : <>↓ + $[18] = isOpen + $[19] = t11 + } else { + t11 = $[19] + } + let t12 + if ($[20] !== t10 || $[21] !== t11) { + t12 = ( + + ) + $[20] = t10 + $[21] = t11 + $[22] = t12 + } else { + t12 = $[22] + } + let t13 + if ($[23] === Symbol.for('react.memo_cache_sentinel')) { + t13 = {padding: '4px 8px'} + $[23] = t13 + } else { + t13 = $[23] + } + let t14 + if ($[24] !== selectItem) { + t14 = ( + + ) + $[24] = selectItem + $[25] = t14 + } else { + t14 = $[25] + } + let t15 + if ($[26] !== t12 || $[27] !== t14 || $[28] !== t8) { + t15 = ( +
+ {t8} + {t12} + {t14} +
+ ) + $[26] = t12 + $[27] = t14 + $[28] = t8 + $[29] = t15 + } else { + t15 = $[29] + } + let t16 + if ($[30] !== getMenuProps) { + t16 = getMenuProps() + $[30] = getMenuProps + $[31] = t16 + } else { + t16 = $[31] + } + let t17 + if ( + $[32] !== getItemProps || + $[33] !== highlightedIndex || + $[34] !== inputItems || + $[35] !== isOpen + ) { + t17 = isOpen + ? inputItems.map((item_0, index) => ( +
  • + {item_0} +
  • + )) + : null + $[32] = getItemProps + $[33] = highlightedIndex + $[34] = inputItems + $[35] = isOpen + $[36] = t17 + } else { + t17 = $[36] + } + let t18 + if ($[37] !== t16 || $[38] !== t17) { + t18 = ( + + ) + $[37] = t16 + $[38] = t17 + $[39] = t18 + } else { + t18 = $[39] + } + let t19 + if ($[40] !== t15 || $[41] !== t18 || $[42] !== t5) { + t19 = ( +
    + {t5} + {t15} + {t18} +
    + ) + $[40] = t15 + $[41] = t18 + $[42] = t5 + $[43] = t19 + } else { + t19 = $[43] + } + return t19 +} diff --git a/e2e/useCombobox.spec.ts b/e2e/useCombobox.spec.ts index 64bce1932..0400aa13e 100644 --- a/e2e/useCombobox.spec.ts +++ b/e2e/useCombobox.spec.ts @@ -12,4 +12,36 @@ test.describe('useCombobox', () => { await page.getByTestId('downshift-item-0').click() await expect(page.getByTestId('combobox-input')).toBeFocused() }) + + test('can select an item', async ({page}) => { + const input = page.getByTestId('combobox-input') + await input.pressSequentially('aq') + await expect(input).toHaveValue('aq') + await input.press('ArrowDown') + await input.press('Enter') + await expect(input).toHaveValue('Aqua') + }) +}) + +test.describe('useCombobox (Compiled)', () => { + test.beforeEach(async ({page}) => { + await page.goto('/useCombobox_compiled') + }) + + test('should keep focus on the input when selecting by click', async ({ + page, + }) => { + await page.getByTestId('combobox-toggle-button').click() + await page.getByTestId('downshift-item-0').click() + await expect(page.getByTestId('combobox-input')).toBeFocused() + }) + + test('can select an item', async ({page}) => { + const input = page.getByTestId('combobox-input') + await input.pressSequentially('aq') + await expect(input).toHaveValue('aq') + await input.press('ArrowDown') + await input.press('Enter') + await expect(input).toHaveValue('Aqua') + }) }) diff --git a/package.json b/package.json index 55f614893..2183e45ee 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "cross-env": "^10.1.0", "eslint": "^8.57.0", "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", "flow-bin": "^0.299.0", "flow-coverage-report": "^0.8.0", "get-pkg-repo": "5.0.0", @@ -131,6 +132,7 @@ "preact": "^10.28.2", "prism-react-renderer": "^2.4.1", "react": "^18.3.1", + "react-compiler-runtime": "^1.0.0", "react-dom": "^18.3.1", "react-is": "^18.3.1", "react-native": "^0.76.0", @@ -139,6 +141,9 @@ "start-server-and-test": "^2.1.3", "typescript": "^5.9.3" }, + "overrides": { + "eslint-plugin-react-hooks": "^7.1.1" + }, "eslintConfig": { "parserOptions": { "ecmaVersion": 2023, @@ -172,6 +177,7 @@ }, "extends": "./node_modules/kcd-scripts/eslint.js", "rules": { + "react-hooks/refs": "error", "react/jsx-indent": "off", "react/prop-types": "off", "max-lines-per-function": "off", diff --git a/src/hooks/useCombobox/index.ts b/src/hooks/useCombobox/index.ts index 056d4266d..a61461463 100644 --- a/src/hooks/useCombobox/index.ts +++ b/src/hooks/useCombobox/index.ts @@ -1,7 +1,6 @@ import React, {useRef, useEffect, useCallback, useMemo} from 'react' import {isPreact, isReactNative, isReactNativeWeb} from '../../is.macro' import { - useLatestRef, validatePropTypes, callAllEventHandlers, handleRefs, @@ -47,7 +46,13 @@ function useCombobox( ...dropdownDefaultProps, ...userProps, } - const {items, scrollIntoView, environment, getA11yStatusMessage} = props + const { + items, + isItemDisabled, + scrollIntoView, + environment, + getA11yStatusMessage, + } = props // Initial state depending on controlled props. const [state, dispatch] = useControlledReducer( downshiftUseComboboxReducer, @@ -68,8 +73,15 @@ function useCombobox( const elementIds = useElementIds(props) // used to keep track of how many items we had on previous cycle. const previousResultCountRef = useRef() - // utility callback to get item element. - const latest = useLatestRef({state, props}) + /** + * Ref to read `state` in handlers to preserve referential identity. + * Only to be used in handlers and effects. + * **never access this in getters** + */ + const stateRef = useRef(state) + useEffect(() => { + stateRef.current = state + }, [state]) // Effects. // Adds an a11y aria live status message if getA11yStatusMessage is passed. @@ -84,8 +96,8 @@ function useCombobox( scrollIntoView, highlightedIndex, isOpen, - menuRef.current, - itemsRef.current, + menuRef, + itemsRef, elementIds.getItemId, ) useControlPropsValidator({ @@ -114,13 +126,13 @@ function useCombobox( const handleBlurInTracker = useCallback( function handleBlur() { - if (latest.current.state.isOpen) { + if (stateRef.current.isOpen) { dispatch({ type: stateChangeTypes.InputBlur, }) } }, - [dispatch, latest], + [dispatch], ) const downshiftRefs = useMemo(() => [menuRef, toggleButtonRef, inputRef], []) const mouseAndTouchTrackers = useMouseAndTouchTracker( @@ -167,7 +179,7 @@ function useCombobox( }) }, Home(event: KeyboardEvent) { - if (!latest.current.state.isOpen) { + if (!stateRef.current.isOpen) { return } @@ -177,7 +189,7 @@ function useCombobox( }) }, End(event: KeyboardEvent) { - if (!latest.current.state.isOpen) { + if (!stateRef.current.isOpen) { return } @@ -187,7 +199,7 @@ function useCombobox( }) }, Escape(event: KeyboardEvent) { - const latestState = latest.current.state + const latestState = stateRef.current if ( latestState.isOpen || latestState.inputValue || @@ -202,7 +214,7 @@ function useCombobox( } }, Enter(event: KeyboardEvent) { - const latestState = latest.current.state + const latestState = stateRef.current // if closed or no highlighted index, do nothing. if ( !latestState.isOpen || @@ -217,7 +229,7 @@ function useCombobox( }) }, PageUp(event: KeyboardEvent) { - if (latest.current.state.isOpen) { + if (stateRef.current.isOpen) { event.preventDefault() dispatch({ @@ -226,7 +238,7 @@ function useCombobox( } }, PageDown(event: KeyboardEvent) { - if (latest.current.state.isOpen) { + if (stateRef.current.isOpen) { event.preventDefault() dispatch({ @@ -235,7 +247,7 @@ function useCombobox( } }, }), - [dispatch, latest], + [dispatch], ) // Getter props. @@ -300,14 +312,13 @@ function useCombobox( ) } - const {props: latestProps, state: latestState} = latest.current const [item, index] = getItemAndIndex( itemProp, indexProp, - latestProps.items, + items, 'Pass either item or index to getItemProps!', ) - const disabled = latestProps.isItemDisabled(item, index) + const disabled = isItemDisabled(item, index) const onSelectKey = isReactNative || isReactNativeWeb ? /* istanbul ignore next (react-native) */ 'onPress' @@ -318,8 +329,8 @@ function useCombobox( const itemHandleMouseMove = () => { if ( - mouseAndTouchTrackers.isTouchEnd || - index === latestState.highlightedIndex + mouseAndTouchTrackers.current.isTouchEnd || + index === stateRef.current.highlightedIndex ) { return } @@ -347,7 +358,7 @@ function useCombobox( } }), 'aria-disabled': disabled, - 'aria-selected': index === latestState.highlightedIndex, + 'aria-selected': index === state.highlightedIndex, id: elementIds.getItemId(index), role: 'option', ...(!disabled && { @@ -362,7 +373,15 @@ function useCombobox( } }, - [dispatch, elementIds, latest, mouseAndTouchTrackers, preventScroll], + [ + dispatch, + elementIds, + items, + isItemDisabled, + state.highlightedIndex, + mouseAndTouchTrackers, + preventScroll, + ], ) as UseComboboxGetItemProps const getToggleButtonProps = useCallback( @@ -375,7 +394,6 @@ function useCombobox( disabled, ...rest } = toggleButtonProps ?? {} - const latestState = latest.current.state const toggleButtonHandleClick = () => { dispatch({ type: stateChangeTypes.ToggleButtonClick, @@ -387,7 +405,7 @@ function useCombobox( toggleButtonRef.current = toggleButtonNode }), 'aria-controls': elementIds.menuId, - 'aria-expanded': latestState.isOpen, + 'aria-expanded': state.isOpen, id: elementIds.toggleButtonId, tabIndex: -1, ...(!disabled && { @@ -403,9 +421,10 @@ function useCombobox( ...rest, } }, - [dispatch, latest, elementIds], + [dispatch, state.isOpen, elementIds], ) as UseComboboxGetToggleButtonProps + const getInputProps = useCallback( (inputProps, otherProps) => { const { @@ -425,7 +444,6 @@ function useCombobox( setGetterPropCallInfo('getInputProps', suppressRefError, refKey, inputRef) - const latestState = latest.current.state const inputHandleKeyDown = (event: KeyboardEvent) => { const key = normalizeArrowKey(event) if (key && key in inputKeyDownHandlers) { @@ -452,8 +470,8 @@ function useCombobox( /* istanbul ignore else */ if ( environment?.document && - latestState.isOpen && - !mouseAndTouchTrackers.isMouseDown + stateRef.current.isOpen && + !mouseAndTouchTrackers.current.isMouseDown ) { const isBlurByTabChange = event.relatedTarget === null && @@ -520,12 +538,12 @@ function useCombobox( inputRef.current = inputNode }), 'aria-activedescendant': - latestState.isOpen && latestState.highlightedIndex > -1 - ? elementIds.getItemId(latestState.highlightedIndex) + state.isOpen && state.highlightedIndex > -1 + ? elementIds.getItemId(state.highlightedIndex) : '', 'aria-autocomplete': 'list', 'aria-controls': elementIds.menuId, - 'aria-expanded': latestState.isOpen, + 'aria-expanded': state.isOpen, 'aria-labelledby': ariaLabel ? undefined : elementIds.labelId, 'aria-label': ariaLabel, // https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion @@ -534,7 +552,7 @@ function useCombobox( disabled, id: elementIds.inputId, role: 'combobox', - value: latestState.inputValue, + value: state.inputValue, ...eventHandlers, ...rest, } @@ -544,7 +562,9 @@ function useCombobox( elementIds, environment, inputKeyDownHandlers, - latest, + state.isOpen, + state.highlightedIndex, + state.inputValue, mouseAndTouchTrackers, setGetterPropCallInfo, ], diff --git a/src/hooks/useMultipleSelection/index.js b/src/hooks/useMultipleSelection/index.js index 6d385760d..86ec5546b 100644 --- a/src/hooks/useMultipleSelection/index.js +++ b/src/hooks/useMultipleSelection/index.js @@ -1,6 +1,5 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' import { - useLatestRef, callAllEventHandlers, handleRefs, normalizeArrowKey, @@ -51,9 +50,13 @@ function useMultipleSelection(userProps = {}) { // Refs. const isInitialMount = useIsInitialMount() const dropdownRef = useRef(null) - const selectedItemRefs = useRef() - selectedItemRefs.current = [] - const latest = useLatestRef({state, props}) + // Map of selected-item index -> DOM node. Populated by the ref callback in + // getSelectedItemProps and read by the focus effect. Keyed by + // index so we never reset it during render. + const selectedItemRefs = useRef(null) + if (selectedItemRefs.current === null) { + selectedItemRefs.current = new Map() + } // Effects. // Adds an a11y aria live status message if getA11yStatusMessage is passed. @@ -71,8 +74,8 @@ function useMultipleSelection(userProps = {}) { if (activeIndex === -1 && dropdownRef.current) { dropdownRef.current.focus() - } else if (selectedItemRefs.current[activeIndex]) { - selectedItemRefs.current[activeIndex].focus() + } else { + selectedItemRefs.current.get(activeIndex)?.focus() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex]) @@ -139,14 +142,13 @@ function useMultipleSelection(userProps = {}) { index: indexProp, ...rest } = {}) => { - const {state: latestState} = latest.current const [, index] = getItemAndIndex( selectedItemProp, indexProp, - latestState.selectedItems, + selectedItems, 'Pass either item or index to getSelectedItemProps!', ) - const isFocusable = index > -1 && index === latestState.activeIndex + const isFocusable = index > -1 && index === activeIndex const selectedItemHandleClick = () => { dispatch({ @@ -164,7 +166,9 @@ function useMultipleSelection(userProps = {}) { return { [refKey]: handleRefs(ref, selectedItemNode => { if (selectedItemNode) { - selectedItemRefs.current.push(selectedItemNode) + selectedItemRefs.current.set(index, selectedItemNode) + } else { + selectedItemRefs.current.delete(index) } }), tabIndex: isFocusable ? 0 : -1, @@ -173,7 +177,7 @@ function useMultipleSelection(userProps = {}) { ...rest, } }, - [dispatch, latest, selectedItemKeyDownHandlers], + [dispatch, selectedItems, activeIndex, selectedItemKeyDownHandlers], ) const getDropdownProps = useCallback( ( diff --git a/src/hooks/useSelect/index.ts b/src/hooks/useSelect/index.ts index d58404825..63781d5e4 100644 --- a/src/hooks/useSelect/index.ts +++ b/src/hooks/useSelect/index.ts @@ -1,6 +1,5 @@ import React, {useRef, useEffect, useCallback, useMemo} from 'react' import { - useLatestRef, validatePropTypes, callAllEventHandlers, handleRefs, @@ -49,7 +48,13 @@ function useSelect( ...dropdownDefaultProps, ...userProps, } - const {scrollIntoView, environment, getA11yStatusMessage} = props + const { + items, + isItemDisabled, + scrollIntoView, + environment, + getA11yStatusMessage, + } = props // Initial state depending on controlled props. const [state, dispatch] = useControlledReducer< UseSelectState, @@ -67,8 +72,15 @@ function useSelect( const clearTimeoutRef = useRef | null>(null) // prevent id re-generation between renders. const elementIds = useElementIds(props) - // utility callback to get item element. - const latest = useLatestRef({state, props}) + /** + * Ref to read `state` in handlers to preserve referential identity. + * Only to be used in handlers and effects. + * **never access this in getters** + */ + const stateRef = useRef(state) + useEffect(() => { + stateRef.current = state + }, [state]) // Effects. // Adds an a11y aria live status message if getA11yStatusMessage is passed. @@ -83,8 +95,8 @@ function useSelect( scrollIntoView, highlightedIndex, isOpen, - menuRef.current, - itemsRef.current, + menuRef, + itemsRef, elementIds.getItemId, ) // Sets cleanup for the keysSoFar callback, debounced after 500ms. @@ -132,13 +144,13 @@ function useSelect( const handleBlurInTracker = useCallback( function handleBlur() { - if (latest.current.state.isOpen) { + if (stateRef.current.isOpen) { dispatch({ type: stateChangeTypes.ToggleButtonBlur, }) } }, - [dispatch, latest], + [dispatch], ) const downshiftRefs = useMemo(() => [menuRef, toggleButtonRef], []) const mouseAndTouchTrackers = useMouseAndTouchTracker( @@ -191,7 +203,7 @@ function useSelect( }) }, Escape() { - if (latest.current.state.isOpen) { + if (stateRef.current.isOpen) { dispatch({ type: stateChangeTypes.ToggleButtonKeyDownEscape, }) @@ -201,13 +213,13 @@ function useSelect( event.preventDefault() dispatch({ - type: latest.current.state.isOpen + type: stateRef.current.isOpen ? stateChangeTypes.ToggleButtonKeyDownEnter : stateChangeTypes.ToggleButtonClick, }) }, PageUp(event: KeyboardEvent) { - if (latest.current.state.isOpen) { + if (stateRef.current.isOpen) { event.preventDefault() dispatch({ @@ -216,7 +228,7 @@ function useSelect( } }, PageDown(event: KeyboardEvent) { - if (latest.current.state.isOpen) { + if (stateRef.current.isOpen) { event.preventDefault() dispatch({ @@ -227,7 +239,7 @@ function useSelect( ' '(event: KeyboardEvent) { event.preventDefault() - const currentState = latest.current.state + const currentState = stateRef.current if (!currentState.isOpen) { dispatch({type: stateChangeTypes.ToggleButtonClick}) @@ -244,7 +256,7 @@ function useSelect( } }, }), - [dispatch, latest], + [dispatch], ) // Getter functions. @@ -316,14 +328,13 @@ function useSelect( } = toggleButtonProps ?? {} const {suppressRefError = false} = otherProps ?? {} - const latestState = latest.current.state const toggleButtonHandleClick = () => { dispatch({ type: stateChangeTypes.ToggleButtonClick, }) } const toggleButtonHandleBlur = () => { - if (latestState.isOpen && !mouseAndTouchTrackers.isMouseDown) { + if (stateRef.current.isOpen && !mouseAndTouchTrackers.current.isMouseDown) { dispatch({ type: stateChangeTypes.ToggleButtonBlur, }) @@ -351,11 +362,11 @@ function useSelect( }, ), 'aria-activedescendant': - latestState.isOpen && latestState.highlightedIndex > -1 - ? elementIds.getItemId(latestState.highlightedIndex) + state.isOpen && state.highlightedIndex > -1 + ? elementIds.getItemId(state.highlightedIndex) : '', 'aria-controls': elementIds.menuId, - 'aria-expanded': latest.current.state.isOpen, + 'aria-expanded': state.isOpen, 'aria-haspopup': 'listbox', 'aria-labelledby': rest['aria-label'] ? undefined @@ -397,7 +408,8 @@ function useSelect( [ dispatch, elementIds, - latest, + state.isOpen, + state.highlightedIndex, mouseAndTouchTrackers, setGetterPropCallInfo, toggleButtonKeyDownHandlers, @@ -425,19 +437,18 @@ function useSelect( ) } - const {state: latestState, props: latestProps} = latest.current const [item, index] = getItemAndIndex( itemProp, indexProp, - latestProps.items, + items, 'Pass either item or index to getItemProps!', ) - const disabled = latestProps.isItemDisabled(item, index) + const disabled = isItemDisabled(item, index) const itemHandleMouseMove = () => { if ( - mouseAndTouchTrackers.isTouchEnd || - index === latestState.highlightedIndex + mouseAndTouchTrackers.current.isTouchEnd || + index === stateRef.current.highlightedIndex ) { return } @@ -466,7 +477,7 @@ function useSelect( }, ), 'aria-disabled': disabled, - 'aria-selected': item === latestState.selectedItem, + 'aria-selected': item === state.selectedItem, id: elementIds.getItemId(index), role: 'option', onMouseMove: callAllEventHandlers(onMouseMove, itemHandleMouseMove), @@ -489,7 +500,15 @@ function useSelect( return resultItemProps }, - [latest, elementIds, mouseAndTouchTrackers, preventScroll, dispatch], + [ + items, + isItemDisabled, + state.selectedItem, + elementIds, + mouseAndTouchTrackers, + preventScroll, + dispatch, + ], ) as UseSelectGetItemProps // Action functions. diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index d3f5d0afe..d3cd5363a 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -3,7 +3,6 @@ import {useCallback} from 'react' import { callAllEventHandlers, handleRefs, - useLatestRef, validatePropTypes, } from '../../utils' import {useControlledReducer} from '../utils' @@ -53,7 +52,6 @@ const useTagGroup: UseTagGroupInterface = ( /* Refs */ - const latest = useLatestRef({state, props}) const elementIds = useElementIds({ getTagId: props.getTagId, id: props.id, @@ -132,8 +130,6 @@ const useTagGroup: UseTagGroupInterface = ( throw new Error('Pass correct item index to getTagProps!') } - const latestState = latest.current.state - const handleClick = () => { dispatch({type: stateChangeTypes.TagClick, index}) } @@ -150,11 +146,11 @@ const useTagGroup: UseTagGroupInterface = ( role: 'option', id: tagId, onClick: callAllEventHandlers(onClick, handleClick), - tabIndex: latestState.activeIndex === index ? 0 : -1, + tabIndex: activeIndex === index ? 0 : -1, ...rest, } as GetTagPropsReturnValue & Extra }, - [dispatch, elementIds, latest, itemRefs], + [dispatch, elementIds, activeIndex, itemRefs], ) const getTagRemoveProps = useCallback( diff --git a/src/hooks/useTagGroup/utils/useElementIds.ts b/src/hooks/useTagGroup/utils/useElementIds.ts index b262a1023..f67f80c79 100644 --- a/src/hooks/useTagGroup/utils/useElementIds.ts +++ b/src/hooks/useTagGroup/utils/useElementIds.ts @@ -49,8 +49,7 @@ function useElementIdsLegacy({ getTagId, tagGroupId, }: UseElementIdsProps): UseElementIdsReturnValue { - const baseIdRef = React.useRef(id ?? `downshift-${generateId()}`) - const baseId = baseIdRef.current + const [baseId] = React.useState(() => id ?? `downshift-${generateId()}`) const elementIds = React.useMemo( () => ({ diff --git a/src/hooks/utils/__tests__/useMouseAndTouchTracker.test.ts b/src/hooks/utils/__tests__/useMouseAndTouchTracker.test.ts index 88517a9d8..43480d05a 100644 --- a/src/hooks/utils/__tests__/useMouseAndTouchTracker.test.ts +++ b/src/hooks/utils/__tests__/useMouseAndTouchTracker.test.ts @@ -152,7 +152,7 @@ describe('useMouseAndTouchTracker', () => { props.environment, props.handleBlur, props.downshiftRefs, - ), + ).current, {initialProps}, ) diff --git a/src/hooks/utils/__tests__/useScrollIntoView.test.ts b/src/hooks/utils/__tests__/useScrollIntoView.test.ts index 445ac21a8..f32121208 100644 --- a/src/hooks/utils/__tests__/useScrollIntoView.test.ts +++ b/src/hooks/utils/__tests__/useScrollIntoView.test.ts @@ -19,8 +19,8 @@ function renderScrollHook(props = {}) { p.scrollIntoView, p.highlightedIndex, p.isOpen, - p.menuElement, - p.itemElements, + {current: p.menuElement}, + {current: p.itemElements}, p.getItemId, ), { diff --git a/src/hooks/utils/useElementIds.ts b/src/hooks/utils/useElementIds.ts index 234bda585..75a0d078b 100644 --- a/src/hooks/utils/useElementIds.ts +++ b/src/hooks/utils/useElementIds.ts @@ -61,8 +61,7 @@ function useElementIdsLegacy({ toggleButtonId, inputId, }: UseElementIdsProps): UseElementIdsReturnValue { - const baseIdRef = React.useRef(id ?? `downshift-${generateId()}`) - const baseId = baseIdRef.current + const [baseId] = React.useState(() => id ?? `downshift-${generateId()}`) const elementIds = React.useMemo( () => ({ diff --git a/src/hooks/utils/useEnhancedReducer.ts b/src/hooks/utils/useEnhancedReducer.ts index f7e2678f4..5f37e7ee2 100644 --- a/src/hooks/utils/useEnhancedReducer.ts +++ b/src/hooks/utils/useEnhancedReducer.ts @@ -1,6 +1,6 @@ import * as React from 'react' -import {getState, useLatestRef} from '../../utils' +import {getState} from '../../utils' import {callOnChangeProps} from './callOnChangeProps' import {type Action, type Props, type Reducer} from './index.types' @@ -26,41 +26,49 @@ export function useEnhancedReducer< isStateEqual: (prev: S, next: S) => boolean, ): [S, (action: A) => void] { const prevStateRef = React.useRef({} as S) - const actionRef = React.useRef>() const enhancedReducer = React.useCallback( - (state: S, action: Action): S => { - actionRef.current = action + ( + {state}: {state: S}, + action: Action, + ): {state: S; lastAction?: Action} => { state = getState(state, action.props) const changes = reducer(state, action) const newState = action.props.stateReducer(state, {...action, changes}) - return {...state, ...newState} + return {state: {...state, ...newState}, lastAction: action} }, [reducer], ) - const [state, dispatch] = React.useReducer( + const [{state, lastAction}, dispatch] = React.useReducer( enhancedReducer, props, - createInitialState, + p => ({ + state: createInitialState(p), + lastAction: undefined, + }), ) - const propsRef = useLatestRef(props) + const propsRef = React.useRef(props) + React.useEffect(() => { + propsRef.current = props + }, [props]) const dispatchWithProps = React.useCallback( (action: A) => dispatch({...action, props: propsRef.current}), [propsRef], ) - const action = actionRef.current React.useEffect(() => { - const prevState = getState(prevStateRef.current, action?.props) - const shouldCallOnChangeProps = action && !isStateEqual(prevState, state) + if (lastAction) { + const prevState = getState(prevStateRef.current, lastAction.props) + const shouldCallOnChangeProps = !isStateEqual(prevState, state) - if (shouldCallOnChangeProps) { - callOnChangeProps(action, action.props, prevState, state) + if (shouldCallOnChangeProps) { + callOnChangeProps(lastAction, lastAction.props, prevState, state) + } } prevStateRef.current = state - }, [state, action, isStateEqual]) + }, [state, lastAction, isStateEqual]) return [state, dispatchWithProps] } diff --git a/src/hooks/utils/useIsInitialMount.ts b/src/hooks/utils/useIsInitialMount.ts index 68ef5559a..a53fb3e44 100644 --- a/src/hooks/utils/useIsInitialMount.ts +++ b/src/hooks/utils/useIsInitialMount.ts @@ -14,5 +14,6 @@ export function useIsInitialMount(): boolean { } }, []) + // eslint-disable-next-line react-hooks/refs return isInitialMountRef.current } diff --git a/src/hooks/utils/useMouseAndTouchTracker.ts b/src/hooks/utils/useMouseAndTouchTracker.ts index 4fbed679b..ef41373ff 100644 --- a/src/hooks/utils/useMouseAndTouchTracker.ts +++ b/src/hooks/utils/useMouseAndTouchTracker.ts @@ -9,7 +9,7 @@ import {noop, targetWithinDownshift} from '../../utils' * @param environment The environment to add the event listeners to, for instance window. * @param handleBlur The function that is called if mouseDown or touchEnd occured outside the downshiftElements. * @param downshiftRefs The refs for the elements that should not trigger a blur action from mouseDown or touchEnd. - * @returns The mouse and touch events information. + * @returns A ref holding the mouse and touch events information. Read `.current` only inside event handlers/effects. */ export function useMouseAndTouchTracker( environment: Environment | undefined, @@ -87,5 +87,5 @@ export function useMouseAndTouchTracker( } }, [environment, getDownshiftElements, handleBlur]) - return mouseAndTouchTrackersRef.current + return mouseAndTouchTrackersRef } diff --git a/src/hooks/utils/useScrollIntoView.ts b/src/hooks/utils/useScrollIntoView.ts index 654e46c07..e296f74f5 100644 --- a/src/hooks/utils/useScrollIntoView.ts +++ b/src/hooks/utils/useScrollIntoView.ts @@ -13,8 +13,8 @@ const useIsomorphicLayoutEffect = * @param scrollIntoView The function that does the scroll. * @param highlightedIndex The index of the item that should be scrolled. * @param isOpen If the menu is open or not. - * @param menuElement The menu element. - * @param itemElements The object containing item elements. + * @param menuRef The ref to the menu element. + * @param itemsRef The ref to the object containing item elements. * @param getItemId The function to get the item id from index. * @returns Function that when called prevents the scroll. */ @@ -22,23 +22,27 @@ export function useScrollIntoView( scrollIntoView: (node: HTMLElement, menuNode: HTMLElement) => void, highlightedIndex: number, isOpen: boolean, - menuElement: HTMLElement | null, - itemElements: Record, + menuRef: React.MutableRefObject, + itemsRef: React.MutableRefObject>, getItemId: (index: number) => string, ) { // used not to scroll on highlight by mouse. const shouldScrollRef = React.useRef(true) // Scroll on highlighted item if change comes from keyboard. useIsomorphicLayoutEffect(() => { - if (highlightedIndex < 0 || !isOpen || !Object.keys(itemElements).length) { + if ( + highlightedIndex < 0 || + !isOpen || + !Object.keys(itemsRef.current).length + ) { return } if (shouldScrollRef.current) { - const itemElement = itemElements[getItemId(highlightedIndex)] + const itemElement = itemsRef.current[getItemId(highlightedIndex)] - if (itemElement && menuElement) { - scrollIntoView(itemElement, menuElement) + if (itemElement && menuRef.current) { + scrollIntoView(itemElement, menuRef.current) } } else { shouldScrollRef.current = true diff --git a/src/utils/__tests__/useLatestRef.test.ts b/src/utils/__tests__/useLatestRef.test.ts deleted file mode 100644 index ee145a865..000000000 --- a/src/utils/__tests__/useLatestRef.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {renderHook} from '@testing-library/react' - -import {useLatestRef} from '../useLatestRef' - -test('useLatestRef', () => { - const {result, rerender} = renderHook(val => useLatestRef(val), { - initialProps: 'initial', - }) - - expect(result.current.current).toBe('initial') - - rerender('updated') - expect(result.current.current).toBe('updated') -}) diff --git a/src/utils/index.ts b/src/utils/index.ts index 67c4fc030..ab102cad3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,4 @@ export {generateId, setIdCounter, resetIdCounter} from './generateId' -export {useLatestRef} from './useLatestRef' export {handleRefs} from './handleRefs' export {callAllEventHandlers} from './callAllEventHandlers' export {debounce} from './debounce' diff --git a/src/utils/useLatestRef.ts b/src/utils/useLatestRef.ts deleted file mode 100644 index cd23e096a..000000000 --- a/src/utils/useLatestRef.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react' - -export function useLatestRef(val: T): React.MutableRefObject { - const ref = React.useRef(val) - // technically this is not "concurrent mode safe" because we're manipulating - // the value during render (so it's not idempotent). However, the places this - // hook is used is to support memoizing callbacks which will be called - // *during* render, so we need the latest values *during* render. - // If not for this, then we'd probably want to use useLayoutEffect instead. - ref.current = val - return ref -}