You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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-elementoff
ignored
modal stays open — a keyboard-accessibility defect
enable-dialog-elementon
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.
// 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:
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:
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.
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.
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.
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.
Package
@carbon/react
Browser
Chrome
Package version
1.112.0 and 1.114.0 (latest)
React version
19.2.7
Description
Summary
ComposedModalandModalregister a document-level Escape handler guarded byisTopmostVisibleModal(modalRef.current, prefix). On some page loadsmodalRef.currentis stillnullwhen that handler first runs, so the guard returns early and Carbon neither callspreventDefault()nor closes the modal.With
enable-dialog-elementon, that missedpreventDefault()has a second consequence: thebrowser's own close request on the native
<dialog>runs unopposed. The element closes withoutReact knowing, the parent's
openstaystrue, and the.cds--modaloverlay is left mounted withpointer-events: autoover an unclickable page.enable-dialog-elementoffenable-dialog-elementon<dialog>while React still thinks it is open → overlay stranded over a dead pageThe 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
We isolated which branch is taken by patching
Document.prototype.querySelectorAlland watching forthat exact selector during a keypress:
On the failing press the selector is never queried at all, so execution stopped at
if (!node).modalRef.currentisnull. The DOM side of the comparison was never the problem — the wrappernode is stable across presses and
document.querySelectorAll('.cds--modal.is-visible')returnsexactly
1throughout.modalRefreaches the element throughuseMergeRefs([modalRef, ref, presenceContext?.presenceRef])on the
<Layer>that renders.cds--modal. Why that merged ref is still unset when akeydownlistener registered by an effect first runs is Carbon-internal ref-attachment ordering.
Also missing: no
onCancelis wiredDialogalready accepts anonCancelprop and puts it straight on the<dialog>(
Dialog.js,onCancelin the element's props). NeitherComposedModalnorModalpasses one, sothe 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:
ComposedModalDialogdirectlyModalDialogdirectlyTearsheet(@carbon/ibm-products)ComposedModalRuled out
StrictModedouble-mountingStrictModeabsent entirely<ComposedModal open onClose>, no wrapper<FeatureFlags>scopescreatePortalrefconflicting with Carbon'smodalRefis private and merged internallyenablePresenceandenableDialogElementare first-class typed props onFeatureFlags, notunstable_-prefixedSuggested fix
Two parts, either of which alone reduces the severity:
treated as "not topmost". Falling back to the DOM comparison, or deferring listener registration
until the ref is attached, would both work.
onCancelthroughComposedModal/ModalintoDialogand route it intocloseModal,so the browser's close request and the close button share one path.
Dialogalready supports theprop.
Notes on the sandbox
StrictModeis deliberately removed frommain.tsx. This is not a double-mounting artefact andreproduces with
StrictModeabsent entirely.@carbon/reactis pinned to1.114.0so the sandbox stays meaningful over time. We first hit thison
1.112.0, andisTopmostVisibleModalis byte-identical between the two, so this is not arecent regression.
there, the defect is only that the first Esc did nothing.
Context
enableDialogElementon Carbon's own accessibility guidance, so this surfaced as aregression for us rather than an experimental-flag issue.
enable-dialog-element: move to stable #22240 (enable-dialog-element: move to stable) — this looks like a blockerfor promoting that flag.
^19.0.0support, 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
cancelin the capture phase (it does notbubble), scoped to
.cds--dialog, callpreventDefault()to stop the browser closing behind React,then re-dispatch
Escape— which Carbon then honours. The close still travels Carbon's owncloseModal, soonClosefires 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
ComposedModal,Modal, and theenableDialogElement-off control.Expected: the modal closes and
onClosefires once.Actual:
onClose callsstays0,last Esc defaultPreventedisfalse, the<dialog>loses itsopenattribute, and the.cds--modaloverlay stays mounted andis-visible— a shaded page with no modal on it and nothing clickable. A second Esccloses it correctly.
Reproduction rate. In the StackBlitz above the defect is consistent: measured
fresh load → Open → Esc, the overlay stranded 6/6 on
ComposedModaland 3/3 onModal,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