From 000f91be48fb5f1725ede51e0fa02237788890ff Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Tue, 7 Apr 2026 15:50:07 +0800 Subject: [PATCH 1/2] [transitions] Performance improvements & misc fixes (#48151) --- .../mui-material/src/Collapse/Collapse.js | 27 ++---- packages/mui-material/src/Fade/Fade.js | 81 +++++++++-------- packages/mui-material/src/Grow/Grow.js | 89 ++++++++----------- packages/mui-material/src/Slide/Slide.js | 89 +++++++++---------- packages/mui-material/src/Slide/Slide.test.js | 37 +++++--- .../src/SwipeableDrawer/SwipeableDrawer.js | 3 - packages/mui-material/src/Zoom/Zoom.js | 75 ++++++++-------- .../src/transitions/utils.test.ts | 50 +++++++++++ .../mui-material/src/transitions/utils.ts | 36 ++++++++ 9 files changed, 270 insertions(+), 217 deletions(-) create mode 100644 packages/mui-material/src/transitions/utils.test.ts diff --git a/packages/mui-material/src/Collapse/Collapse.js b/packages/mui-material/src/Collapse/Collapse.js index 5eff6ce1dcd869..4d773d62e8e91f 100644 --- a/packages/mui-material/src/Collapse/Collapse.js +++ b/packages/mui-material/src/Collapse/Collapse.js @@ -10,7 +10,7 @@ import { styled, useTheme } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; import { duration } from '../styles/createTransitions'; -import { getTransitionProps } from '../transitions/utils'; +import { normalizedTransitionCallback, getTransitionProps } from '../transitions/utils'; import { useForkRef } from '../utils'; import useSlot from '../utils/useSlot'; import { getCollapseUtilityClass } from './collapseClasses'; @@ -179,23 +179,10 @@ const Collapse = React.forwardRef(function Collapse(inProps, ref) { const nodeRef = React.useRef(null); const handleRef = useForkRef(ref, nodeRef); - const normalizedTransitionCallback = (callback) => (maybeIsAppearing) => { - if (callback) { - const node = nodeRef.current; - - // onEnterXxx and onExitXxx callbacks have a different arguments.length value. - if (maybeIsAppearing === undefined) { - callback(node); - } else { - callback(node, maybeIsAppearing); - } - } - }; - const getWrapperSize = () => wrapperRef.current ? wrapperRef.current[isHorizontal ? 'clientWidth' : 'clientHeight'] : 0; - const handleEnter = normalizedTransitionCallback((node, isAppearing) => { + const handleEnter = normalizedTransitionCallback(nodeRef, (node, isAppearing) => { if (wrapperRef.current && isHorizontal) { // Set absolute position to get the size of collapsed content wrapperRef.current.style.position = 'absolute'; @@ -207,7 +194,7 @@ const Collapse = React.forwardRef(function Collapse(inProps, ref) { } }); - const handleEntering = normalizedTransitionCallback((node, isAppearing) => { + const handleEntering = normalizedTransitionCallback(nodeRef, (node, isAppearing) => { const wrapperSize = getWrapperSize(); if (wrapperRef.current && isHorizontal) { @@ -239,7 +226,7 @@ const Collapse = React.forwardRef(function Collapse(inProps, ref) { } }); - const handleEntered = normalizedTransitionCallback((node, isAppearing) => { + const handleEntered = normalizedTransitionCallback(nodeRef, (node, isAppearing) => { node.style[size] = 'auto'; if (onEntered) { @@ -247,7 +234,7 @@ const Collapse = React.forwardRef(function Collapse(inProps, ref) { } }); - const handleExit = normalizedTransitionCallback((node) => { + const handleExit = normalizedTransitionCallback(nodeRef, (node) => { node.style[size] = `${getWrapperSize()}px`; if (onExit) { @@ -255,9 +242,9 @@ const Collapse = React.forwardRef(function Collapse(inProps, ref) { } }); - const handleExited = normalizedTransitionCallback(onExited); + const handleExited = normalizedTransitionCallback(nodeRef, onExited); - const handleExiting = normalizedTransitionCallback((node) => { + const handleExiting = normalizedTransitionCallback(nodeRef, (node) => { const wrapperSize = getWrapperSize(); const { duration: transitionDuration, easing: transitionTimingFunction } = getTransitionProps( { style, timeout, easing }, diff --git a/packages/mui-material/src/Fade/Fade.js b/packages/mui-material/src/Fade/Fade.js index 44d4b1ee364b52..302dc425a8dd89 100644 --- a/packages/mui-material/src/Fade/Fade.js +++ b/packages/mui-material/src/Fade/Fade.js @@ -5,18 +5,23 @@ import { Transition } from 'react-transition-group'; import elementAcceptingRef from '@mui/utils/elementAcceptingRef'; import getReactElementRef from '@mui/utils/getReactElementRef'; import { useTheme } from '../zero-styled'; -import { reflow, getTransitionProps } from '../transitions/utils'; +import { + normalizedTransitionCallback, + reflow, + getTransitionProps, + getTransitionChildStyle, +} from '../transitions/utils'; import useForkRef from '../utils/useForkRef'; const styles = { - entering: { - opacity: 1, - }, - entered: { - opacity: 1, - }, + entering: { opacity: 1 }, + entered: { opacity: 1 }, + exiting: { opacity: 0 }, + exited: { opacity: 0 }, }; +const hiddenStyles = { opacity: 0, visibility: 'hidden' }; + /** * The Fade transition is used by the [Modal](/material-ui/react-modal/) component. * It uses [react-transition-group](https://github.com/reactjs/react-transition-group) internally. @@ -42,31 +47,15 @@ const Fade = React.forwardRef(function Fade(props, ref) { onExiting, style, timeout = defaultTimeout, - // eslint-disable-next-line react/prop-types - TransitionComponent = Transition, ...other } = props; - const enableStrictModeCompat = true; const nodeRef = React.useRef(null); const handleRef = useForkRef(nodeRef, getReactElementRef(children), ref); - const normalizedTransitionCallback = (callback) => (maybeIsAppearing) => { - if (callback) { - const node = nodeRef.current; - - // onEnterXxx and onExitXxx callbacks have a different arguments.length value. - if (maybeIsAppearing === undefined) { - callback(node); - } else { - callback(node, maybeIsAppearing); - } - } - }; - - const handleEntering = normalizedTransitionCallback(onEntering); + const handleEntering = normalizedTransitionCallback(nodeRef, onEntering); - const handleEnter = normalizedTransitionCallback((node, isAppearing) => { + const handleEnter = normalizedTransitionCallback(nodeRef, (node, isAppearing) => { reflow(node); // So the animation always start from the start. const transitionProps = getTransitionProps( @@ -76,7 +65,6 @@ const Fade = React.forwardRef(function Fade(props, ref) { }, ); - node.style.webkitTransition = theme.transitions.create('opacity', transitionProps); node.style.transition = theme.transitions.create('opacity', transitionProps); if (onEnter) { @@ -84,11 +72,11 @@ const Fade = React.forwardRef(function Fade(props, ref) { } }); - const handleEntered = normalizedTransitionCallback(onEntered); + const handleEntered = normalizedTransitionCallback(nodeRef, onEntered); - const handleExiting = normalizedTransitionCallback(onExiting); + const handleExiting = normalizedTransitionCallback(nodeRef, onExiting); - const handleExit = normalizedTransitionCallback((node) => { + const handleExit = normalizedTransitionCallback(nodeRef, (node) => { const transitionProps = getTransitionProps( { style, timeout, easing }, { @@ -96,7 +84,6 @@ const Fade = React.forwardRef(function Fade(props, ref) { }, ); - node.style.webkitTransition = theme.transitions.create('opacity', transitionProps); node.style.transition = theme.transitions.create('opacity', transitionProps); if (onExit) { @@ -104,7 +91,16 @@ const Fade = React.forwardRef(function Fade(props, ref) { } }); - const handleExited = normalizedTransitionCallback(onExited); + const handleExited = normalizedTransitionCallback(nodeRef, (node) => { + // Clear the transition CSS to release the compositor layer when the + // element is fully exited (prevents idle CPU usage on fixed elements + // like Backdrop). handleEnter re-sets it on the next open. + node.style.transition = ''; + + if (onExited) { + onExited(node); + } + }); const handleAddEndListener = (next) => { if (addEndListener) { @@ -114,10 +110,10 @@ const Fade = React.forwardRef(function Fade(props, ref) { }; return ( - {/* Ensure "ownerState" is not forwarded to the child DOM element when a direct HTML element is used. This avoids unexpected behavior since "ownerState" is intended for internal styling, component props and not as a DOM attribute. */} {(state, { ownerState, ...restChildProps }) => { + const childStyle = getTransitionChildStyle( + state, + inProp, + styles, + hiddenStyles, + style, + children.props.style, + ); + return React.cloneElement(children, { - style: { - opacity: 0, - visibility: state === 'exited' && !inProp ? 'hidden' : undefined, - ...styles[state], - ...style, - ...children.props.style, - }, + style: childStyle, ref: handleRef, ...restChildProps, }); }} - + ); }); diff --git a/packages/mui-material/src/Grow/Grow.js b/packages/mui-material/src/Grow/Grow.js index 3f99d4b2ba6fb7..cf4e7a59d0e15e 100644 --- a/packages/mui-material/src/Grow/Grow.js +++ b/packages/mui-material/src/Grow/Grow.js @@ -6,7 +6,12 @@ import elementAcceptingRef from '@mui/utils/elementAcceptingRef'; import getReactElementRef from '@mui/utils/getReactElementRef'; import { Transition } from 'react-transition-group'; import { useTheme } from '../zero-styled'; -import { getTransitionProps, reflow } from '../transitions/utils'; +import { + normalizedTransitionCallback, + getTransitionProps, + getTransitionChildStyle, + reflow, +} from '../transitions/utils'; import useForkRef from '../utils/useForkRef'; function getScale(value) { @@ -14,24 +19,13 @@ function getScale(value) { } const styles = { - entering: { - opacity: 1, - transform: getScale(1), - }, - entered: { - opacity: 1, - transform: 'none', - }, + entering: { opacity: 1, transform: getScale(1) }, + entered: { opacity: 1, transform: 'none' }, + exiting: { opacity: 0, transform: getScale(0.75) }, + exited: { opacity: 0, transform: getScale(0.75) }, }; -/* - TODO v6: remove - Conditionally apply a workaround for the CSS transition bug in Safari 15.4 / WebKit browsers. - */ -const isWebKit154 = - typeof navigator !== 'undefined' && - /^((?!chrome|android).)*(safari|mobile)/i.test(navigator.userAgent) && - /(os |version\/)15(.|_)4/i.test(navigator.userAgent); +const hiddenStyles = { opacity: 0, transform: getScale(0.75), visibility: 'hidden' }; /** * The Grow transition is used by the [Tooltip](/material-ui/react-tooltip/) and @@ -53,8 +47,6 @@ const Grow = React.forwardRef(function Grow(props, ref) { onExiting, style, timeout = 'auto', - // eslint-disable-next-line react/prop-types - TransitionComponent = Transition, ...other } = props; const timer = useTimeout(); @@ -64,22 +56,9 @@ const Grow = React.forwardRef(function Grow(props, ref) { const nodeRef = React.useRef(null); const handleRef = useForkRef(nodeRef, getReactElementRef(children), ref); - const normalizedTransitionCallback = (callback) => (maybeIsAppearing) => { - if (callback) { - const node = nodeRef.current; - - // onEnterXxx and onExitXxx callbacks have a different arguments.length value. - if (maybeIsAppearing === undefined) { - callback(node); - } else { - callback(node, maybeIsAppearing); - } - } - }; - - const handleEntering = normalizedTransitionCallback(onEntering); + const handleEntering = normalizedTransitionCallback(nodeRef, onEntering); - const handleEnter = normalizedTransitionCallback((node, isAppearing) => { + const handleEnter = normalizedTransitionCallback(nodeRef, (node, isAppearing) => { reflow(node); // So the animation always start from the start. const { @@ -107,7 +86,7 @@ const Grow = React.forwardRef(function Grow(props, ref) { delay, }), theme.transitions.create('transform', { - duration: isWebKit154 ? duration : duration * 0.666, + duration: duration * 0.666, delay, easing: transitionTimingFunction, }), @@ -118,11 +97,11 @@ const Grow = React.forwardRef(function Grow(props, ref) { } }); - const handleEntered = normalizedTransitionCallback(onEntered); + const handleEntered = normalizedTransitionCallback(nodeRef, onEntered); - const handleExiting = normalizedTransitionCallback(onExiting); + const handleExiting = normalizedTransitionCallback(nodeRef, onExiting); - const handleExit = normalizedTransitionCallback((node) => { + const handleExit = normalizedTransitionCallback(nodeRef, (node) => { const { duration: transitionDuration, delay, @@ -148,8 +127,8 @@ const Grow = React.forwardRef(function Grow(props, ref) { delay, }), theme.transitions.create('transform', { - duration: isWebKit154 ? duration : duration * 0.666, - delay: isWebKit154 ? delay : delay || duration * 0.333, + duration: duration * 0.666, + delay: delay || duration * 0.333, easing: transitionTimingFunction, }), ].join(','); @@ -162,7 +141,13 @@ const Grow = React.forwardRef(function Grow(props, ref) { } }); - const handleExited = normalizedTransitionCallback(onExited); + const handleExited = normalizedTransitionCallback(nodeRef, (node) => { + node.style.transition = ''; + + if (onExited) { + onExited(node); + } + }); const handleAddEndListener = (next) => { if (timeout === 'auto') { @@ -175,7 +160,7 @@ const Grow = React.forwardRef(function Grow(props, ref) { }; return ( - {/* Ensure "ownerState" is not forwarded to the child DOM element when a direct HTML element is used. This avoids unexpected behavior since "ownerState" is intended for internal styling, component props and not as a DOM attribute. */} {(state, { ownerState, ...restChildProps }) => { + const childStyle = getTransitionChildStyle( + state, + inProp, + styles, + hiddenStyles, + style, + children.props.style, + ); + return React.cloneElement(children, { - style: { - opacity: 0, - transform: getScale(0.75), - visibility: state === 'exited' && !inProp ? 'hidden' : undefined, - ...styles[state], - ...style, - ...children.props.style, - }, + style: childStyle, ref: handleRef, ...restChildProps, }); }} - + ); }); diff --git a/packages/mui-material/src/Slide/Slide.js b/packages/mui-material/src/Slide/Slide.js index 75718dac338c02..279237ad65db0f 100644 --- a/packages/mui-material/src/Slide/Slide.js +++ b/packages/mui-material/src/Slide/Slide.js @@ -10,25 +10,31 @@ import isLayoutSupported from '../utils/isLayoutSupported'; import debounce from '../utils/debounce'; import useForkRef from '../utils/useForkRef'; import { useTheme } from '../zero-styled'; -import { reflow, getTransitionProps } from '../transitions/utils'; +import { normalizedTransitionCallback, reflow, getTransitionProps } from '../transitions/utils'; import { ownerWindow } from '../utils'; +const hiddenStyles = { visibility: 'hidden' }; + // Translate the node so it can't be seen on the screen. // Later, we're going to translate the node back to its original location with `none`. function getTranslateValue(direction, node, resolvedContainer) { - const rect = node.getBoundingClientRect(); const containerRect = resolvedContainer && resolvedContainer.getBoundingClientRect(); const containerWindow = ownerWindow(node); - let transform; - - if (node.fakeTransform) { - transform = node.fakeTransform; - } else { - const computedStyle = containerWindow.getComputedStyle(node); - transform = - computedStyle.getPropertyValue('-webkit-transform') || - computedStyle.getPropertyValue('transform'); - } + + // Clear the inline transform and transition before reading layout and computed + // style so we compute from the element's natural position, not its previous + // off-screen translation. The transition must also be cleared, otherwise the + // browser may report an animated intermediate value from a still-running + // enter transition when reading getComputedStyle during exit. + const previousTransform = node.style.transform; + const previousTransition = node.style.transition; + node.style.transition = ''; + node.style.transform = ''; + const rect = node.getBoundingClientRect(); + const computedStyle = containerWindow.getComputedStyle(node); + const transform = computedStyle.getPropertyValue('transform'); + node.style.transform = previousTransform; + node.style.transition = previousTransition; let offsetX = 0; let offsetY = 0; @@ -78,7 +84,6 @@ export function setTranslateValue(direction, node, containerProp) { const transform = getTranslateValue(direction, node, resolvedContainer); if (transform) { - node.style.webkitTransform = transform; node.style.transform = transform; } } @@ -115,26 +120,13 @@ const Slide = React.forwardRef(function Slide(props, ref) { onExiting, style, timeout = defaultTimeout, - // eslint-disable-next-line react/prop-types - TransitionComponent = Transition, ...other } = props; const childrenRef = React.useRef(null); const handleRef = useForkRef(getReactElementRef(children), childrenRef, ref); - const normalizedTransitionCallback = (callback) => (isAppearing) => { - if (callback) { - // onEnterXxx and onExitXxx callbacks have a different arguments.length value. - if (isAppearing === undefined) { - callback(childrenRef.current); - } else { - callback(childrenRef.current, isAppearing); - } - } - }; - - const handleEnter = normalizedTransitionCallback((node, isAppearing) => { + const handleEnter = normalizedTransitionCallback(childrenRef, (node, isAppearing) => { setTranslateValue(direction, node, containerProp); reflow(node); @@ -143,7 +135,7 @@ const Slide = React.forwardRef(function Slide(props, ref) { } }); - const handleEntering = normalizedTransitionCallback((node, isAppearing) => { + const handleEntering = normalizedTransitionCallback(childrenRef, (node, isAppearing) => { const transitionProps = getTransitionProps( { timeout, style, easing: easingProp }, { @@ -151,25 +143,18 @@ const Slide = React.forwardRef(function Slide(props, ref) { }, ); - node.style.webkitTransition = theme.transitions.create('-webkit-transform', { - ...transitionProps, - }); - - node.style.transition = theme.transitions.create('transform', { - ...transitionProps, - }); + node.style.transition = theme.transitions.create('transform', transitionProps); - node.style.webkitTransform = 'none'; node.style.transform = 'none'; if (onEntering) { onEntering(node, isAppearing); } }); - const handleEntered = normalizedTransitionCallback(onEntered); - const handleExiting = normalizedTransitionCallback(onExiting); + const handleEntered = normalizedTransitionCallback(childrenRef, onEntered); + const handleExiting = normalizedTransitionCallback(childrenRef, onExiting); - const handleExit = normalizedTransitionCallback((node) => { + const handleExit = normalizedTransitionCallback(childrenRef, (node) => { const transitionProps = getTransitionProps( { timeout, style, easing: easingProp }, { @@ -177,7 +162,6 @@ const Slide = React.forwardRef(function Slide(props, ref) { }, ); - node.style.webkitTransition = theme.transitions.create('-webkit-transform', transitionProps); node.style.transition = theme.transitions.create('transform', transitionProps); setTranslateValue(direction, node, containerProp); @@ -187,9 +171,8 @@ const Slide = React.forwardRef(function Slide(props, ref) { } }); - const handleExited = normalizedTransitionCallback((node) => { + const handleExited = normalizedTransitionCallback(childrenRef, (node) => { // No need for transitions when the component is hidden - node.style.webkitTransition = ''; node.style.transition = ''; if (onExited) { @@ -239,7 +222,7 @@ const Slide = React.forwardRef(function Slide(props, ref) { }, [inProp, updatePosition]); return ( - {/* Ensure "ownerState" is not forwarded to the child DOM element when a direct HTML element is used. This avoids unexpected behavior since "ownerState" is intended for internal styling, component props and not as a DOM attribute. */} {(state, { ownerState, ...restChildProps }) => { + let childStyle; + if (state === 'exited' && !inProp) { + childStyle = + style || children.props.style + ? { visibility: 'hidden', ...style, ...children.props.style } + : hiddenStyles; + } else if (style && children.props.style) { + childStyle = { ...style, ...children.props.style }; + } else { + childStyle = style || children.props.style; + } + return React.cloneElement(children, { ref: handleRef, - style: { - visibility: state === 'exited' && !inProp ? 'hidden' : undefined, - ...style, - ...children.props.style, - }, + style: childStyle, ...restChildProps, }); }} - + ); }); diff --git a/packages/mui-material/src/Slide/Slide.test.js b/packages/mui-material/src/Slide/Slide.test.js index d0b4e5fa015782..ead162bb09f96a 100644 --- a/packages/mui-material/src/Slide/Slide.test.js +++ b/packages/mui-material/src/Slide/Slide.test.js @@ -289,7 +289,6 @@ describe('', () => { const FakeDiv = React.forwardRef(({ rect, ...props }, ref) => { const stubBoundingClientRect = (element) => { if (element !== null) { - element.fakeTransform = 'none'; try { stub(element, 'getBoundingClientRect').callsFake(() => { const r = { @@ -593,21 +592,31 @@ describe('', () => { expect(child.style.transform).not.to.equal(undefined); }); - it('should take existing transform into account', () => { - const element = { - fakeTransform: 'transform matrix(1, 0, 0, 1, 0, 420)', - getBoundingClientRect: () => ({ - width: 500, - height: 300, - left: 300, - right: 800, - top: 1200, - bottom: 1500, - }), - style: {}, - }; + // getComputedStyle in a real browser resolves CSS transforms to matrix() format, + // which the component parses to account for existing offsets. jsdom does not resolve + // CSS values, so this test only runs in browser environments. + // The transform must come from a CSS rule (not inline style) because getTranslateValue + // clears inline transforms before reading computed style. + it.skipIf(isJsdom())('should take existing transform into account', function test() { + const styleEl = document.createElement('style'); + styleEl.textContent = '#slide-test-transform { transform: matrix(1, 0, 0, 1, 0, 420); }'; + document.head.appendChild(styleEl); + const element = document.createElement('div'); + element.id = 'slide-test-transform'; + document.body.appendChild(element); + stub(element, 'getBoundingClientRect').callsFake(() => ({ + width: 500, + height: 300, + left: 300, + right: 800, + top: 1200, + bottom: 1500, + })); setTranslateValue('up', element); expect(element.style.transform).to.equal(`translateY(${globalThis.innerHeight - 780}px)`); + + document.body.removeChild(element); + document.head.removeChild(styleEl); }); it('should do nothing when visible', () => { diff --git a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js index cd0e2cc179f29e..54351d3df567f9 100644 --- a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js +++ b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js @@ -199,7 +199,6 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) ? `translate(${rtlTranslateMultiplier * translate}px, 0)` : `translate(0, ${rtlTranslateMultiplier * translate}px)`; const drawerStyle = paperRef.current.style; - drawerStyle.webkitTransform = transform; drawerStyle.transform = transform; let transition = ''; @@ -221,7 +220,6 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) } if (changeTransition) { - drawerStyle.webkitTransition = transition; drawerStyle.transition = transition; } @@ -230,7 +228,6 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) backdropStyle.opacity = 1 - translate / getMaxTranslate(horizontalSwipe, paperRef.current); if (changeTransition) { - backdropStyle.webkitTransition = transition; backdropStyle.transition = transition; } } diff --git a/packages/mui-material/src/Zoom/Zoom.js b/packages/mui-material/src/Zoom/Zoom.js index 70258048f32a7c..10f3219ac5c600 100644 --- a/packages/mui-material/src/Zoom/Zoom.js +++ b/packages/mui-material/src/Zoom/Zoom.js @@ -5,18 +5,23 @@ import { Transition } from 'react-transition-group'; import elementAcceptingRef from '@mui/utils/elementAcceptingRef'; import getReactElementRef from '@mui/utils/getReactElementRef'; import { useTheme } from '../zero-styled'; -import { reflow, getTransitionProps } from '../transitions/utils'; +import { + normalizedTransitionCallback, + reflow, + getTransitionProps, + getTransitionChildStyle, +} from '../transitions/utils'; import useForkRef from '../utils/useForkRef'; const styles = { - entering: { - transform: 'none', - }, - entered: { - transform: 'none', - }, + entering: { transform: 'none' }, + entered: { transform: 'none' }, + exiting: { transform: 'scale(0)' }, + exited: { transform: 'scale(0)' }, }; +const hiddenStyles = { transform: 'scale(0)', visibility: 'hidden' }; + /** * The Zoom transition can be used for the floating variant of the * [Button](/material-ui/react-floating-action-button/#animation) component. @@ -43,30 +48,15 @@ const Zoom = React.forwardRef(function Zoom(props, ref) { onExiting, style, timeout = defaultTimeout, - // eslint-disable-next-line react/prop-types - TransitionComponent = Transition, ...other } = props; const nodeRef = React.useRef(null); const handleRef = useForkRef(nodeRef, getReactElementRef(children), ref); - const normalizedTransitionCallback = (callback) => (maybeIsAppearing) => { - if (callback) { - const node = nodeRef.current; - - // onEnterXxx and onExitXxx callbacks have a different arguments.length value. - if (maybeIsAppearing === undefined) { - callback(node); - } else { - callback(node, maybeIsAppearing); - } - } - }; - - const handleEntering = normalizedTransitionCallback(onEntering); + const handleEntering = normalizedTransitionCallback(nodeRef, onEntering); - const handleEnter = normalizedTransitionCallback((node, isAppearing) => { + const handleEnter = normalizedTransitionCallback(nodeRef, (node, isAppearing) => { reflow(node); // So the animation always start from the start. const transitionProps = getTransitionProps( @@ -76,7 +66,6 @@ const Zoom = React.forwardRef(function Zoom(props, ref) { }, ); - node.style.webkitTransition = theme.transitions.create('transform', transitionProps); node.style.transition = theme.transitions.create('transform', transitionProps); if (onEnter) { @@ -84,11 +73,11 @@ const Zoom = React.forwardRef(function Zoom(props, ref) { } }); - const handleEntered = normalizedTransitionCallback(onEntered); + const handleEntered = normalizedTransitionCallback(nodeRef, onEntered); - const handleExiting = normalizedTransitionCallback(onExiting); + const handleExiting = normalizedTransitionCallback(nodeRef, onExiting); - const handleExit = normalizedTransitionCallback((node) => { + const handleExit = normalizedTransitionCallback(nodeRef, (node) => { const transitionProps = getTransitionProps( { style, timeout, easing }, { @@ -96,7 +85,6 @@ const Zoom = React.forwardRef(function Zoom(props, ref) { }, ); - node.style.webkitTransition = theme.transitions.create('transform', transitionProps); node.style.transition = theme.transitions.create('transform', transitionProps); if (onExit) { @@ -104,7 +92,13 @@ const Zoom = React.forwardRef(function Zoom(props, ref) { } }); - const handleExited = normalizedTransitionCallback(onExited); + const handleExited = normalizedTransitionCallback(nodeRef, (node) => { + node.style.transition = ''; + + if (onExited) { + onExited(node); + } + }); const handleAddEndListener = (next) => { if (addEndListener) { @@ -114,7 +108,7 @@ const Zoom = React.forwardRef(function Zoom(props, ref) { }; return ( - {/* Ensure "ownerState" is not forwarded to the child DOM element when a direct HTML element is used. This avoids unexpected behavior since "ownerState" is intended for internal styling, component props and not as a DOM attribute. */} {(state, { ownerState, ...restChildProps }) => { + const childStyle = getTransitionChildStyle( + state, + inProp, + styles, + hiddenStyles, + style, + children.props.style, + ); + return React.cloneElement(children, { - style: { - transform: 'scale(0)', - visibility: state === 'exited' && !inProp ? 'hidden' : undefined, - ...styles[state], - ...style, - ...children.props.style, - }, + style: childStyle, ref: handleRef, ...restChildProps, }); }} - + ); }); diff --git a/packages/mui-material/src/transitions/utils.test.ts b/packages/mui-material/src/transitions/utils.test.ts new file mode 100644 index 00000000000000..41651a7f292298 --- /dev/null +++ b/packages/mui-material/src/transitions/utils.test.ts @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { normalizedTransitionCallback } from './utils'; + +describe('normalizedTransitionCallback', () => { + it('resolves node from ref and passes it to callback', () => { + const node = document.createElement('div'); + const nodeRef = { current: node } as React.RefObject; + const callback = spy(); + + const handler = normalizedTransitionCallback(nodeRef, callback); + handler(); + + expect(callback.calledOnce).to.equal(true); + expect(callback.firstCall.args[0]).to.equal(node); + }); + + it('forwards isAppearing boolean when present', () => { + const node = document.createElement('div'); + const nodeRef = { current: node } as React.RefObject; + const callback = spy(); + + const handler = normalizedTransitionCallback(nodeRef, callback); + handler(true); + + expect(callback.calledOnce).to.equal(true); + expect(callback.firstCall.args[0]).to.equal(node); + expect(callback.firstCall.args[1]).to.equal(true); + }); + + it('omits isAppearing when undefined', () => { + const node = document.createElement('div'); + const nodeRef = { current: node } as React.RefObject; + const callback = spy(); + + const handler = normalizedTransitionCallback(nodeRef, callback); + handler(undefined); + + expect(callback.calledOnce).to.equal(true); + expect(callback.firstCall.args).to.have.lengthOf(1); + }); + + it('does not throw when callback is undefined', () => { + const nodeRef = { current: document.createElement('div') } as React.RefObject; + + const handler = normalizedTransitionCallback(nodeRef, undefined); + expect(() => handler()).not.to.throw(); + }); +}); diff --git a/packages/mui-material/src/transitions/utils.ts b/packages/mui-material/src/transitions/utils.ts index daf526e0c4f8b4..b3725ad64f9f99 100644 --- a/packages/mui-material/src/transitions/utils.ts +++ b/packages/mui-material/src/transitions/utils.ts @@ -18,6 +18,42 @@ interface TransitionProps { delay: string | undefined; } +export function normalizedTransitionCallback( + nodeRef: React.RefObject, + callback: ((node: HTMLElement, isAppearing?: boolean) => void) | undefined, +): (maybeIsAppearing?: boolean) => void { + return (maybeIsAppearing) => { + if (callback) { + const node = nodeRef.current!; + // onEnterXxx and onExitXxx callbacks have a different arguments.length value. + if (maybeIsAppearing === undefined) { + callback(node); + } else { + callback(node, maybeIsAppearing); + } + } + }; +} + +type TransitionState = 'entering' | 'entered' | 'exiting' | 'exited'; + +/** + * Computes the child style for a transition component, reusing existing + * references when possible to preserve referential equality for React.memo. + */ +export function getTransitionChildStyle( + state: TransitionState, + inProp: boolean | undefined, + baseStyles: Record, + hiddenStyles: React.CSSProperties, + styleProp: React.CSSProperties | undefined, + childStyle: React.CSSProperties | undefined, +): React.CSSProperties | undefined { + const base = + state === 'exited' && !inProp ? hiddenStyles : baseStyles[state] || baseStyles.exited; + return styleProp || childStyle ? { ...base, ...styleProp, ...childStyle } : base; +} + export function getTransitionProps(props: ComponentProps, options: Options): TransitionProps { const { timeout, easing, style = {} } = props; From aa73e6a45f7196dc7ed3277d1063a2a3ac2a3f37 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Tue, 7 Apr 2026 15:52:33 +0800 Subject: [PATCH 2/2] ci