From d709471059c8893e5fc68f85a589994cf062b77b Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 20 Apr 2026 22:06:37 +0800 Subject: [PATCH] [drawer] Fix transition jump (#48308) --- packages/mui-material/src/Drawer/Drawer.js | 17 +++- .../mui-material/src/Drawer/Drawer.test.js | 99 ++++++++++++++++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/mui-material/src/Drawer/Drawer.js b/packages/mui-material/src/Drawer/Drawer.js index 8783d063064120..f65dffc5cecfec 100644 --- a/packages/mui-material/src/Drawer/Drawer.js +++ b/packages/mui-material/src/Drawer/Drawer.js @@ -13,6 +13,7 @@ import rootShouldForwardProp from '../styles/rootShouldForwardProp'; import { styled, useTheme } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; +import useForkRef from '../utils/useForkRef'; import { getDrawerUtilityClass } from './drawerClasses'; import useSlot from '../utils/useSlot'; import { FOCUSABLE_ATTRIBUTE } from '../utils/focusable'; @@ -222,10 +223,17 @@ const Drawer = React.forwardRef(function Drawer(inProps, ref) { // We use this state is order to skip the appear transition during the // initial mount of the component. const mounted = React.useRef(false); + const rootRef = React.useRef(null); + const handleRef = useForkRef(ref, rootRef); + React.useEffect(() => { mounted.current = true; }, []); + // Resolve the container lazily so Slide reads the mounted modal root + // after refs are assigned, rather than the initial null ref during render. + const resolveSlideContainer = React.useCallback(() => rootRef.current, []); + const anchorInvariant = getAnchor({ direction: isRtl ? 'rtl' : 'ltr' }, anchorProp); const anchor = anchorProp; @@ -256,7 +264,7 @@ const Drawer = React.forwardRef(function Drawer(inProps, ref) { }; const [RootSlot, rootSlotProps] = useSlot('root', { - ref, + ref: handleRef, elementType: DrawerRoot, className: clsx(classes.root, classes.modal, className), shouldForwardComponentProp: true, @@ -267,6 +275,7 @@ const Drawer = React.forwardRef(function Drawer(inProps, ref) { ...ModalProps, }, additionalProps: { + closeAfterTransition: true, open, onClose, hideBackdrop, @@ -299,7 +308,7 @@ const Drawer = React.forwardRef(function Drawer(inProps, ref) { const [DockedSlot, dockedSlotProps] = useSlot('docked', { elementType: DrawerDockedRoot, - ref, + ref: handleRef, className: clsx(classes.root, classes.docked, className), ownerState, externalForwardedProps, @@ -315,6 +324,10 @@ const Drawer = React.forwardRef(function Drawer(inProps, ref) { direction: oppositeDirection[anchorInvariant], timeout: transitionDuration, appear: mounted.current, + ...(variant === 'temporary' && + (slots.transition == null || slots.transition === Slide) && { + container: resolveSlideContainer, + }), }, }); diff --git a/packages/mui-material/src/Drawer/Drawer.test.js b/packages/mui-material/src/Drawer/Drawer.test.js index 7ed0e3327ceed0..57a7f3667e3390 100644 --- a/packages/mui-material/src/Drawer/Drawer.test.js +++ b/packages/mui-material/src/Drawer/Drawer.test.js @@ -1,7 +1,7 @@ import * as React from 'react'; import { expect } from 'chai'; -import { spy } from 'sinon'; -import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils'; +import { spy, stub } from 'sinon'; +import { act, createRenderer, screen, isJsdom } from '@mui/internal-test-utils'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import Drawer, { drawerClasses as classes } from '@mui/material/Drawer'; import { modalClasses } from '@mui/material/Modal'; @@ -155,6 +155,101 @@ describe('', () => { }); }); + describe('scroll lock', () => { + it('should keep the scroll locked until the exit transition completes by default', () => { + const transitionDuration = 123; + const { setProps } = render( + +
+ , + ); + + expect(document.body.style.overflow).to.equal(''); + + setProps({ open: true }); + clock.runToLast(); + + expect(document.body.style.overflow).to.equal('hidden'); + + setProps({ open: false }); + + expect(document.body.style.overflow).to.equal('hidden'); + + act(() => { + clock.runToLast(); + }); + + expect(document.body.style.overflow).to.equal(''); + }); + + it('should allow opting out of waiting for the exit transition before unlocking scroll', () => { + const transitionDuration = 123; + const { setProps } = render( + +
+ , + ); + + setProps({ open: true }); + clock.runToLast(); + + expect(document.body.style.overflow).to.equal('hidden'); + + setProps({ open: false }); + + expect(document.body.style.overflow).to.equal(''); + }); + }); + + describe('transition container', () => { + it.skipIf(isJsdom())('should slide relative to the drawer root by default', function test() { + let nodeExitingTransformStyle; + const { setProps } = render( + { + nodeExitingTransformStyle = node.style.transform; + }, + }, + }} + > +
+ , + ); + + const root = document.querySelector(`.${classes.root}`); + const paper = document.querySelector(`.${classes.paper}`); + + const rootRectStub = stub(root, 'getBoundingClientRect').callsFake(() => ({ + width: 1000, + height: 500, + left: 0, + right: 1000, + top: 0, + bottom: 500, + })); + const paperRectStub = stub(paper, 'getBoundingClientRect').callsFake(() => ({ + width: 200, + height: 500, + left: 800, + right: 1000, + top: 0, + bottom: 500, + })); + + try { + setProps({ open: false }); + expect(nodeExitingTransformStyle).to.equal('translateX(200px)'); + } finally { + rootRectStub.restore(); + paperRectStub.restore(); + } + }); + }); + describe('accessibility', () => { it('should have role="dialog" and aria-modal="true" when variant is temporary', () => { render(