Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(dropdown): stabilize internal references to reduce re-renders #17952

Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/react/src/components/Dropdown/Dropdown.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,68 @@ export const withAILabel = () => (
/>
</div>
);

// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
const renderTestItems = [
{
value: '',
label: '...',
},
{
value: 'Age',
label: 'Age',
},
{
value: 'Sex',
label: 'Sex',
},
{
value: 'BP',
label: 'BP',
},
{
value: 'Cholesterol',
label: 'Cholesterol',
},
{
value: 'Na',
label: 'Na',
},
{
value: 'K',
label: 'K',
},
{
value: 'Drug',
label: 'Drug',
},
];
function renderItem(item) {
console.count('times renderItem called');
return item ? (
<span className="test" style={{ color: 'red' }}>
{item.label} 🔥
</span>
) : (
''
);
}
export const RenderTest = () => (
<div style={{ width: 400 }}>
<Dropdown
id="default"
titleText="Dropdown label"
helperText="This is some helper text"
items={renderTestItems}
itemToElement={renderItem}
renderSelectedItem={renderItem}
/>
</div>
);
215 changes: 141 additions & 74 deletions packages/react/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import React, {
useCallback,
useContext,
useState,
FocusEvent,
Expand All @@ -20,6 +21,7 @@
UseSelectInterface,
UseSelectProps,
UseSelectState,
UseSelectStateChange,
UseSelectStateChangeTypes,
} from 'downshift';
import cx from 'classnames';
Expand Down Expand Up @@ -240,14 +242,49 @@

export type DropdownTranslationKey = ListBoxMenuIconTranslationKey;

/**
* Custom state reducer for `useSelect` in Downshift, providing control over
* state changes.
*
* This function is called each time `useSelect` updates its internal state or
* triggers `onStateChange`. It allows for fine-grained control of state
* updates by modifying or overriding the default changes from Downshift's
* reducer.
* https://github.com/downshift-js/downshift/tree/master/src/hooks/useSelect#statereducer
*
* @param {Object} state - The current full state of the Downshift component.
* @param {Object} actionAndChanges - Contains the action type and proposed
* changes from the default Downshift reducer.
* @param {Object} actionAndChanges.changes - Suggested state changes.
* @param {string} actionAndChanges.type - The action type for the state
* change (e.g., item selection).
* @returns {Object} - The modified state based on custom logic or default
* changes if no custom logic applies.
*/
function stateReducer(state, actionAndChanges) {
const { changes, type } = actionAndChanges;

switch (type) {
case ItemMouseMove:
case MenuMouseLeave:
if (changes.highlightedIndex === state.highlightedIndex) {
// Prevent state update if highlightedIndex hasn't changed
return state;

Check warning on line 272 in packages/react/src/components/Dropdown/Dropdown.tsx

View check run for this annotation

Codecov / codecov/patch

packages/react/src/components/Dropdown/Dropdown.tsx#L272

Added line #L272 was not covered by tests
}
return changes;
default:
return changes;
}
}

