Skip to content

Commit

Permalink
[TextareaAutosize] Simplify logic and add test (mui#38728)
Browse files Browse the repository at this point in the history
  • Loading branch information
oliviertassinari authored and christophermorin committed Sep 21, 2023
1 parent 2e5faca commit 9131d9f
Show file tree
Hide file tree
Showing 16 changed files with 222 additions and 74 deletions.
6 changes: 4 additions & 2 deletions docs/data/joy/components/autocomplete/Asynchronous.js
Expand Up @@ -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);
});
}

Expand Down
8 changes: 5 additions & 3 deletions docs/data/joy/components/autocomplete/Asynchronous.tsx
Expand Up @@ -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<void> {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}

Expand Down
6 changes: 4 additions & 2 deletions docs/data/material/components/autocomplete/Asynchronous.js
Expand Up @@ -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);
});
}

Expand Down
8 changes: 5 additions & 3 deletions docs/data/material/components/autocomplete/Asynchronous.tsx
Expand Up @@ -8,9 +8,11 @@ interface Film {
year: number;
}

function sleep(delay = 0) {
return new Promise((resolve) => {
setTimeout(resolve, delay);
function sleep(duration: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}

Expand Down
128 changes: 128 additions & 0 deletions packages/mui-base/src/TextareaAutosize/TextareaAutosize.test.tsx
Expand Up @@ -4,6 +4,8 @@ import sinon, { spy, stub } from 'sinon';
import {
describeConformanceUnstyled,
act,
screen,
waitFor,
createMount,
createRenderer,
fireEvent,
Expand All @@ -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<void> {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}

async function raf() {
return new Promise<void>((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('<TextareaAutosize />', () => {
const { clock, render } = createRenderer();
const mount = createMount();
Expand All @@ -36,6 +61,109 @@ describe('<TextareaAutosize />', () => {
],
}));

// 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<any>((resolve) => {
setTimeout(() => {
resolve({
default: () => <div>LazyRoute</div>,
});
}, 0);
});
});

function App() {
const [toggle, setToggle] = React.useState(false);

return (
<React.Suspense fallback={null}>
<button onClick={() => setToggle((r) => !r)}>Toggle</button>
{toggle ? <LazyRoute /> : <TextareaAutosize />}
</React.Suspense>
);
}

render(<App />);
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<any>) => {
setValue(event.target.value);
};

return <TextareaAutosize value={value} onChange={handleChange} />;
}
const { container } = render(<App />);
const input = container.querySelector<HTMLTextAreaElement>('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<HTMLTextAreaElement>(null);
return (
<div>
<button
onClick={() => {
ref.current!.style.width = '250px';
}}
>
change
</button>
<div>
<TextareaAutosize
ref={ref}
style={{
width: 150,
padding: 0,
fontSize: 14,
lineHeight: '15px',
border: '1px solid',
}}
defaultValue="qdzqzd qzd qzd qzd qz dqz"
/>
</div>
</div>
);
}
const { container } = render(<App />);
const input = container.querySelector<HTMLTextAreaElement>('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<Element, Partial<CSSStyleDeclaration>>();
function setLayout(
Expand Down
81 changes: 38 additions & 43 deletions packages/mui-base/src/TextareaAutosize/TextareaAutosize.tsx
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion packages/mui-lab/src/Masonry/Masonry.js
Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions 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);
});
}

Expand Down
Expand Up @@ -4,9 +4,11 @@ const playwright = require('playwright');
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(timeoutMS) {
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => resolve(), timeoutMS);
setTimeout(() => {
resolve();
}, duration);
});
}

Expand Down
7 changes: 4 additions & 3 deletions test/bundling/fixtures/esbuild/testEsbuildIntegration.js
Expand Up @@ -4,12 +4,13 @@ const playwright = require('playwright');
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(timeoutMS) {
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => resolve(), timeoutMS);
setTimeout(() => {
resolve();
}, duration);
});
}

/**
* Attempts page.goto with retries
*
Expand Down
6 changes: 4 additions & 2 deletions test/bundling/fixtures/gatsby/testGatsbyIntegration.js
Expand Up @@ -4,9 +4,11 @@ const playwright = require('playwright');
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(timeoutMS) {
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => resolve(), timeoutMS);
setTimeout(() => {
resolve();
}, duration);
});
}

Expand Down
Expand Up @@ -4,9 +4,11 @@ const playwright = require('playwright');
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(timeoutMS) {
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => resolve(), timeoutMS);
setTimeout(() => {
resolve();
}, duration);
});
}

Expand Down

0 comments on commit 9131d9f

Please sign in to comment.