From cfc706fd3b49146efc5db696930d81c13e43aa4c Mon Sep 17 00:00:00 2001 From: Olagokemills Date: Fri, 12 Jun 2026 16:38:43 +0100 Subject: [PATCH] [Modal] Fix document scroll behind modal on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Safari ignores `overflow: hidden` on the document scroll container for touch scrolling, so the page scrolls behind a modal once content overflows — most visibly when focusing an Autocomplete inside a Drawer opens the on-screen keyboard. On iOS, additionally pin the document scroll container with `position: fixed` (the robust scroll-lock technique) and restore the scroll position on unlock to avoid a jump to the top. Non-iOS platforms and custom (non-document) scroll containers keep the existing `overflow: hidden` behavior unchanged. Detection covers iPadOS 13+, which reports as `MacIntel` (distinguished from a real Mac via `maxTouchPoints`). Closes #46791 --- .../src/Modal/ModalManager.test.ts | 157 ++++++++++++++++++ .../mui-material/src/Modal/ModalManager.ts | 51 ++++++ 2 files changed, 208 insertions(+) diff --git a/packages/mui-material/src/Modal/ModalManager.test.ts b/packages/mui-material/src/Modal/ModalManager.test.ts index bba004c0188dc2..39b41d42e48f8d 100644 --- a/packages/mui-material/src/Modal/ModalManager.test.ts +++ b/packages/mui-material/src/Modal/ModalManager.test.ts @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import { spy } from 'sinon'; import getScrollbarSize from '@mui/utils/getScrollbarSize'; import { ModalManager } from './ModalManager'; @@ -432,4 +433,160 @@ describe('ModalManager', () => { expect(container2.children[2]).not.toBeInaccessible(); }); }); + + // Regression test for https://github.com/mui/material-ui/issues/46791 + // iOS Safari ignores `overflow: hidden` on the document scroll container, so + // the page can still scroll behind a modal (e.g. when the on-screen keyboard + // opens for an Autocomplete inside a Drawer). On iOS we additionally pin the + // body with `position: fixed` and restore the scroll position on unlock. + describe('iOS scroll lock', () => { + let scrollYDescriptor: PropertyDescriptor | undefined; + let originalScrollTo: typeof window.scrollTo; + let scrollToSpy: ReturnType; + + // Shadow the read-only navigator getters with own properties; afterEach + // deletes them to fall back to the original prototype getters. + function stubNavigator(values: { + userAgent?: string; + platform?: string; + maxTouchPoints?: number; + }) { + if (values.userAgent !== undefined) { + Object.defineProperty(navigator, 'userAgent', { + value: values.userAgent, + configurable: true, + }); + } + if (values.platform !== undefined) { + Object.defineProperty(navigator, 'platform', { + value: values.platform, + configurable: true, + }); + } + if (values.maxTouchPoints !== undefined) { + Object.defineProperty(navigator, 'maxTouchPoints', { + value: values.maxTouchPoints, + configurable: true, + }); + } + } + + beforeEach(() => { + modalManager = new ModalManager(); + scrollYDescriptor = Object.getOwnPropertyDescriptor(window, 'scrollY'); + Object.defineProperty(window, 'scrollY', { value: 150, configurable: true }); + + originalScrollTo = window.scrollTo; + scrollToSpy = spy(); + window.scrollTo = scrollToSpy as unknown as typeof window.scrollTo; + }); + + afterEach(() => { + delete (navigator as any).userAgent; + delete (navigator as any).platform; + delete (navigator as any).maxTouchPoints; + if (scrollYDescriptor) { + Object.defineProperty(window, 'scrollY', scrollYDescriptor); + } else { + delete (window as any).scrollY; + } + window.scrollTo = originalScrollTo; + document.body.style.position = ''; + document.body.style.top = ''; + document.body.style.left = ''; + document.body.style.right = ''; + document.body.style.width = ''; + document.body.style.overflow = ''; + }); + + it('pins the body with position: fixed and restores scroll on iOS', () => { + stubNavigator({ userAgent: 'iPhone OS 17_0 like Mac OS X', platform: 'iPhone' }); + + const modal = getDummyModal(); + modalManager.add(modal, document.body); + modalManager.mount(modal, {}); + + expect(document.body.style.overflow).to.equal('hidden'); + expect(document.body.style.position).to.equal('fixed'); + expect(document.body.style.top).to.equal('-150px'); + expect(document.body.style.width).to.equal('100%'); + + modalManager.remove(modal); + + expect(document.body.style.position).to.equal(''); + expect(document.body.style.top).to.equal(''); + expect(document.body.style.overflow).to.equal(''); + expect(scrollToSpy.calledOnceWith(0, 150)).to.equal(true); + }); + + it('detects iPadOS 13+ which reports as MacIntel with touch points', () => { + stubNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/604.1', + platform: 'MacIntel', + maxTouchPoints: 5, + }); + + const modal = getDummyModal(); + modalManager.add(modal, document.body); + modalManager.mount(modal, {}); + + expect(document.body.style.position).to.equal('fixed'); + + modalManager.remove(modal); + expect(scrollToSpy.calledOnceWith(0, 150)).to.equal(true); + }); + + it('does not pin the body on non-iOS platforms', () => { + stubNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/604.1', + platform: 'MacIntel', + maxTouchPoints: 0, + }); + + const modal = getDummyModal(); + modalManager.add(modal, document.body); + modalManager.mount(modal, {}); + + expect(document.body.style.overflow).to.equal('hidden'); + expect(document.body.style.position).to.equal(''); + expect(document.body.style.top).to.equal(''); + + modalManager.remove(modal); + expect(scrollToSpy.called).to.equal(false); + }); + + it('does nothing when disableScrollLock is set on iOS', () => { + stubNavigator({ userAgent: 'iPhone OS 17_0 like Mac OS X', platform: 'iPhone' }); + + const modal = getDummyModal(); + modalManager.add(modal, document.body); + modalManager.mount(modal, { disableScrollLock: true }); + + expect(document.body.style.position).to.equal(''); + expect(document.body.style.overflow).to.equal(''); + + modalManager.remove(modal); + expect(scrollToSpy.called).to.equal(false); + }); + + it('does not pin a non-document scroll container on iOS', () => { + stubNavigator({ userAgent: 'iPhone OS 17_0 like Mac OS X', platform: 'iPhone' }); + + const customContainer = document.createElement('div'); + document.body.appendChild(customContainer); + + const modal = getDummyModal(); + modalManager.add(modal, customContainer); + modalManager.mount(modal, {}); + + expect(customContainer.style.overflow).to.equal('hidden'); + expect(customContainer.style.position).to.equal(''); + + modalManager.remove(modal); + expect(scrollToSpy.called).to.equal(false); + document.body.removeChild(customContainer); + }); + }); }); diff --git a/packages/mui-material/src/Modal/ModalManager.ts b/packages/mui-material/src/Modal/ModalManager.ts index 4607599c4e5dc6..2cbda3d24ea0b3 100644 --- a/packages/mui-material/src/Modal/ModalManager.ts +++ b/packages/mui-material/src/Modal/ModalManager.ts @@ -6,6 +6,22 @@ export interface ManagedModalProps { disableScrollLock?: boolean | undefined; } +// iOS Safari ignores `overflow: hidden` on the scroll container for touch +// scrolling, so the page can still scroll behind a modal. We detect iOS to +// apply a stronger `position: fixed` scroll lock there. Evaluated at call time +// (not module load) so it stays SSR-safe and testable. +function isIOS(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + // iPadOS 13+ reports as "MacIntel"; distinguish a real Mac from an iPad + // via touch points. + return ( + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + ); +} + // Is a vertical scrollbar displayed? function isOverflowing(container: Element): boolean { const doc = ownerDocument(container); @@ -94,6 +110,7 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) { value: string; }> = []; const container = containerInfo.container; + let restoreScroll: (() => void) | undefined; if (!props.disableScrollLock) { if (isOverflowing(container)) { @@ -157,6 +174,38 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) { ); scrollContainer.style.overflow = 'hidden'; + + // iOS Safari does not reliably honor `overflow: hidden` for touch scrolling, + // so the document can still scroll behind the modal — e.g. once the on-screen + // keyboard opens for an Autocomplete inside a Drawer. Pin the body with + // `position: fixed` (the robust scroll-lock technique) and restore the scroll + // position on unlock to avoid a jump to the top. Only the document scroll + // container is affected; custom containers keep the existing behavior. + const doc = ownerDocument(container); + const isDocumentScrollContainer = + scrollContainer === doc.body || scrollContainer === doc.documentElement; + + if (isIOS() && isDocumentScrollContainer) { + const scrollContainerWindow = ownerWindow(container); + const scrollY = scrollContainerWindow.scrollY; + + (['position', 'top', 'left', 'right', 'width'] as const).forEach((property) => { + restoreStyle.push({ + value: scrollContainer.style.getPropertyValue(property), + property, + el: scrollContainer, + }); + }); + + scrollContainer.style.position = 'fixed'; + scrollContainer.style.top = `${-scrollY}px`; + scrollContainer.style.left = '0'; + scrollContainer.style.right = '0'; + // Preserve the layout width so content doesn't reflow while pinned. + scrollContainer.style.width = '100%'; + + restoreScroll = () => scrollContainerWindow.scrollTo(0, scrollY); + } } const restore = () => { @@ -167,6 +216,8 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) { el.style.removeProperty(property); } }); + // Restore scroll position after the `position: fixed` styles are reverted. + restoreScroll?.(); }; return restore;