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

[TextareaAutosize] Fix ResizeObserver causing infinite selectionchange loop #45351

Merged
merged 8 commits into from
Mar 6, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -445,4 +445,33 @@ describe('<TextareaAutosize />', () => {
backgroundColor: 'rgb(255, 255, 0)',
});
});

// edge case: https://github.com/mui/material-ui/issues/45307
it('should not infinite loop document selectionchange', async function test() {
// document selectionchange event doesn't fire in JSDOM
if (/jsdom/.test(window.navigator.userAgent)) {
this.skip();
}

const handleSelectionChange = spy();

function App() {
React.useEffect(() => {
document.addEventListener('selectionchange', handleSelectionChange);
return () => {
document.removeEventListener('selectionchange', handleSelectionChange);
};
}, []);

return (
<TextareaAutosize defaultValue="some long text that makes the input start with multiple rows" />
);
}

await render(<App />);
await sleep(100);
// when the component mounts and idles this fires 3 times in browser tests
// and 2 times in a real browser
expect(handleSelectionChange.callCount).to.lessThanOrEqual(3);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

before applying the fix this would fire 61 times in browser tests

after the fix, it's 3 in browser tests, 2 when I test in the browser console, and it doesn't work in the unit tests...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expect(handleSelectionChange.callCount).to.lessThanOrEqual(3)
I'm not really sure what the best way to test this is, just trying to avoid making it slow or potentially flaky, do you think this is good enough? @DiegoAndai

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this should be enough to catch it if it comes back 👍🏼

});
});
44 changes: 30 additions & 14 deletions packages/mui-material/src/TextareaAutosize/TextareaAutosize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
unstable_debounce as debounce,
unstable_useForkRef as useForkRef,
unstable_useEnhancedEffect as useEnhancedEffect,
unstable_useEventCallback as useEventCallback,
unstable_ownerWindow as ownerWindow,
} from '@mui/utils';
import { TextareaAutosizeProps } from './TextareaAutosize.types';
Expand Down Expand Up @@ -129,6 +130,19 @@ const TextareaAutosize = React.forwardRef(function TextareaAutosize(
return { outerHeightStyle, overflowing };
}, [maxRows, minRows, props.placeholder]);

const didHeightChange = useEventCallback(() => {
const textarea = textareaRef.current;
const textareaStyles = calculateTextareaStyles();

if (!textarea || !textareaStyles || isEmpty(textareaStyles)) {
return false;
}

const outerHeightStyle = textareaStyles.outerHeightStyle;

return heightRef.current != null && heightRef.current !== outerHeightStyle;
});

const syncHeight = React.useCallback(() => {
const textarea = textareaRef.current;
const textareaStyles = calculateTextareaStyles();
Expand All @@ -148,7 +162,7 @@ const TextareaAutosize = React.forwardRef(function TextareaAutosize(
const frameRef = React.useRef(-1);

useEnhancedEffect(() => {
const debounceHandleResize = debounce(() => syncHeight());
const debouncedHandleResize = debounce(syncHeight);
const textarea = textareaRef?.current;

if (!textarea) {
Expand All @@ -157,34 +171,36 @@ const TextareaAutosize = React.forwardRef(function TextareaAutosize(

const containerWindow = ownerWindow(textarea);

containerWindow.addEventListener('resize', debounceHandleResize);
containerWindow.addEventListener('resize', debouncedHandleResize);

let resizeObserver: ResizeObserver;

if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
// avoid "ResizeObserver loop completed with undelivered notifications" error
// by temporarily unobserving the textarea element while manipulating the height
// and reobserving one frame later
resizeObserver.unobserve(textarea);
cancelAnimationFrame(frameRef.current);
syncHeight();
frameRef.current = requestAnimationFrame(() => {
resizeObserver.observe(textarea);
});
if (didHeightChange()) {
// avoid "ResizeObserver loop completed with undelivered notifications" error
// by temporarily unobserving the textarea element while manipulating the height
// and reobserving one frame later
resizeObserver.unobserve(textarea);
cancelAnimationFrame(frameRef.current);
syncHeight();
frameRef.current = requestAnimationFrame(() => {
resizeObserver.observe(textarea);
});
}
});
resizeObserver.observe(textarea);
}

return () => {
debounceHandleResize.clear();
debouncedHandleResize.clear();
cancelAnimationFrame(frameRef.current);
containerWindow.removeEventListener('resize', debounceHandleResize);
containerWindow.removeEventListener('resize', debouncedHandleResize);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [calculateTextareaStyles, syncHeight]);
}, [calculateTextareaStyles, syncHeight, didHeightChange]);

useEnhancedEffect(() => {
syncHeight();
Expand Down