Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
75 changes: 68 additions & 7 deletions packages/mui-material/src/Autocomplete/Autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import PropTypes from 'prop-types';
import integerPropType from '@mui/utils/integerPropType';
import chainPropTypes from '@mui/utils/chainPropTypes';
import composeClasses from '@mui/utils/composeClasses';
import useForcedRerendering from '@mui/utils/useForcedRerendering';
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
import useAutocomplete, { createFilterOptions } from '../useAutocomplete';
import Popper from '../Popper';
import ListSubheader from '../ListSubheader';
Expand Down Expand Up @@ -505,6 +507,51 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) {
groupedOptions,
} = useAutocomplete({ ...props, componentName: 'Autocomplete' });

// Re-render when anchorEl resizes so the Popper width stays in sync.
// Width is always read synchronously from anchorEl.clientWidth during render
// (no stale cached value). The hook just triggers a re-render on resize.
const forceRenderOnResize = useForcedRerendering();

React.useEffect(() => {
if (!popupOpen || !anchorEl || typeof ResizeObserver === 'undefined') {
return undefined;
}

let lastWidth = anchorEl.clientWidth;

const observer = new ResizeObserver(() => {
const newWidth = anchorEl.clientWidth;
if (lastWidth !== newWidth) {
lastWidth = newWidth;
forceRenderOnResize();
}
});
observer.observe(anchorEl);

return () => {
observer.disconnect();
};
}, [popupOpen, anchorEl, forceRenderOnResize]);

// When popupOpen becomes false, useAutocomplete returns [] for groupedOptions.
// Transitioned Poppers can remain mounted for their exit animation, so keep rendering
// the last open-state options instead of flashing "No options" or an empty Paper.
// These options are stale because they no longer reflect the hook's current
// groupedOptions, but they are non-interactive while closing and reset on next open.
const previousGroupedOptionsRef = React.useRef([]);
const prevPopupOpenRef = React.useRef(false);
const renderedOptions = popupOpen ? groupedOptions : previousGroupedOptionsRef.current;

useEnhancedEffect(() => {
if (popupOpen && !prevPopupOpenRef.current) {
previousGroupedOptionsRef.current = [];
}
prevPopupOpenRef.current = popupOpen;
if (popupOpen && groupedOptions.length > 0) {
previousGroupedOptionsRef.current = groupedOptions;
}
}, [popupOpen, groupedOptions]);

const hasClearIcon = !disableClearable && !disabled && dirty && !readOnly;
const hasPopupIcon = (!freeSolo || forcePopupIcon === true) && forcePopupIcon !== false;

Expand Down Expand Up @@ -580,13 +627,27 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) {
className: classes.popper,
additionalProps: {
disablePortal,
style: { width: anchorEl ? anchorEl.clientWidth : null },
style: {
width: anchorEl ? anchorEl.clientWidth : null,
// Prevent interaction with stale cached options during exit transitions.
// The hook's filteredOptions is [] when popupOpen=false, so clicks on stale
// rendered options would pass undefined to selectNewValue.
pointerEvents: popupOpen ? undefined : 'none',
},
role: 'presentation',
anchorEl,
open: popupOpen,
},
});

// Don't render the Popper when there's no content to show.
// In freeSolo mode, "No options" text is suppressed, so if there are also no
// matching options and loading is false, the Paper would be empty.
// Uses renderedOptions (not groupedOptions) so Popper stays during exit transitions.
// Respect keepMounted from resolved popperProps (handles both object and callback slotProps forms).
const hasPopupContent =
renderedOptions.length > 0 || loading || !freeSolo || popperProps.keepMounted === true;

