Skip to content

[Bug]: Escape ignored on first press after modal mount (enable-dialog-element) #23126

Description

@gillesdandrea

Package

@carbon/react

Browser

Chrome

Package version

1.112.0 and 1.114.0 (latest)

React version

19.2.7

Description

Summary

ComposedModal and Modal register a document-level Escape handler guarded by
isTopmostVisibleModal(modalRef.current, prefix). On some page loads modalRef.current is still
null when that handler first runs, so the guard returns early and Carbon neither calls
preventDefault() nor closes the modal.

With enable-dialog-element on, that missed preventDefault() has a second consequence: the
browser's own close request on the native <dialog> runs unopposed. The element closes without
React knowing, the parent's open stays true, and the .cds--modal overlay is left mounted with
pointer-events: auto over an unclickable page.

first Escape consequence
enable-dialog-element off ignored modal stays open — a keyboard-accessibility defect
enable-dialog-element on ignored browser closes the <dialog> while React still thinks it is open → overlay stranded over a dead page

The flag escalates the defect; it does not cause it. The control story in the reproduction has
enableDialogElement={false} and the first Escape is still ignored.

Root cause

// packages/react/src/components/Modal/isTopmostVisibleModal.ts
const isTopmostVisibleModal = (node, prefix) => {
  if (!node) return false;                    // <- execution stops here
  const visibleModals = document.querySelectorAll(`.${prefix}--modal.is-visible`);
  return visibleModals.item(visibleModals.length - 1) === node;
};
// ComposedModal.js — the only condition guarding preventDefault()
if (match(event, keys.Escape) && isTopmostVisibleModal(modalRef.current, prefix)) {
  event.preventDefault();
  closeModal(event);
}

We isolated which branch is taken by patching Document.prototype.querySelectorAll and watching for
that exact selector during a keypress:

Esc press #1  -> selector never queried          -> defaultPrevented = false
Esc press #2  -> selector queried, node not null -> defaultPrevented = true

On the failing press the selector is never queried at all, so execution stopped at if (!node).
modalRef.current is null. The DOM side of the comparison was never the problem — the wrapper
node is stable across presses and document.querySelectorAll('.cds--modal.is-visible') returns
exactly 1 throughout.

modalRef reaches the element through useMergeRefs([modalRef, ref, presenceContext?.presenceRef])
on the <Layer> that renders .cds--modal. Why that merged ref is still unset when a keydown
listener registered by an effect first runs is Carbon-internal ref-attachment ordering.

Also missing: no onCancel is wired

Dialog already accepts an onCancel prop and puts it straight on the <dialog>
(Dialog.js, onCancel in the element's props). Neither ComposedModal nor Modal passes one, so
the browser's close request is never reflected back into React even in principle. That is what turns
a silently ignored keystroke into a visibly broken page.

Scope

Affected, all reproduced in a real browser:

component path
ComposedModal renders Dialog directly
Modal renders Dialog directly
Tearsheet (@carbon/ibm-products) renders through ComposedModal

Ruled out

candidate cause verdict
React StrictMode double-mounting Not in play — reproduced with StrictMode absent entirely
Consumer wrapper/HOC The reproduction uses plain <ComposedModal open onClose>, no wrapper
Nested <FeatureFlags> scopes Reproduces with no provider of its own
createPortal Not required; the reproduction uses no portal
A consumer-supplied ref conflicting with Carbon's We pass none. modalRef is private and merged internally
Unsupported flag usage enablePresence and enableDialogElement are first-class typed props on FeatureFlags, not unstable_-prefixed

Suggested fix

Two parts, either of which alone reduces the severity:

  1. Make the guard robust to a null ref. A modal whose own ref has not attached yet should not be
    treated as "not topmost". Falling back to the DOM comparison, or deferring listener registration
    until the ref is attached, would both work.
  2. Wire onCancel through ComposedModal/Modal into Dialog and route it into closeModal,
    so the browser's close request and the close button share one path. Dialog already supports the
    prop.

Notes on the sandbox

  • StrictMode is deliberately removed from main.tsx. This is not a double-mounting artefact and
    reproduces with StrictMode absent entirely.
  • @carbon/react is pinned to 1.114.0 so the sandbox stays meaningful over time. We first hit this
    on 1.112.0, and isTopmostVisibleModal is byte-identical between the two, so this is not a
    recent regression.
  • In the control variant the overlay legitimately covers the page because the modal is still open —
    there, the defect is only that the first Esc did nothing.

Context

  • We adopted enableDialogElement on Carbon's own accessibility guidance, so this surfaced as a
    regression for us rather than an experimental-flag issue.
  • Possibly relevant to enable-dialog-element: move to stable #22240 (enable-dialog-element: move to stable) — this looks like a blocker
    for promoting that flag.
  • React 19 is a plausible trigger given its changes to ref handling and ref cleanup. Carbon declares
    ^19.0.0 support, so we are reporting it as a Carbon defect, but it may help you reproduce.

Our workaround

For anyone hitting this before a fix lands: listen for cancel in the capture phase (it does not
bubble), scoped to .cds--dialog, call preventDefault() to stop the browser closing behind React,
then re-dispatch Escape — which Carbon then honours. The close still travels Carbon's own
closeModal, so onClose fires exactly as it does for the close button. Measured 0/6 clean without,
6/6 clean with.

Reproduction/example

https://stackblitz.com/edit/cxu1afz2?file=src%2FApp.tsx

Steps to reproduce

  1. Open the StackBlitz preview in its own window.
  2. Click Open.
  3. Press Esc once.
  4. Read the live state table. Use the three variant buttons to switch between ComposedModal,
    Modal, and the enableDialogElement-off control.

Expected: the modal closes and onClose fires once.

Actual: onClose calls stays 0, last Esc defaultPrevented is
false, the <dialog> loses its open attribute, and the .cds--modal overlay stays mounted and
is-visible — a shaded page with no modal on it and nothing clickable. A second Esc
closes it correctly.

Reproduction rate. In the StackBlitz above the defect is consistent: measured
fresh load → Open → Esc, the overlay stranded 6/6 on ComposedModal and 3/3 on Modal,
and the control ignored the first Escape 3/3.

In our own application the same defect is intermittent — a race latched around mount, where a
given page load is consistently affected or consistently fine. If a single Esc ever does
close cleanly for you, reload and repeat; within an affected load it is 100% reproducible. We mention
this because it is why the bug is reported in the field as random, and why it may have gone unnoticed.

The variant buttons in the sandbox navigate rather than re-render, so every variant starts on a fresh
page load.

Suggested Severity

Medium (Severity 3) = User can complete task, and/or has a workaround within the user experience of a given component.

Project name

IBM Event Endpoint Management

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    Severity

    Medium

    Projects

    Status
    🕵️‍♀️ Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions