Skip to content

Commit 68381cf

Browse files
authored
[material-ui] Performance: lazy Ripple (#41061)
1 parent 43cb327 commit 68381cf

File tree

27 files changed

+460
-500
lines changed

27 files changed

+460
-500
lines changed

packages-internal/test-utils/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@emotion/react": "^11.11.4",
4040
"@testing-library/dom": "^10.3.1",
4141
"@testing-library/react": "^16.0.0",
42+
"@testing-library/user-event": "^14.5.2",
4243
"chai": "^4.4.1",
4344
"chai-dom": "^1.12.0",
4445
"dom-accessibility-api": "^0.6.3",

packages-internal/test-utils/src/createRenderer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
within,
1616
RenderResult,
1717
} from '@testing-library/react/pure';
18+
import { userEvent } from '@testing-library/user-event';
1819
import { useFakeTimers } from 'sinon';
1920

2021
interface Interaction {
@@ -268,6 +269,7 @@ interface ServerRenderConfiguration extends RenderConfiguration {
268269
export type RenderOptions = Partial<RenderConfiguration>;
269270

270271
export interface MuiRenderResult extends RenderResult<typeof queries & typeof customQueries> {
272+
user: ReturnType<typeof userEvent.setup>;
271273
forceUpdate(): void;
272274
/**
273275
* convenience helper. Better than repeating all props.
@@ -296,6 +298,7 @@ function render(
296298
);
297299
const result: MuiRenderResult = {
298300
...testingLibraryRenderResult,
301+
user: userEvent.setup(),
299302
forceUpdate() {
300303
traceSync('forceUpdate', () =>
301304
testingLibraryRenderResult.rerender(

packages-internal/test-utils/src/focusVisible.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export function simulatePointerDevice() {
1414
fireEvent.pointerDown(document.body);
1515
}
1616

17+
export function simulateKeyboardDevice() {
18+
fireEvent.keyDown(document.body, { key: 'TAB' });
19+
}
20+
1721
/**
1822
* See https://bugs.chromium.org/p/chromium/issues/detail?id=1127875 for more details.
1923
*/

packages-internal/test-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './createRenderer';
88
export {
99
default as focusVisible,
1010
simulatePointerDevice,
11+
simulateKeyboardDevice,
1112
programmaticFocusTriggersFocusVisible,
1213
} from './focusVisible';
1314
export {} from './initMatchers';

packages/mui-joy/src/MenuItem/MenuItem.test.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,18 @@ describe('Joy <MenuItem />', () => {
9292
];
9393

9494
events.forEach((eventName) => {
95-
it(`should fire ${eventName}`, () => {
95+
it(`should fire ${eventName}`, async () => {
9696
const handlerName = `on${eventName[0].toUpperCase()}${eventName.slice(1)}`;
9797
const handler = spy();
9898
render(<MenuItem {...{ [handlerName]: handler }} />);
9999

100-
fireEvent[eventName](screen.getByRole('menuitem'));
100+
await act(async () => fireEvent[eventName](screen.getByRole('menuitem')));
101101

102102
expect(handler.callCount).to.equal(1);
103103
});
104104
});
105105

106-
it(`should fire focus, keydown, keyup and blur`, () => {
106+
it(`should fire focus, keydown, keyup and blur`, async () => {
107107
const handleFocus = spy();
108108
const handleKeyDown = spy();
109109
const handleKeyUp = spy();
@@ -119,17 +119,17 @@ describe('Joy <MenuItem />', () => {
119119
);
120120
const menuitem = screen.getByRole('menuitem');
121121

122-
act(() => {
122+
await act(async () => {
123123
menuitem.focus();
124124
});
125125

126126
expect(handleFocus.callCount).to.equal(1);
127127

128-
fireEvent.keyDown(menuitem);
128+
await act(async () => fireEvent.keyDown(menuitem));
129129

130130
expect(handleKeyDown.callCount).to.equal(1);
131131

132-
fireEvent.keyUp(menuitem);
132+
await act(async () => fireEvent.keyUp(menuitem));
133133

134134
expect(handleKeyUp.callCount).to.equal(1);
135135

packages/mui-material/src/Button/Button.test.js

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import * as React from 'react';
22
import { expect } from 'chai';
3-
import { act, createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
3+
import { createRenderer, screen, simulateKeyboardDevice } from '@mui/internal-test-utils';
44
import { ClassNames } from '@emotion/react';
55
import { ThemeProvider, createTheme } from '@mui/material/styles';
66
import Button, { buttonClasses as classes } from '@mui/material/Button';
77
import ButtonBase, { touchRippleClasses } from '@mui/material/ButtonBase';
88
import describeConformance from '../../test/describeConformance';
9+
import * as ripple from '../../test/ripple';
910

1011
describe('<Button />', () => {
1112
const { render, renderToString } = createRenderer();
@@ -555,23 +556,23 @@ describe('<Button />', () => {
555556
expect(endIcon).not.to.have.class(classes.startIcon);
556557
});
557558

558-
it('should have a ripple by default', () => {
559+
it('should have a ripple', async () => {
559560
const { getByRole } = render(
560561
<Button TouchRippleProps={{ className: 'touch-ripple' }}>Hello World</Button>,
561562
);
562563
const button = getByRole('button');
563-
564+
await ripple.startTouch(button);
564565
expect(button.querySelector('.touch-ripple')).not.to.equal(null);
565566
});
566567

567-
it('can disable the ripple', () => {
568+
it('can disable the ripple', async () => {
568569
const { getByRole } = render(
569570
<Button disableRipple TouchRippleProps={{ className: 'touch-ripple' }}>
570571
Hello World
571572
</Button>,
572573
);
573574
const button = getByRole('button');
574-
575+
await ripple.startTouch(button);
575576
expect(button.querySelector('.touch-ripple')).to.equal(null);
576577
});
577578

@@ -582,7 +583,7 @@ describe('<Button />', () => {
582583
expect(button).to.have.class(classes.disableElevation);
583584
});
584585

585-
it('should have a focusRipple by default', function test() {
586+
it('should have a focusRipple', async function test() {
586587
if (/jsdom/.test(window.navigator.userAgent)) {
587588
// JSDOM doesn't support :focus-visible
588589
this.skip();
@@ -595,15 +596,13 @@ describe('<Button />', () => {
595596
);
596597
const button = getByRole('button');
597598

598-
fireEvent.keyDown(document.body, { key: 'TAB' });
599-
act(() => {
600-
button.focus();
601-
});
599+
simulateKeyboardDevice();
600+
await ripple.startFocus(button);
602601

603602
expect(button.querySelector('.pulsate-focus-visible')).not.to.equal(null);
604603
});
605604

606-
it('can disable the focusRipple', function test() {
605+
it('can disable the focusRipple', async function test() {
607606
if (/jsdom/.test(window.navigator.userAgent)) {
608607
// JSDOM doesn't support :focus-visible
609608
this.skip();
@@ -619,10 +618,8 @@ describe('<Button />', () => {
619618
);
620619
const button = getByRole('button');
621620

622-
act(() => {
623-
fireEvent.keyDown(document.body, { key: 'TAB' });
624-
button.focus();
625-
});
621+
simulateKeyboardDevice();
622+
await ripple.startFocus(button);
626623

627624
expect(button.querySelector('.pulsate-focus-visible')).to.equal(null);
628625
});
@@ -676,7 +673,7 @@ describe('<Button />', () => {
676673
expect(container.firstChild.querySelector(`.${touchRippleClasses.root}`)).to.equal(null);
677674
});
678675

679-
it("should disable ripple when MuiButtonBase has disableRipple in theme's defaultProps but override on the individual Buttons if provided", () => {
676+
it("should disable ripple when MuiButtonBase has disableRipple in theme's defaultProps but override on the individual Buttons if provided", async () => {
680677
const theme = createTheme({
681678
components: {
682679
MuiButtonBase: {
@@ -693,6 +690,7 @@ describe('<Button />', () => {
693690
<Button>Disabled ripple 2</Button>
694691
</ThemeProvider>,
695692
);
693+
await ripple.startTouch(container.querySelector('button'));
696694
expect(container.querySelectorAll(`.${touchRippleClasses.root}`)).to.have.length(1);
697695
});
698696

packages/mui-material/src/ButtonBase/ButtonBase.js

Lines changed: 15 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { styled } from '../zero-styled';
1010
import { useDefaultProps } from '../DefaultPropsProvider';
1111
import useForkRef from '../utils/useForkRef';
1212
import useEventCallback from '../utils/useEventCallback';
13+
import useLazyRipple from '../useLazyRipple';
1314
import TouchRipple from './TouchRipple';
1415
import buttonBaseClasses, { getButtonBaseUtilityClass } from './buttonBaseClasses';
1516

@@ -109,8 +110,8 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
109110

110111
const buttonRef = React.useRef(null);
111112

112-
const rippleRef = React.useRef(null);
113-
const handleRippleRef = useForkRef(rippleRef, touchRippleRef);
113+
const ripple = useLazyRipple();
114+
const handleRippleRef = useForkRef(ripple.ref, touchRippleRef);
114115

115116
const [focusVisible, setFocusVisible] = React.useState(false);
116117
if (disabled && focusVisible) {
@@ -128,19 +129,13 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
128129
[],
129130
);
130131

131-
const [mountedState, setMountedState] = React.useState(false);
132+
const enableTouchRipple = ripple.shouldMount && !disableRipple && !disabled;
132133

133134
React.useEffect(() => {
134-
setMountedState(true);
135-
}, []);
136-
137-
const enableTouchRipple = mountedState && !disableRipple && !disabled;
138-
139-
React.useEffect(() => {
140-
if (focusVisible && focusRipple && !disableRipple && mountedState) {
141-
rippleRef.current.pulsate();
135+
if (focusVisible && focusRipple && !disableRipple) {
136+
ripple.pulsate();
142137
}
143-
}, [disableRipple, focusRipple, focusVisible, mountedState]);
138+
}, [disableRipple, focusRipple, focusVisible, ripple]);
144139

145140
function useRippleHandler(rippleAction, eventCallback, skipRippleAction = disableTouchRipple) {
146141
return useEventCallback((event) => {
@@ -149,8 +144,8 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
149144
}
150145

151146
const ignore = skipRippleAction;
152-
if (!ignore && rippleRef.current) {
153-
rippleRef.current[rippleAction](event);
147+
if (!ignore) {
148+
ripple[rippleAction](event);
154149
}
155150

156151
return true;
@@ -212,9 +207,9 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
212207

213208
const handleKeyDown = useEventCallback((event) => {
214209
// Check if key is already down to avoid repeats being counted as multiple activations
215-
if (focusRipple && !event.repeat && focusVisible && rippleRef.current && event.key === ' ') {
216-
rippleRef.current.stop(event, () => {
217-
rippleRef.current.start(event);
210+
if (focusRipple && !event.repeat && focusVisible && event.key === ' ') {
211+
ripple.stop(event, () => {
212+
ripple.start(event);
218213
});
219214
}
220215

@@ -243,15 +238,9 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
243238
const handleKeyUp = useEventCallback((event) => {
244239
// calling preventDefault in keyUp on a <button> will not dispatch a click event if Space is pressed
245240
// https://codesandbox.io/p/sandbox/button-keyup-preventdefault-dn7f0
246-
if (
247-
focusRipple &&
248-
event.key === ' ' &&
249-
rippleRef.current &&
250-
focusVisible &&
251-
!event.defaultPrevented
252-
) {
253-
rippleRef.current.stop(event, () => {
254-
rippleRef.current.pulsate(event);
241+
if (focusRipple && event.key === ' ' && focusVisible && !event.defaultPrevented) {
242+
ripple.stop(event, () => {
243+
ripple.pulsate(event);
255244
});
256245
}
257246
if (onKeyUp) {
@@ -291,20 +280,6 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
291280

292281
const handleRef = useForkRef(ref, buttonRef);
293282

294-
if (process.env.NODE_ENV !== 'production') {
295-
// eslint-disable-next-line react-hooks/rules-of-hooks
296-
React.useEffect(() => {
297-
if (enableTouchRipple && !rippleRef.current) {
298-
console.error(
299-
[
300-
'MUI: The `component` prop provided to ButtonBase is invalid.',
301-
'Please make sure the children prop is rendered in this custom component.',
302-
].join('\n'),
303-
);
304-
}
305-
}, [enableTouchRipple]);
306-
}
307-
308283
const ownerState = {
309284
...props,
310285
centerRipple,
@@ -345,7 +320,6 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
345320
>
346321
{children}
347322
{enableTouchRipple ? (
348-
/* TouchRipple is only needed client-side, x2 boost on the server. */
349323
<TouchRipple ref={handleRippleRef} center={centerRipple} {...TouchRippleProps} />
350324
) : null}
351325
</ButtonBaseRoot>

0 commit comments

Comments
 (0)