const [ClearIndicatorSlot, clearIndicatorProps] = useSlot('clearIndicator', {
elementType: AutocompleteClearIndicator,
externalForwardedProps,
Expand Down Expand Up @@ -740,15 +801,15 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) {
},
})}
</RootSlot>
{anchorEl ? (
{anchorEl && hasPopupContent ? (
<AutocompletePopper as={PopperSlot} {...popperProps}>
<AutocompletePaper as={PaperSlot} {...paperProps}>
{loading && groupedOptions.length === 0 ? (
{loading && renderedOptions.length === 0 ? (
<AutocompleteLoading className={classes.loading} ownerState={ownerState}>
{loadingText}
</AutocompleteLoading>
) : null}
{groupedOptions.length === 0 && !freeSolo && !loading ? (
{renderedOptions.length === 0 && !freeSolo && !loading ? (
<AutocompleteNoOptions
className={classes.noOptions}
ownerState={ownerState}
Expand All @@ -761,9 +822,9 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) {
{noOptionsText}
</AutocompleteNoOptions>
) : null}
{groupedOptions.length > 0 ? (
<ListboxSlot as={ListboxComponentProp} {...listboxProps}>
{groupedOptions.map((option, index) => {
{renderedOptions.length > 0 ? (
<ListboxSlot {...listboxProps}>
{renderedOptions.map((option, index) => {
if (groupBy) {
return renderGroup({
key: option.key,
Expand Down
213 changes: 212 additions & 1 deletion packages/mui-material/src/Autocomplete/Autocomplete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import Autocomplete, {
autocompleteClasses as classes,
createFilterOptions,
} from '@mui/material/Autocomplete';
import { paperClasses } from '@mui/material/Paper';
import Grow from '@mui/material/Grow';
import { iconButtonClasses } from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import { paperClasses } from '@mui/material/Paper';
import Popper from '@mui/material/Popper';
import Tooltip from '@mui/material/Tooltip';
import describeConformance from '../../test/describeConformance';

Expand Down Expand Up @@ -2988,6 +2990,59 @@ describe('<Autocomplete />', () => {

expect(view.container.querySelector(`.${classes.endAdornment}`)).to.equal(null);
});

it('should not render the Popper when freeSolo and no options match', async () => {
const { user } = render(
<Autocomplete
freeSolo
options={['one', 'two']}
renderInput={(params) => <TextField {...params} />}
slotProps={{ popper: { 'data-testid': 'popper' } }}
/>,
);
await user.type(screen.getByRole('combobox'), 'xyz');
expect(screen.queryByTestId('popper')).to.equal(null);
});

it('should render loading text in freeSolo even with no options', async () => {
const { user } = render(
<Autocomplete
freeSolo
loading
options={[]}
renderInput={(params) => <TextField {...params} />}
/>,
);
await user.type(screen.getByRole('combobox'), 'a');
expect(screen.getByText('Loading…')).not.to.equal(null);
});

it('should keep the Popper in the DOM when freeSolo, keepMounted, and no options match', async () => {
const { user } = render(
<Autocomplete
freeSolo
options={['one', 'two']}
renderInput={(params) => <TextField {...params} />}
slotProps={{ popper: { keepMounted: true, 'data-testid': 'popper' } }}
/>,
);
await user.type(screen.getByRole('combobox'), 'xyz');
// keepMounted keeps the Popper in the DOM but hidden
expect(screen.getByTestId('popper')).not.to.equal(null);
});

it('should respect keepMounted from callback-form slotProps.popper in freeSolo with no matches', async () => {
const { user } = render(
<Autocomplete
freeSolo
options={['one', 'two']}
renderInput={(params) => <TextField {...params} />}
slotProps={{ popper: () => ({ keepMounted: true, 'data-testid': 'popper' }) }}
/>,
);
await user.type(screen.getByRole('combobox'), 'xyz');
expect(screen.getByTestId('popper')).not.to.equal(null);
});
});

describe('prop: onChange', () => {
Expand Down Expand Up @@ -4368,4 +4423,160 @@ describe('<Autocomplete />', () => {
expect(listbox).not.to.have.property('scrollTop', 0);
},
);

describe('exit transition', () => {
it.skipIf(isJsdom())(
'should preserve options in DOM during Popper exit transition',
async () => {
function TransitionPopper(props) {
const { children, open: popperOpen, ...other } = props;
return (
<Popper {...other} open={popperOpen} transition>
{({ TransitionProps }) => (
<Grow {...TransitionProps} timeout={200}>
<div>{children}</div>
</Grow>
)}
</Popper>
);
}
TransitionPopper.propTypes = {
children: PropTypes.node,
open: PropTypes.bool,
};

const { user } = render(
<Autocomplete
options={['one', 'two', 'three']}
slots={{ popper: TransitionPopper }}
renderInput={(params) => <TextField {...params} />}
/>,
);

// Open popup
await user.click(screen.getByRole('combobox'));
expect(screen.getAllByRole('option')).to.have.length(3);

// Close popup
await user.keyboard('{Escape}');

// Options should still be in DOM during transition
expect(screen.getAllByRole('option')).to.have.length(3);
},
);

it('should not show stale options from a prior session during exit', async () => {
const { user, rerender } = render(
<Autocomplete
freeSolo
options={['one', 'two']}
renderInput={(params) => <TextField {...params} />}
slotProps={{ popper: { keepMounted: true } }}
/>,
);

const input = screen.getByRole('combobox');

// Open popup and verify options
await user.click(input);
expect(screen.getAllByRole('option')).to.have.length(2);

// Close popup
await user.keyboard('{Escape}');

// Change to empty options and re-open
rerender(
<Autocomplete
freeSolo
options={[]}
renderInput={(params) => <TextField {...params} />}
slotProps={{ popper: { keepMounted: true } }}
/>,
);
await user.click(input);

// No options should be visible (not stale ones from prior session)
expect(screen.queryAllByRole('option')).to.have.length(0);

// Close again — should not flash stale options from the first session
await user.keyboard('{Escape}');
expect(screen.queryAllByRole('option')).to.have.length(0);
});

it('should disable pointer events on Popper when closing', async () => {
const { user } = render(
<Autocomplete
options={['one']}
renderInput={(params) => <TextField {...params} />}
slotProps={{ popper: { keepMounted: true, 'data-testid': 'popper' } }}
/>,
);

// Open popup
await user.click(screen.getByRole('combobox'));
expect(screen.getByTestId('popper').style.pointerEvents).to.equal('');

// Close popup
await user.keyboard('{Escape}');

// pointerEvents: none prevents stale clicks during exit animation
expect(screen.getByTestId('popper').style.pointerEvents).to.equal('none');
});
});

describe('Popper width', () => {
it('should observe anchor element for resize when popup is open', async () => {
const observeSpy = spy();
const MockResizeObserver = class {
observe() {
observeSpy();
}

disconnect() {}
};
const originalRO = window.ResizeObserver;
window.ResizeObserver = MockResizeObserver;

try {
const { user } = render(
<Autocomplete
options={['one', 'two']}
renderInput={(params) => <TextField {...params} />}
slotProps={{ popper: { 'data-testid': 'popper' } }}
/>,
);

await user.click(screen.getByRole('combobox'));
expect(screen.getByTestId('popper')).not.to.equal(null);
expect(observeSpy.callCount).to.be.greaterThan(0);
} finally {
window.ResizeObserver = originalRO;
}
});

it('should disconnect ResizeObserver when popup closes', async () => {
const disconnectSpy = spy();
const MockResizeObserver = class {
observe() {}

disconnect() {
disconnectSpy();
}
};
const originalRO = window.ResizeObserver;
window.ResizeObserver = MockResizeObserver;

try {
const { user } = render(
<Autocomplete options={['one']} renderInput={(params) => <TextField {...params} />} />,
);

await user.click(screen.getByRole('combobox'));
await user.keyboard('{Escape}');
expect(disconnectSpy.callCount).to.be.greaterThan(0);
} finally {
window.ResizeObserver = originalRO;
}
});
});
});
1 change: 1 addition & 0 deletions packages/mui-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export { default as unstable_useLazyRef } from './useLazyRef';
export { default as unstable_useTimeout, Timeout as unstable_Timeout } from './useTimeout';
export { default as unstable_useOnMount } from './useOnMount';
export { default as unstable_useIsFocusVisible } from './useIsFocusVisible';
export { default as unstable_useForcedRerendering } from './useForcedRerendering';
export { default as unstable_isFocusVisible } from './isFocusVisible';
export { default as unstable_getScrollbarSize } from './getScrollbarSize';
export { default as usePreviousProps } from './usePreviousProps';
Expand Down
1 change: 1 addition & 0 deletions packages/mui-utils/src/useForcedRerendering/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './useForcedRerendering';
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';
import * as React from 'react';

/**
* Copied from @base-ui/utils
*
* Returns a function that forces a rerender.
*/
export default function useForcedRerendering() {
const [, setState] = React.useState({});

return React.useCallback(() => {
setState({});
}, []);
}
Loading