diff --git a/docs/data/joy/components/autocomplete/Asynchronous.js b/docs/data/joy/components/autocomplete/Asynchronous.js index a449988fc78e95..82c987f77d8ea6 100644 --- a/docs/data/joy/components/autocomplete/Asynchronous.js +++ b/docs/data/joy/components/autocomplete/Asynchronous.js @@ -4,9 +4,11 @@ import FormLabel from '@mui/joy/FormLabel'; import Autocomplete from '@mui/joy/Autocomplete'; import CircularProgress from '@mui/joy/CircularProgress'; -function sleep(delay = 0) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(resolve, delay); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/docs/data/joy/components/autocomplete/Asynchronous.tsx b/docs/data/joy/components/autocomplete/Asynchronous.tsx index e1379748d3c5bc..36c52369e77e78 100644 --- a/docs/data/joy/components/autocomplete/Asynchronous.tsx +++ b/docs/data/joy/components/autocomplete/Asynchronous.tsx @@ -4,9 +4,11 @@ import FormLabel from '@mui/joy/FormLabel'; import Autocomplete from '@mui/joy/Autocomplete'; import CircularProgress from '@mui/joy/CircularProgress'; -function sleep(delay = 0) { - return new Promise((resolve) => { - setTimeout(resolve, delay); +function sleep(duration: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/docs/data/material/components/autocomplete/Asynchronous.js b/docs/data/material/components/autocomplete/Asynchronous.js index beb9c03846ee17..ada8153aa99fd6 100644 --- a/docs/data/material/components/autocomplete/Asynchronous.js +++ b/docs/data/material/components/autocomplete/Asynchronous.js @@ -3,9 +3,11 @@ import TextField from '@mui/material/TextField'; import Autocomplete from '@mui/material/Autocomplete'; import CircularProgress from '@mui/material/CircularProgress'; -function sleep(delay = 0) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(resolve, delay); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/docs/data/material/components/autocomplete/Asynchronous.tsx b/docs/data/material/components/autocomplete/Asynchronous.tsx index 7fbbd7e62684cd..c2c4e4720ed519 100644 --- a/docs/data/material/components/autocomplete/Asynchronous.tsx +++ b/docs/data/material/components/autocomplete/Asynchronous.tsx @@ -8,9 +8,11 @@ interface Film { year: number; } -function sleep(delay = 0) { - return new Promise((resolve) => { - setTimeout(resolve, delay); +function sleep(duration: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/packages/mui-base/src/TextareaAutosize/TextareaAutosize.test.tsx b/packages/mui-base/src/TextareaAutosize/TextareaAutosize.test.tsx index 1740aa8eb6b37b..3d70576baacd46 100644 --- a/packages/mui-base/src/TextareaAutosize/TextareaAutosize.test.tsx +++ b/packages/mui-base/src/TextareaAutosize/TextareaAutosize.test.tsx @@ -4,6 +4,8 @@ import sinon, { spy, stub } from 'sinon'; import { describeConformanceUnstyled, act, + screen, + waitFor, createMount, createRenderer, fireEvent, @@ -15,6 +17,29 @@ function getStyleValue(value: string) { return parseInt(value, 10) || 0; } +// TODO: merge into a shared test helpers. +// MUI X already have one under mui-x/test/utils/helperFn.ts +function sleep(duration: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); + }); +} + +async function raf() { + return new Promise((resolve) => { + // Chrome and Safari have a bug where calling rAF once returns the current + // frame instead of the next frame, so we need to call a double rAF here. + // See crbug.com/675795 for more. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); +} + describe('', () => { const { clock, render } = createRenderer(); const mount = createMount(); @@ -36,6 +61,109 @@ describe('', () => { ], })); + // For https://github.com/mui/material-ui/pull/33238 + it('should not crash when unmounting with Suspense', async () => { + const LazyRoute = React.lazy(() => { + // Force react to show fallback suspense + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + default: () =>
LazyRoute
, + }); + }, 0); + }); + }); + + function App() { + const [toggle, setToggle] = React.useState(false); + + return ( + + + {toggle ? : } + + ); + } + + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + await waitFor(() => { + expect(screen.queryByText('LazyRoute')).not.to.equal(null); + }); + }); + + // For https://github.com/mui/material-ui/pull/33253 + it('should update height without an infinite rendering loop', async () => { + function App() { + const [value, setValue] = React.useState('Controlled'); + + const handleChange = (event: React.ChangeEvent) => { + setValue(event.target.value); + }; + + return ; + } + const { container } = render(); + const input = container.querySelector('textarea')!; + act(() => { + input.focus(); + }); + const activeElement = document.activeElement!; + // set the value of the input to be 1 larger than its content width + fireEvent.change(activeElement, { + target: { value: 'Controlled\n' }, + }); + await sleep(0); + fireEvent.change(activeElement, { + target: { value: 'Controlled\n\n' }, + }); + }); + + // For https://github.com/mui/material-ui/pull/37135 + it('should update height without delay', async function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + // It depends on ResizeObserver + this.skip(); + } + + function App() { + const ref = React.useRef(null); + return ( +
+ +
+ +
+
+ ); + } + const { container } = render(); + const input = container.querySelector('textarea')!; + const button = screen.getByRole('button'); + expect(parseInt(input.style.height, 10)).to.be.within(30, 32); + fireEvent.click(button); + await raf(); + await raf(); + expect(parseInt(input.style.height, 10)).to.be.within(15, 17); + }); + describe('layout', () => { const getComputedStyleStub = new Map>(); function setLayout( diff --git a/packages/mui-base/src/TextareaAutosize/TextareaAutosize.tsx b/packages/mui-base/src/TextareaAutosize/TextareaAutosize.tsx index 164dbd478a2bde..9f582765f6e932 100644 --- a/packages/mui-base/src/TextareaAutosize/TextareaAutosize.tsx +++ b/packages/mui-base/src/TextareaAutosize/TextareaAutosize.tsx @@ -163,71 +163,66 @@ const TextareaAutosize = React.forwardRef(function TextareaAutosize( return; } - setState((prevState) => { - return updateState(prevState, newState); - }); + setState((prevState) => updateState(prevState, newState)); }, [getUpdatedState]); - const syncHeightWithFlushSync = () => { - const newState = getUpdatedState(); + useEnhancedEffect(() => { + const syncHeightWithFlushSync = () => { + const newState = getUpdatedState(); - if (isEmpty(newState)) { - return; - } + if (isEmpty(newState)) { + return; + } - // In React 18, state updates in a ResizeObserver's callback are happening after the paint which causes flickering - // when doing some visual updates in it. Using flushSync ensures that the dom will be painted after the states updates happen - // Related issue - https://github.com/facebook/react/issues/24331 - ReactDOM.flushSync(() => { - setState((prevState) => { - return updateState(prevState, newState); + // In React 18, state updates in a ResizeObserver's callback are happening after + // the paint, this leads to an infinite rendering. + // + // Using flushSync ensures that the states is updated before the next pain. + // Related issue - https://github.com/facebook/react/issues/24331 + ReactDOM.flushSync(() => { + setState((prevState) => updateState(prevState, newState)); }); - }); - }; + }; - React.useEffect(() => { const handleResize = () => { renders.current = 0; - - // If the TextareaAutosize component is replaced by Suspense with a fallback, the last - // ResizeObserver's handler that runs because of the change in the layout is trying to - // access a dom node that is no longer there (as the fallback component is being shown instead). - // See https://github.com/mui/material-ui/issues/32640 - if (inputRef.current) { - syncHeightWithFlushSync(); - } + syncHeightWithFlushSync(); }; - const handleResizeWindow = debounce(() => { - renders.current = 0; - - // If the TextareaAutosize component is replaced by Suspense with a fallback, the last - // ResizeObserver's handler that runs because of the change in the layout is trying to - // access a dom node that is no longer there (as the fallback component is being shown instead). - // See https://github.com/mui/material-ui/issues/32640 - if (inputRef.current) { - syncHeightWithFlushSync(); - } - }); - let resizeObserver: ResizeObserver; - + // Workaround a "ResizeObserver loop completed with undelivered notifications" error + // in test. + // Note that we might need to use this logic in production per https://github.com/WICG/resize-observer/issues/38 + // Also see https://github.com/mui/mui-x/issues/8733 + let rAF: any; + const rAFHandleResize = () => { + cancelAnimationFrame(rAF); + rAF = requestAnimationFrame(() => { + handleResize(); + }); + }; + const debounceHandleResize = debounce(handleResize); const input = inputRef.current!; const containerWindow = ownerWindow(input); - containerWindow.addEventListener('resize', handleResizeWindow); + containerWindow.addEventListener('resize', debounceHandleResize); + + let resizeObserver: ResizeObserver; if (typeof ResizeObserver !== 'undefined') { - resizeObserver = new ResizeObserver(handleResize); + resizeObserver = new ResizeObserver( + process.env.NODE_ENV === 'test' ? rAFHandleResize : handleResize, + ); resizeObserver.observe(input); } return () => { - handleResizeWindow.clear(); - containerWindow.removeEventListener('resize', handleResizeWindow); + debounceHandleResize.clear(); + cancelAnimationFrame(rAF); + containerWindow.removeEventListener('resize', debounceHandleResize); if (resizeObserver) { resizeObserver.disconnect(); } }; - }); + }, [getUpdatedState]); useEnhancedEffect(() => { syncHeight(); diff --git a/packages/mui-lab/src/Masonry/Masonry.js b/packages/mui-lab/src/Masonry/Masonry.js index b29c236c3f4cfe..c48e32772f9170 100644 --- a/packages/mui-lab/src/Masonry/Masonry.js +++ b/packages/mui-lab/src/Masonry/Masonry.js @@ -287,7 +287,7 @@ const Masonry = React.forwardRef(function Masonry(inProps, ref) { const resizeObserver = new ResizeObserver(() => { // see https://github.com/mui/material-ui/issues/36909 - animationFrame = window.requestAnimationFrame(handleResize); + animationFrame = requestAnimationFrame(handleResize); }); if (masonryRef.current) { diff --git a/packages/waterfall/sleep.mjs b/packages/waterfall/sleep.mjs index 1ca84351fa20ba..608d27ddcf4371 100644 --- a/packages/waterfall/sleep.mjs +++ b/packages/waterfall/sleep.mjs @@ -1,6 +1,8 @@ -function sleep(delay = 0) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(resolve, delay); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/test/bundling/fixtures/create-react-app/testCreateReactAppIntegration.js b/test/bundling/fixtures/create-react-app/testCreateReactAppIntegration.js index 2142cdc2cd2230..740140e7d79fed 100644 --- a/test/bundling/fixtures/create-react-app/testCreateReactAppIntegration.js +++ b/test/bundling/fixtures/create-react-app/testCreateReactAppIntegration.js @@ -4,9 +4,11 @@ const playwright = require('playwright'); * @param {number} timeoutMS * @returns {Promise} */ -function sleep(timeoutMS) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMS); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/test/bundling/fixtures/esbuild/testEsbuildIntegration.js b/test/bundling/fixtures/esbuild/testEsbuildIntegration.js index 2142cdc2cd2230..ce6eaa4f493a5d 100644 --- a/test/bundling/fixtures/esbuild/testEsbuildIntegration.js +++ b/test/bundling/fixtures/esbuild/testEsbuildIntegration.js @@ -4,12 +4,13 @@ const playwright = require('playwright'); * @param {number} timeoutMS * @returns {Promise} */ -function sleep(timeoutMS) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMS); + setTimeout(() => { + resolve(); + }, duration); }); } - /** * Attempts page.goto with retries * diff --git a/test/bundling/fixtures/gatsby/testGatsbyIntegration.js b/test/bundling/fixtures/gatsby/testGatsbyIntegration.js index 639c6027069a42..5425d43055406c 100644 --- a/test/bundling/fixtures/gatsby/testGatsbyIntegration.js +++ b/test/bundling/fixtures/gatsby/testGatsbyIntegration.js @@ -4,9 +4,11 @@ const playwright = require('playwright'); * @param {number} timeoutMS * @returns {Promise} */ -function sleep(timeoutMS) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMS); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/test/bundling/fixtures/next-webpack4/testNextWebpack4Integration.js b/test/bundling/fixtures/next-webpack4/testNextWebpack4Integration.js index 5f6fc1c70e3dba..23630f301c8834 100644 --- a/test/bundling/fixtures/next-webpack4/testNextWebpack4Integration.js +++ b/test/bundling/fixtures/next-webpack4/testNextWebpack4Integration.js @@ -4,9 +4,11 @@ const playwright = require('playwright'); * @param {number} timeoutMS * @returns {Promise} */ -function sleep(timeoutMS) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMS); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/test/bundling/fixtures/next-webpack5/testNextWebpack5Integration.js b/test/bundling/fixtures/next-webpack5/testNextWebpack5Integration.js index 5f6fc1c70e3dba..23630f301c8834 100644 --- a/test/bundling/fixtures/next-webpack5/testNextWebpack5Integration.js +++ b/test/bundling/fixtures/next-webpack5/testNextWebpack5Integration.js @@ -4,9 +4,11 @@ const playwright = require('playwright'); * @param {number} timeoutMS * @returns {Promise} */ -function sleep(timeoutMS) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMS); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/test/bundling/fixtures/snowpack/testSnowpackIntegration.js b/test/bundling/fixtures/snowpack/testSnowpackIntegration.js index 32ef4ef36b0fa5..0885ee87964da4 100644 --- a/test/bundling/fixtures/snowpack/testSnowpackIntegration.js +++ b/test/bundling/fixtures/snowpack/testSnowpackIntegration.js @@ -4,9 +4,11 @@ const playwright = require('playwright'); * @param {number} timeoutMS * @returns {Promise} */ -function sleep(timeoutMS) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMS); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/test/bundling/fixtures/vite/testViteIntegration.js b/test/bundling/fixtures/vite/testViteIntegration.js index 2142cdc2cd2230..740140e7d79fed 100644 --- a/test/bundling/fixtures/vite/testViteIntegration.js +++ b/test/bundling/fixtures/vite/testViteIntegration.js @@ -4,9 +4,11 @@ const playwright = require('playwright'); * @param {number} timeoutMS * @returns {Promise} */ -function sleep(timeoutMS) { +function sleep(duration) { return new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMS); + setTimeout(() => { + resolve(); + }, duration); }); } diff --git a/test/e2e/index.test.ts b/test/e2e/index.test.ts index b944ad1a625602..2014d81fe96f74 100644 --- a/test/e2e/index.test.ts +++ b/test/e2e/index.test.ts @@ -9,9 +9,11 @@ import type { } from '@testing-library/dom'; import '../utils/initPlaywrightMatchers'; -function sleep(timeoutMS: number): Promise { - return new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMS); +function sleep(duration: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); }); }