Skip to content

[fix] Modal: trap focus and handle Escape from the commit that activates it - #2879

Open
ilazaridis wants to merge 1 commit into
necolas:masterfrom
ilazaridis:fix/modal-activation-layout-effects
Open

ilazaridis wants to merge 1 commit into
necolas:masterfrom
ilazaridis:fix/modal-activation-layout-effects

Conversation

@ilazaridis

Copy link
Copy Markdown

A modal becomes active (role="dialog") in a render after it shows: onShow → setIsActive(true). That render commits role="dialog". But the focus trap (ModalFocusTrap's [active] effect) and the Escape listener (ModalContent) were set up in passive effects, which run a scheduler task after the commit. Two things can go wrong in that window:

  1. A click inside the modal loses its focus.

    • The browser starts moving focus to the clicked control and fires focusout on the element that had it. document.activeElement is <body> at that point.
    • If that element's blur handler sets state (Pressable does, for its focus state), React flushes the pending passive effects first.
    • The trap sees focus outside the modal and focuses the modal's first focusable element.
    • The browser then drops the click's own focus change, because a blur handler moved focus.

    The clicked control never gets focus. If the click opens a nested modal, that modal returns focus to the wrong element when it closes.

  2. An Escape reaches the modal below. When a second modal becomes active, the first still has its Escape listener with active true until the passive effects run. An Escape keyup in that window closes the modal below, not the new top one.

The window is one task, so it's rare by hand. We hit case 1 as an end-to-end test flake: a click on a button in a bottom sheet right after the sheet opened, in Chromium and WebKit, under CI load. Holding React's scheduler for 1 s once the modal gets role="dialog" reproduces both cases every time. Case 2 needs a browser without CloseWatcher, such as shipping Safari.

Change

Both effects run as layout effects, through modules/useLayoutEffect (as TextInput and Image do, so SSR still uses useEffect). The trap and the Escape listener now take effect in the same commit that sets role="dialog", and the modal below gives them up in that same commit. Nothing else changes:

  • The trap's mount-only effect that refocuses the opener on close stays a passive effect.
  • Both effects already guard with canUseDOM.

Tests

Two new tests use a probe component rendered after the modals. Its layout effect runs in the commit that makes a modal active, after the modal's own effects and before passive effects, so it sees what an event arriving right after that commit would see:

  • focus is trapped in the same commit that makes the modal active
  • escape goes to the new top modal in the same commit that makes it active

Both fail without the change (focus still outside the modal; the Escape goes to the modal below) and pass with it.

  • npm run flow: no errors.
  • npm run format and npm run lint pass.
  • Unit tests: DOM suite 717 passed, 6 skipped; Node suite 6 passed.

I ran the unit suites with jest --config ./configs/jest.config{,.node}.js --runInBand. npm 12 rejects npm run unit --runInBand ("Unknown cli flag: --runInBand").

We have run the same change in production as a patch on 0.21.3, with React 19.2. The repro above fails 10/10 without it and passes 10/10 with it, in Chromium and WebKit. Our full end-to-end suite, which covers focus trapping, Tab cycling, Escape, focus return and nested modals, also passes with it.

Related: #2862 also touches ModalContent's Escape handling (one modal per key press). It's independent of this timing change.

…tes 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".
@codesandbox-ci

codesandbox-ci Bot commented Oct 7, 2026

Copy link
Copy Markdown

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit c1045b1:

Sandbox Source
react-native-web-examples Configuration

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant