diff --git a/packages/react/src/dialog/root/DialogRoot.test.tsx b/packages/react/src/dialog/root/DialogRoot.test.tsx index 35e3ead8108..1ddfb726018 100644 --- a/packages/react/src/dialog/root/DialogRoot.test.tsx +++ b/packages/react/src/dialog/root/DialogRoot.test.tsx @@ -2,12 +2,13 @@ import { expect, vi } from 'vitest'; import * as React from 'react'; import { act, fireEvent, screen, waitFor, flushMicrotasks } from '@mui/internal-test-utils'; import { Dialog } from '@base-ui/react/dialog'; -import { createRenderer, isJSDOM, popupConformanceTests } from '#test-utils'; +import { createRenderer, isJSDOM, popupConformanceTests, wait } from '#test-utils'; import { Menu } from '@base-ui/react/menu'; import { Select } from '@base-ui/react/select'; import { NumberField } from '@base-ui/react/number-field'; import { ScrollArea } from '@base-ui/react/scroll-area'; import { useRefWithInit } from '@base-ui/utils/useRefWithInit'; +import { useTimeout } from '@base-ui/utils/useTimeout'; import { REASONS } from '../../internals/reasons'; import { useDialogRootContext } from './DialogRootContext'; @@ -1277,6 +1278,81 @@ describe('', () => { }); }); + describe.for([ + { name: 'body', target: 'body' as const }, + { name: 'html', target: 'html' as const }, + ])('when a third-party $name lock hands off to the dialog', ({ target }) => { + it('keeps the page scroll-locked', async () => { + function App() { + const [dialogOpen, setDialogOpen] = React.useState(false); + const element = getScrollLockElement(document, target); + const timeout = useTimeout(); + + React.useEffect(() => { + return () => { + element.style.overflow = ''; + }; + }, [element]); + + return ( + + + + + + Close dialog + + + + + ); + } + + await render(); + + const doc = document; + + fireEvent.click(screen.getByRole('button', { name: 'Open dialog' })); + await act(async () => { + await wait(0); + }); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBe(null); + }); + expect(isScrollLocked(doc)).toBe(true); + + await act(async () => { + await wait(75); + }); + await waitFor(() => { + expect(isScrollLocked(doc)).toBe(true); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })); + await act(async () => { + await wait(0); + }); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBe(null); + }); + await waitFor(() => { + expect(isScrollLocked(doc)).toBe(false); + }); + }); + }); + it.skipIf(isJSDOM)( 'keeps focus trapped when dialog content contains a non-scrollable scroll area', async () => { @@ -1347,6 +1423,16 @@ function DialogOpenChangeSpy(props: { return null; } +function isScrollLocked(doc: Document) { + return /hidden|clip/.test( + getComputedStyle(doc.documentElement).overflowY + getComputedStyle(doc.body).overflowY, + ); +} + +function getScrollLockElement(doc: Document, target: 'body' | 'html') { + return target === 'body' ? doc.body : doc.documentElement; +} + type TestDialogProps = { rootProps?: Omit; triggerProps?: Dialog.Trigger.Props; diff --git a/packages/utils/src/useScrollLock.ts b/packages/utils/src/useScrollLock.ts index 66c69517a6f..c3b2114a6f8 100644 --- a/packages/utils/src/useScrollLock.ts +++ b/packages/utils/src/useScrollLock.ts @@ -12,6 +12,12 @@ let originalHtmlStyles: Partial = {}; let originalBodyStyles: Partial = {}; let originalHtmlScrollBehavior = ''; +function isPageScrollLocked(win: Window, html: Element, body: Element) { + return /hidden|clip/.test( + win.getComputedStyle(html).overflowY + win.getComputedStyle(body).overflowY, + ); +} + function hasInsetScrollbars(referenceElement: Element | null) { if (typeof document === 'undefined') { return false; @@ -242,6 +248,42 @@ class ScrollLocker { } }; + private observePageScrollUnlock(referenceElement: Element | null) { + if (typeof MutationObserver !== 'function') { + return NOOP; + } + + const doc = ownerDocument(referenceElement); + const win = ownerWindow(doc); + const html = doc.documentElement; + const body = doc.body; + + let observer: MutationObserver; + + function disconnect() { + observer.disconnect(); + } + + observer = new MutationObserver(() => { + if ( + this.lockCount === 0 || + this.restore !== disconnect || + isPageScrollLocked(win, html, body) + ) { + return; + } + + disconnect(); + this.restore = null; + this.lock(referenceElement); + }); + + observer.observe(html, { attributes: true }); + observer.observe(body, { attributes: true }); + + return disconnect; + } + private lock(referenceElement: Element | null) { if (this.lockCount === 0 || this.restore !== null) { return; @@ -249,11 +291,11 @@ class ScrollLocker { const doc = ownerDocument(referenceElement); const html = doc.documentElement; - const htmlOverflowY = ownerWindow(html).getComputedStyle(html).overflowY; + const body = doc.body; + const win = ownerWindow(html); - // If the site author already hid overflow on , respect it and bail out. - if (htmlOverflowY === 'hidden' || htmlOverflowY === 'clip') { - this.restore = NOOP; + if (isPageScrollLocked(win, html, body)) { + this.restore = this.observePageScrollUnlock(referenceElement); return; }