const Dropdown = React.forwardRef(
<ItemType,>(
{
autoAlign = false,
className: containerClassName,
disabled = false,
direction = 'bottom',
items,
items: itemsProp,
label,
['aria-label']: ariaLabel,
ariaLabel: deprecatedAriaLabel,
Expand Down Expand Up @@ -329,22 +366,33 @@
const prefix = usePrefix();
const { isFluid } = useContext(FormContext);

const selectProps: UseSelectProps<ItemType> = {
items,
itemToString,
initialSelectedItem,
onSelectedItemChange,
stateReducer,
isItemDisabled(item, _index) {
const isObject = item !== null && typeof item === 'object';
return isObject && 'disabled' in item && item.disabled === true;
const onSelectedItemChange = useCallback(
({ selectedItem }: Partial<UseSelectState<ItemType>>) => {
if (onChange) {
onChange({ selectedItem: selectedItem ?? null });
}
},
onHighlightedIndexChange: ({ highlightedIndex }) => {
if (highlightedIndex! > -1 && typeof window !== undefined) {
[onChange]
);

const isItemDisabled = useCallback((item, _index) => {
const isObject = item !== null && typeof item === 'object';
return isObject && 'disabled' in item && item.disabled === true;
}, []);

const onHighlightedIndexChange = useCallback(
(changes: UseSelectStateChange<ItemType>) => {
const { highlightedIndex } = changes;

if (
highlightedIndex !== undefined &&
highlightedIndex > -1 &&
typeof window !== undefined
) {
const itemArray = document.querySelectorAll(
`li.${prefix}--list-box__menu-item[role="option"]`
);
const highlightedItem = itemArray[highlightedIndex!];
const highlightedItem = itemArray[highlightedIndex];
if (highlightedItem) {
highlightedItem.scrollIntoView({
behavior: 'smooth',
Expand All @@ -353,20 +401,33 @@
}
}
},
...downshiftProps,
};
const dropdownInstanceId = useId();

function stateReducer(state, actionAndChanges) {
const { changes, type } = actionAndChanges;
[prefix]
);

switch (type) {
case ItemMouseMove:
case MenuMouseLeave:
return { ...changes, highlightedIndex: state.highlightedIndex };
}
return changes;
}
const items = useMemo(() => itemsProp, [itemsProp]);
const selectProps = useMemo(
() => ({
items,
itemToString,
initialSelectedItem,
onSelectedItemChange,
stateReducer,
isItemDisabled,
onHighlightedIndexChange,
...downshiftProps,
}),
[
items,
itemToString,
initialSelectedItem,
onSelectedItemChange,
stateReducer,
isItemDisabled,
onHighlightedIndexChange,
downshiftProps,
]
);
const dropdownInstanceId = useId();

// only set selectedItem if the prop is defined. Setting if it is undefined
// will overwrite default selected items from useSelect
Expand Down Expand Up @@ -432,9 +493,13 @@

// needs to be Capitalized for react to render it correctly
const ItemToElement = itemToElement;
const toggleButtonProps = getToggleButtonProps({
'aria-label': ariaLabel || deprecatedAriaLabel,
});
const toggleButtonProps = useMemo(
() =>
getToggleButtonProps({
'aria-label': ariaLabel || deprecatedAriaLabel,
}),
[getToggleButtonProps, ariaLabel, deprecatedAriaLabel, isOpen]
);

const helper =
helperText && !isFluid ? (
Expand All @@ -443,14 +508,6 @@
</div>
) : null;

function onSelectedItemChange({
selectedItem,
}: Partial<UseSelectState<ItemType>>) {
if (onChange) {
onChange({ selectedItem: selectedItem ?? null });
}
}

const handleFocus = (evt: FocusEvent<HTMLDivElement>) => {
setIsFocused(evt.type === 'focus' ? true : false);
};
Expand All @@ -459,11 +516,39 @@

const [currTimer, setCurrTimer] = useState<NodeJS.Timeout>();

// eslint-disable-next-line prefer-const
let [isTyping, setIsTyping] = useState(false);
const [isTyping, setIsTyping] = useState(false);

const onKeyDownHandler = useCallback(
(evt: React.KeyboardEvent<HTMLButtonElement>) => {
if (
evt.code !== 'Space' ||
!['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(evt.key)
) {
setIsTyping(true);
}
if (
(isTyping && evt.code === 'Space') ||
!['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(evt.key)
) {
if (currTimer) {
clearTimeout(currTimer);
}
setCurrTimer(
setTimeout(() => {
setIsTyping(false);
}, 3000)
);
}
if (toggleButtonProps.onKeyDown) {
toggleButtonProps.onKeyDown(evt);
}
},
[isTyping, currTimer, toggleButtonProps]
);

const readOnlyEventHandlers = readOnly
? {
const readOnlyEventHandlers = useMemo(() => {
if (readOnly) {
return {
onClick: (evt: MouseEvent<HTMLButtonElement>) => {
// NOTE: does not prevent click
evt.preventDefault();
Expand All @@ -477,49 +562,31 @@
evt.preventDefault();
}
},
}
: {
onKeyDown: (evt: React.KeyboardEvent<HTMLButtonElement>) => {
if (
evt.code !== 'Space' ||
!['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(evt.key)
) {
setIsTyping(true);
}
if (
(isTyping && evt.code === 'Space') ||
!['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(evt.key)
) {
if (currTimer) {
clearTimeout(currTimer);
}
setCurrTimer(
setTimeout(() => {
setIsTyping(false);
}, 3000)
);
}
if (toggleButtonProps.onKeyDown) {
toggleButtonProps.onKeyDown(evt);
}
},
};
} else {
return {
onKeyDown: onKeyDownHandler,
};
}
}, [readOnly, onKeyDownHandler]);

const menuProps = useMemo(
() =>
getMenuProps({
ref: enableFloatingStyles || autoAlign ? refs.setFloating : null,
}),
[autoAlign, getMenuProps, refs.setFloating]
[autoAlign, getMenuProps, refs.setFloating, enableFloatingStyles]
);

// Slug is always size `mini`
let normalizedSlug;
if (slug && slug['type']?.displayName === 'AILabel') {
normalizedSlug = React.cloneElement(slug as React.ReactElement<any>, {
size: 'mini',
});
}
const normalizedSlug = useMemo(() => {
if (slug && slug['type']?.displayName === 'AILabel') {
return React.cloneElement(slug as React.ReactElement<any>, {
size: 'mini',
});
}
return slug;
}, [slug]);

return (
<div className={wrapperClasses} {...other}>
Expand Down
Loading