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;
}