From 13eafc2a727efdfdb1a6aa4aaf5dbc72419a2764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 25 May 2026 18:09:07 +0200 Subject: [PATCH 1/3] fix: modal layout shift when auto-focusing input --- packages/react-aria-components/src/Modal.tsx | 59 +++++++++++++++---- .../src/overlays/usePreventScroll.ts | 40 ++++++++----- 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index ecde2ec2385..2d918a5763a 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -11,7 +11,8 @@ */ import {AriaModalOverlayProps, useModalOverlay} from 'react-aria/useModalOverlay'; - +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {willOpenKeyboard} from 'react-aria/private/utils/keyboard'; import { ClassNameOrFunction, ContextValue, @@ -34,11 +35,21 @@ import { useOverlayTriggerState } from 'react-stately/useOverlayTriggerState'; import {OverlayTriggerStateContext} from './Dialog'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo, useRef} from 'react'; +import React, { + createContext, + ForwardedRef, + forwardRef, + useContext, + useMemo, + useRef, + useState +} from 'react'; import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; import {useIsSSR} from 'react-aria/SSRProvider'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useViewportSize} from 'react-aria/private/utils/useViewportSize'; +import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; export interface ModalOverlayProps extends @@ -300,11 +311,12 @@ interface ModalContentProps function ModalContent(props: ModalContentProps) { let {modalProps, modalRef, isExiting, isDismissable} = useContext(InternalModalContext)!; + let [isOpen, setOpen] = useState(false); let state = useContext(OverlayTriggerStateContext)!; let mergedRefs = useMemo(() => mergeRefs(props.modalRef, modalRef), [props.modalRef, modalRef]); let ref = useObjectRef(mergedRefs); - let entering = useEnterAnimation(ref); + let entering = useEnterAnimation(ref, isOpen); let renderProps = useRenderProps({ ...props, defaultClassName: 'react-aria-Modal', @@ -315,15 +327,40 @@ function ModalContent(props: ModalContentProps) { } }); + // Hide the modal initially, since an auto-focused input may cause a viewport resize in the next frame. + // If so, delay the reveal by another frame to avoid layout shift when the viewport settles. + useLayoutEffect(() => { + let frame: number, frame2: number; + + frame = requestAnimationFrame(() => { + let activeElement = getActiveElement(); + if (activeElement && willOpenKeyboard(activeElement)) { + frame2 = requestAnimationFrame(() => setOpen(true)); + } else { + setOpen(true); + } + }); + + return () => { + cancelAnimationFrame(frame); + cancelAnimationFrame(frame2); + }; + }, []); + + let {visuallyHiddenProps} = useVisuallyHidden(); + let contentStyle = isOpen ? {display: 'contents'} : visuallyHiddenProps.style; + return ( - - {isDismissable && } - {renderProps.children} + + + {isDismissable && } + {renderProps.children} + ); } diff --git a/packages/react-aria/src/overlays/usePreventScroll.ts b/packages/react-aria/src/overlays/usePreventScroll.ts index 6ed499570d6..1366f1c9c79 100644 --- a/packages/react-aria/src/overlays/usePreventScroll.ts +++ b/packages/react-aria/src/overlays/usePreventScroll.ts @@ -11,11 +11,10 @@ */ import {chain} from '../utils/chain'; - import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {getNonce} from '../utils/getNonce'; import {getScrollParent} from '../utils/getScrollParent'; -import {isIOS} from '../utils/platform'; +import {isIOS, isWebKit} from '../utils/platform'; import {isScrollable} from '../utils/isScrollable'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {willOpenKeyboard} from '../utils/keyboard'; @@ -36,6 +35,9 @@ let restore; * restores it on unmount. Also ensures that content does not * shift due to the scrollbars disappearing. */ +// TODO(Docs): Fixed outdated documentation of IOS Safari scroll prevention. +// TODO(Docs): Fixed crash when attempting to override focus in test scenarios. +// TODO(Docs): Fixed platform detection causing IOS Safari prevention to run in other engines. export function usePreventScroll(options: PreventScrollOptions = {}): void { let {isDisabled} = options; @@ -46,7 +48,7 @@ export function usePreventScroll(options: PreventScrollOptions = {}): void { preventScrollCount++; if (preventScrollCount === 1) { - if (isIOS()) { + if (isIOS() && isWebKit()) { restore = preventScrollMobileSafari(); } else { restore = preventScrollStandard(); @@ -197,18 +199,22 @@ function preventScrollMobileSafari() { // Override programmatic focus to scroll into view without scrolling the whole page. let focus = HTMLElement.prototype.focus; - HTMLElement.prototype.focus = function (opts) { - // Track whether the keyboard was already visible before. - let activeElement = getActiveElement(); - let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); - - // Focus the element without scrolling the page. - focus.call(this, {...opts, preventScroll: true}); - - if (!opts || !opts.preventScroll) { - scrollIntoViewWhenReady(this, wasKeyboardVisible); + Reflect.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: function (opts?: FocusOptions) { + // Track whether the keyboard was already visible before. + let activeElement = getActiveElement(); + let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); + + // Focus the element without scrolling the page. + focus.call(this, {...opts, preventScroll: true}); + + if (!opts || !opts.preventScroll) { + scrollIntoViewWhenReady(this, wasKeyboardVisible); + } } - }; + }); let removeEvents = chain( addEvent(document, 'touchstart', onTouchStart, {passive: false, capture: true}), @@ -220,7 +226,11 @@ function preventScrollMobileSafari() { restoreOverflow(); removeEvents(); style.remove(); - HTMLElement.prototype.focus = focus; + Reflect.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: focus + }); }; } From 64a2d762d0ba561ea601ae2089c10b89b2792c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 25 May 2026 18:09:56 +0200 Subject: [PATCH 2/3] chore: remove comments --- packages/react-aria/src/overlays/usePreventScroll.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/react-aria/src/overlays/usePreventScroll.ts b/packages/react-aria/src/overlays/usePreventScroll.ts index 1366f1c9c79..69cc0c8ae52 100644 --- a/packages/react-aria/src/overlays/usePreventScroll.ts +++ b/packages/react-aria/src/overlays/usePreventScroll.ts @@ -35,9 +35,6 @@ let restore; * restores it on unmount. Also ensures that content does not * shift due to the scrollbars disappearing. */ -// TODO(Docs): Fixed outdated documentation of IOS Safari scroll prevention. -// TODO(Docs): Fixed crash when attempting to override focus in test scenarios. -// TODO(Docs): Fixed platform detection causing IOS Safari prevention to run in other engines. export function usePreventScroll(options: PreventScrollOptions = {}): void { let {isDisabled} = options; From 8bf7085901702c9dcd45e52a9d7d930c34a21d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 25 May 2026 18:29:33 +0200 Subject: [PATCH 3/3] chore: lint --- packages/react-aria-components/src/Modal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index 2d918a5763a..811d4c36581 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -11,8 +11,6 @@ */ import {AriaModalOverlayProps, useModalOverlay} from 'react-aria/useModalOverlay'; -import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; -import {willOpenKeyboard} from 'react-aria/private/utils/keyboard'; import { ClassNameOrFunction, ContextValue, @@ -26,6 +24,7 @@ import { import {DismissButton, Overlay} from 'react-aria/Overlay'; import {DOMAttributes, forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {isScrollable} from 'react-aria/private/utils/isScrollable'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; @@ -46,10 +45,11 @@ import React, { } from 'react'; import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; import {useIsSSR} from 'react-aria/SSRProvider'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useViewportSize} from 'react-aria/private/utils/useViewportSize'; -import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; +import {willOpenKeyboard} from 'react-aria/private/utils/keyboard'; export interface ModalOverlayProps extends