Skip to content

useEscKeyDown: under React 18 StrictMode, a portal that mounts open leaves a stale Esc entry, and the portal below never gets top again #48

Open

Description

@jainkuniya

Summary

On React 18, under <React.StrictMode>, a portal that mounts already open leaves a permanent entry on the Esc stack. Once that portal closes, the portal that was open below it never gets top: true again, so Escape stops closing it. In antd: open a Drawer, open a Modal with destroyOnHidden from it, and close the Modal. From then on, Escape no longer closes the Drawer, however often it's pressed.

This is development only (StrictMode doesn't double-render in production), and React 19 isn't affected.

Versions

  • @rc-component/portal 2.2.1 (also current master, 4f29120)
  • React 18.3.1 / react-dom 18.3.1 (inside the react >= 18 peer range)
  • antd 6.3.5 (@rc-component/dialog 1.8.4, @rc-component/drawer 1.4.2)

Reproduction

const Demo = ({ child }: { child: boolean }) => (
  <>
    <Portal open onEsc={onEscA}>
      <div />
    </Portal>
    {child && (
      <Portal open onEsc={onEscB}>
        <div />
      </Portal>
    )}
  </>
);

const { rerender } = render(<Demo child={false} />, { wrapper: React.StrictMode });
rerender(<Demo child />); // B mounts already open
rerender(<Demo child={false} />); // and closes

fireEvent.keyDown(window, { key: 'Escape' });
// React 18: onEscA is only ever called with { top: false }

The same happens in antd with:

  • a Modal with destroyOnHidden (or a Modal rendered only while open);
  • a nested Drawer;
  • a Tooltip shown and then hidden inside the Drawer.

A Modal without destroyOnHidden is fine, because its portal mounts closed and only later turns open.

Cause

useEscKeyDown pushes onto the stack during render, inside useMemo. On React 18, StrictMode renders a mounting component twice, and useId returns a different id each time (:r1: then :r2: in a browser). Effects only run for the second render. The first render's entry is never cleared: it sits above the portal that was already open, with a handler from a render React discarded.

The suite runs React 19, where this doesn't happen, so CI doesn't show it. On React 18.3.1, three Esc tests fail on master:

  • onEsc › onEsc should treat first mounted portal as top in StrictMode
  • onEsc › should clear stack when unmount
  • the regression test in the PR that fixes this

Proposed fix

Keep the render-time push, which keeps a parent below its children, but let an entry take part in Escape only once its effect has run. Once no live entry is left, drop the entries no effect ever claimed. Fix: #49.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions