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
-}