bugfix(modal): stack body portals by open order, not mount order (CUI-43) - #1186
Conversation
Hello jeanmarcmilletscality,My role is to assist you with the merge of this Available options
Available commands
Status report is not available. |
Waiting for approvalThe following approvals are needed before I can proceed with the merge:
Peer approvals must include at least 1 approval from the following list: |
c282d3d to
09e1cda
Compare
…-43) Modal created its portal host on mount and `prepend`ed it to <body>, so with one shared z-index the earliest-mounted modal always painted on top — and a mounted-but-closed modal reserved a stacking slot it never used. Host the portal only while open and `append` it, so <body> order is open order. Drawer had the identical defect; fixed the same way, gated on `mounted` so the node survives the closing transition. Keyed on the DOM rather than the ticket's suggested incrementing z-index: a module-level counter in core-ui is not shared across Module Federation remotes, since federated hosts share it non-singleton, so two remotes would each start their counter at the same value and collide — which is the reported case. zIndex.modal stays a single constant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
09e1cda to
3ab17ac
Compare
Waiting for approvalThe following approvals are needed before I can proceed with the merge:
Peer approvals must include at least 1 approval from the following list: |
|
/approve |
In the queueThe changeset has received all authorizations and has been added to the The changeset will be merged in:
There is no action required on your side. You will be notified here once IMPORTANT Please do not attempt to modify this pull request.
If you need this pull request to be removed from the queue, please contact a The following options are set: approve |
|
I have successfully merged the changeset of this pull request
Please check the status of the associated issue CUI-43. Goodbye jeanmarcmilletscality. |
TL;DR
Two open
Modals stacked by the order they mounted in, not the order they were opened in, so the modal a user opened last could be painted underneath another one with its buttons unclickable. The portal host is now attached when the modal opens — and appended rather than prepended — so<body>order is open order.Drawerhad the identical defect and is fixed the same way.Context
Reported by a consuming application that composes two independently-owned Module Federation remotes, each auto-opening a modal on load: the modal that opened second painted underneath the first and swallowed clicks on its own close button. Long-standing bug, not a regression from any recent dependency bump — the bump only shifted remote-load timing enough to lose a pre-existing race consistently. The consumer worked around it in its own test suite; this fixes the library. See CUI-43 for the internal report.
Approach
Every
ModalContainergets the samez-index: 8500, so paint order among open modals is decided purely by<body>order — and the host wasprepended at mount, which puts the newest host first, where it paints below its siblings. Net effect: the earliest-mounted modal always won, and a mounted-but-closed modal still reserved a slot it never used.Before:
After:
Draweris gated onmountedrather thanisOpen, becausemountedspans the closing transition — gating onisOpenwould yank the node out mid-animation.document.body.removeChild(host)also becamehost.remove(), which does not throw if the node has already moved.Why not the incrementing z-index the ticket recommended
CUI-43 proposed three fixes and called the third — assign an incrementing z-index when a modal opens — the most robust. It isn't, for this bug: a module-level counter in core-ui is not shared across Module Federation remotes. A federated host typically shares most dependencies non-singleton and lists only a chosen few as singletons;
@scality/core-uiis not among the singletons in the host configuration checked here. So two remotes can each hold their own core-ui instance with its own counter, both starting at the same value — colliding straight back into DOM order, which is exactly the reported scenario. The DOM is the only coordination surface genuinely shared across remotes, so ordering stays keyed on<body>order andzIndex.modalremains a single shared constant.Measured
Real browser,
elementFromPointon each footer button.Aalways mounts first:<body>orderConfirm[B, A][A, B][B, A]Tracking both directions is the point: it shows the order follows open order, not merely a reversed mount order.
The sharpest user-facing symptom isn't the visual one. The focus effect is keyed on
isOpen, so focus went to the modal that opened last while paint went to the one that mounted first — and both containers carryaria-modal="true", so assistive tech announced a dialog the user could not see andTabwalked controls hidden behind another modal's overlay:Audit of the other
document.bodyportalsCUI-43 asked for this.
Tooltipneeds no fix: its overlay portals straight todocument.bodyand only while visible, so React appends it at show time (already the safe polarity) atz-index: 9990, above modal and drawer. The ticket's concern thatButtonwraps every button in aTooltipis accurate (Buttonv2.component.tsx:370, unconditional) but harmless — with no overlay nothing is ever portalled, andTooltipContainerrenders inline.Usage
No API change. The guarantee the fix adds, as the new
StackedModalsstory exercises it:Open B then A, and A is on top with its buttons live. Before, A was on top either way.
Review focus
src/lib/components/modal/Modal.component.tsx› theuseLayoutEffect— a behaviour change on everyModalin every consumer. The bit worth confirming is effect ordering: the layout effect must attach the host before theisOpenfocus effect runs, which is what keeps auto-focus working (layout effects run first, so it holds — but it's load-bearing).src/lib/components/drawer/Drawer.component.tsx› theuseLayoutEffect— gated onmounted, notisOpen, deliberately. If that call is wrong, the drawer loses its node mid closing-transition.stories/Modal/modal.stories.tsx›StackedModals— new story; doubles as the manual repro.How to test
No screenshot: the change is a stacking order, so the meaningful evidence is which element takes the click. Reproduce it directly —
npm run storybook→ Components / Feedback / Modal / Stacked Modals.development/1.0, both orders leave A on top, and in step 2 Confirm B does nothing.document.bodyholds no leftover empty hostdivs — before, there was one per mountedModalwhether or not it ever opened.Follow-up
.sc-modalnodes in the document rather than use module state — same cross-remote reason that ruled out the incrementing z-index above. Not in this PR.src/lib/components/charts/common/ChartTooltip.tsx:178creates its portal host withappendChildat mount — mount-time rather than show-time, butappendis the safe polarity and it declares no z-index of its own. Left alone.Modalregisters its Esc handler ondocumentper open modal, so Esc closes every open modal at once. Pre-existing, untouched here.References
Modalstacking is inverted and keyed on mount order, not open order". Bug / Severity Major / Impact Internal. Carries the downstream report and the consumer-side workaround this replaces at the library level.What changed
Modal.component.tsxandDrawer.component.tsxeach move their portal-host insertion out of a mount-time effect and into one gated on being open, switchingprependforappend. Nothing else in either component changes — no props, no styles, no z-index values.Modal.test.tsxis new and asserts the four cases in DOM terms (jsdom cannot paint): no host while closed, open-beats-mounted-first, two opens ordered by open order, and re-opening raising a modal back to the top. Three equivalent cases were added to the existingDrawer.component.test.tsx. All seven were confirmed to fail againstdevelopment/1.0before the fix.Deliberately not in this PR: making
zIndex.modalper-instance (unnecessary once DOM order encodes open order), any enforcement of a single-modal rule, and theChartTooltipmount-time host.🤖 Generated with Claude Code