From c1045b1a558a9ca83b71389ca961013de4755c64 Mon Sep 17 00:00:00 2001
From: Ioannis <2973490+ilazaridis@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:14:48 +0200
Subject: [PATCH] [fix] Modal: trap focus and handle Escape from the commit
that activates it
A modal becomes active, and gets role="dialog", in a render after it
shows. Its focus trap and its Escape listener were set up in passive
effects, which run a task after that render commits. In between:
- A click inside the modal can lose its focus. The previously focused
element's blur handler may set state (Pressable does), which makes
React flush the pending effects mid focus change. The trap then sees
focus outside the modal, focuses its first focusable element, and the
browser drops the click's own focus change.
- The modal below still has its Escape listener with active=true, so an
Escape closes the modal below instead of the new top one.
Both effects are now layout effects (SSR-safe, via
modules/useLayoutEffect), so they take effect in the same commit as
role="dialog".
---
.../src/exports/Modal/ModalContent.js | 5 +-
.../src/exports/Modal/ModalFocusTrap.js | 5 +-
.../src/exports/Modal/__tests__/index-test.js | 80 +++++++++++++++++++
3 files changed, 88 insertions(+), 2 deletions(-)
diff --git a/packages/react-native-web/src/exports/Modal/ModalContent.js b/packages/react-native-web/src/exports/Modal/ModalContent.js
index 0ce3ee493c..f09888adb2 100644
--- a/packages/react-native-web/src/exports/Modal/ModalContent.js
+++ b/packages/react-native-web/src/exports/Modal/ModalContent.js
@@ -14,6 +14,7 @@ import * as React from 'react';
import View from '../View';
import StyleSheet from '../StyleSheet';
import canUseDOM from '../../modules/canUseDom';
+import useLayoutEffect from '../../modules/useLayoutEffect';
export type ModalContentProps = {
...ViewProps,
@@ -29,7 +30,9 @@ const ModalContent: React.AbstractComponent<
> = React.forwardRef((props, forwardedRef) => {
const { active, children, onRequestClose, transparent, ...rest } = props;
- React.useEffect(() => {
+ // A layout effect, so that Escape goes to the active modal from the commit
+ // that sets `role="dialog"`, not a task later.
+ useLayoutEffect(() => {
if (canUseDOM) {
const closeOnEscape = (e: KeyboardEvent) => {
if (active && e.key === 'Escape') {
diff --git a/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js b/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js
index f596bf60d2..eb7a0f5018 100644
--- a/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js
+++ b/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js
@@ -14,6 +14,7 @@ import createElement from '../createElement';
import StyleSheet from '../StyleSheet';
import UIManager from '../UIManager';
import canUseDOM from '../../modules/canUseDom';
+import useLayoutEffect from '../../modules/useLayoutEffect';
/**
* This Component is used to "wrap" the modal we're opening
@@ -83,7 +84,9 @@ const ModalFocusTrap = ({
lastFocusedElement: null
});
- React.useEffect(() => {
+ // A layout effect, so that focus is trapped from the commit that makes the
+ // modal active, not a task later.
+ useLayoutEffect(() => {
if (canUseDOM) {
const trapFocus = () => {
// We should not trap focus if:
diff --git a/packages/react-native-web/src/exports/Modal/__tests__/index-test.js b/packages/react-native-web/src/exports/Modal/__tests__/index-test.js
index 9e82fcc5ba..f12af213b1 100644
--- a/packages/react-native-web/src/exports/Modal/__tests__/index-test.js
+++ b/packages/react-native-web/src/exports/Modal/__tests__/index-test.js
@@ -567,6 +567,86 @@ describe('components/Modal', () => {
expect(document.activeElement).toBe(outsideElement);
});
+ test('focus is trapped in the same commit that makes the modal active', () => {
+ // Its layout effect runs in that commit, after the modal's own effects and
+ // before passive effects: what it sees is what an event arriving right
+ // after the commit would see.
+ function Probe({ active, onCommit }) {
+ React.useLayoutEffect(() => {
+ if (active) onCommit();
+ }, [active, onCommit]);
+ return null;
+ }
+
+ let seen;
+ const onCommit = () => {
+ const dialog = document.querySelector('[role="dialog"]');
+ seen = dialog != null && dialog.contains(document.activeElement);
+ };
+
+ function Test({ visible }) {
+ const [active, setActive] = React.useState(false);
+ return (
+ <>
+
+ Outside
+
+ setActive(true)} visible={visible}>
+
+ Hello
+
+
+
+ >
+ );
+ }
+
+ const { rerender } = render();
+ document.querySelector('[data-testid="outside"]').focus();
+ rerender();
+
+ expect(seen).toBe(true);
+ });
+
+ test('escape goes to the new top modal in the same commit that makes it active', () => {
+ const spyA = jest.fn();
+ const spyB = jest.fn();
+
+ // See the test above: an Escape right after the commit that makes modal B
+ // active.
+ function Probe({ active }) {
+ React.useLayoutEffect(() => {
+ if (active) {
+ document.dispatchEvent(
+ new KeyboardEvent('keyup', { bubbles: true, key: 'Escape' })
+ );
+ }
+ }, [active]);
+ return null;
+ }
+
+ function Test({ visibleB }) {
+ const [activeB, setActiveB] = React.useState(false);
+ return (
+ <>
+
+ setActiveB(true)}
+ visible={visibleB}
+ />
+
+ >
+ );
+ }
+
+ const { rerender } = render();
+ rerender();
+
+ expect(spyA).toHaveBeenCalledTimes(0);
+ expect(spyB).toHaveBeenCalledTimes(1);
+ });
+
test('creates portal outside of the react container', () => {
const { container, baseElement } = render(