Compare commits

...

5 Commits

Author SHA1 Message Date
rusackas
cafdd491bc docs: note UnsavedChangesModal zIndex prop removal in UPDATING.md
Addresses review feedback on #42548 about downstream API compat --
document the removed prop instead of re-adding a passthrough that
would reintroduce the hardcoded z-index footgun this PR fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 10:59:01 -07:00
Claude Code
481d78db64 fix: destroy UnsavedChangesModal's portal on hide so DOM order tracks true open-recency
Dropping the hardcoded z-index (previous commit) fixes the simple case, but
not the general one: two sibling Modals always fall back to the same static
z-index and are tie-broken by DOM order, and Ant Design's Modal portal node
is otherwise created once, lazily, on first open, and never moves again. If
this modal is ever opened once before whatever it's meant to interrupt is
opened for the first time, a later reopen goes right back to that stale,
now-too-early DOM position and renders behind it again -- reproducing the
original bug even with no z-index anywhere. destroyOnHidden tears the portal
down on every close so every open recreates it at the end of the document,
making DOM order (and thus stacking) always match true open-recency.

Replaces the failing regression test (which asserted z-index magnitude, an
invariant sibling modals were never guaranteed to hold, and which jsdom
can't resolve anyway) with two DOM-order assertions: the original scenario,
and the reopen-after-prior-open scenario that only the destroyOnHidden fix
actually covers. Adds a permanent Storybook story with a toggle to visually
reproduce the bug and the fix side by side.
2026-07-31 13:16:55 -07:00
rusackas
fc6e4321f3 test: dedupe modal accessible-name lookup in UnsavedChangesModal z-index test
rc-util's useId hook always returns the mocked string "test-id" in test
environments, so when two Ant Design modals are open at once their
aria-labelledby ids collide and getByRole('dialog', { name }) can't
distinguish them. Match dialogs by their title text instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 00:48:55 -07:00
rusackas
7f102fa853 fix: check .ant-modal-wrap for z-index in stacking regression test
Ant Design applies the automatically-assigned stacking z-index to the
.ant-modal-wrap element, not to the role="dialog" element, so
getComputedStyle on the dialog itself returned an empty string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:27:42 -07:00
rusackas
a0851f38b6 fix(core): let UnsavedChangesModal use Ant Design's automatic z-index stacking
UNSAVED_CHANGES_MODAL_Z_INDEX was a hardcoded literal meant to keep this
modal above other open modals (e.g. a draggable View query modal).
Ant Design already auto-increments z-index for every newly opened
Modal off theme.zIndexPopupBase, so any manual override just needs to
be bumped again whenever something else's computed z-index creeps
past it, which is exactly what happened (#42510).

Audited every other zIndex/z-index usage in the frontend: this was the
only place hardcoding a z-index override on a Modal-class component.
Everything else either doesn't need one, or already derives it from
theme.zIndexPopupBase/theme.zIndexBase for a real, local reason (sticky
headers inside modal content, the native Fullscreen API's interaction
with portaled antd components, custom fixed-position overlays that sit
outside Ant Design's own stacking system entirely). None of those are
part of this bug and are left untouched.

Since this modal is always opened on top of whatever it's interrupting,
dropping the override (the constant, the prop, and the pass-through)
lets it stack correctly with no number to maintain.
2026-07-29 18:20:45 -07:00
4 changed files with 228 additions and 8 deletions

View File

@@ -24,6 +24,15 @@ assists people when migrating to a new version.
## Next
### `UnsavedChangesModal` no longer accepts a `zIndex` prop
`@superset-ui/core`'s `UnsavedChangesModal` dropped its `zIndex` prop (and the
hardcoded default it fed) in favor of letting Ant Design's own stacking
handle placement. Callers passing `zIndex` to override the modal's layering
will now get a TypeScript error and must remove the prop; keeping a manual
override was exactly the footgun this change removes (see #42510). No
callers in the Superset frontend codebase itself passed this prop.
### Principal listing APIs now honour related-field filters
Two authorization-related listing behaviors changed for API clients. Neither

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { useState } from 'react';
import { Button } from '../Button';
import { Modal } from './Modal';
import type { ModalProps, ModalFuncProps } from './types';
@@ -179,3 +180,74 @@ ModalFunctions.args = {
maskClosable: true,
mask: true,
};
/**
* Two top-level Modals that are React siblings, not nested inside one
* another (e.g. a "View query" modal and a confirmation dialog it can
* trigger, like `UnsavedChangesModal`). Ant Design only assigns an
* automatically-incremented z-index when a Modal is nested inside another
* *currently open* Modal's React tree, so two siblings always fall back to
* the same static z-index and are tie-broken by DOM order: whichever
* `.ant-modal-wrap` was inserted later paints on top.
*
* With `destroyOnHidden={false}` (Ant Design's default), a Modal's wrap
* node is created once, lazily, on first open, and is never removed or
* recreated afterward. So the modal that happens to have been opened
* *first ever*, not most recently, keeps winning the DOM-order tiebreak
* even after being closed and reopened. Toggle "Reproduce stale DOM order"
* off to see the fix: with `destroyOnHidden`, every open recreates the wrap
* node at the end of the document, so DOM order (and stacking) always
* matches true open-recency and no manual z-index is ever needed.
*
* To see the bug: click "Open A", close it, then "Open B", then "Open A"
* again -- with the toggle on, A renders behind B despite being the modal
* that was opened most recently.
*/
export const SiblingModalStacking = ({
reproduceStaleDomOrder,
}: {
reproduceStaleDomOrder: boolean;
}) => {
const [showA, setShowA] = useState(false);
const [showB, setShowB] = useState(false);
return (
<div>
<Button onClick={() => setShowA(true)} buttonStyle="secondary">
Open A
</Button>
<Button onClick={() => setShowB(true)} buttonStyle="secondary">
Open B
</Button>
<Modal
name="modal-a"
title="Modal A"
show={showA}
onHide={() => setShowA(false)}
destroyOnHidden={!reproduceStaleDomOrder}
>
Modal A content
</Modal>
<Modal
name="modal-b"
title="Modal B"
show={showB}
onHide={() => setShowB(false)}
destroyOnHidden={!reproduceStaleDomOrder}
>
Modal B content
</Modal>
</div>
);
};
SiblingModalStacking.args = {
reproduceStaleDomOrder: true,
};
SiblingModalStacking.argTypes = {
reproduceStaleDomOrder: {
control: 'boolean',
description:
'On: Ant Design default behavior, a modal opened once keeps its DOM position forever (the bug from #42510). Off: destroyOnHidden, DOM order always matches true open-recency (the fix).',
},
};

View File

@@ -16,7 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { useState } from 'react';
import {
render,
screen,
userEvent,
waitFor,
within,
} from '@superset-ui/core/spec';
import { Modal } from '@superset-ui/core/components';
import { UnsavedChangesModal } from '.';
test('should render nothing if showModal is false', () => {
@@ -94,3 +102,128 @@ test('should only call handleSave when clicking the Save button', async () => {
expect(mockOnHide).not.toHaveBeenCalled();
expect(mockOnConfirmNavigation).not.toHaveBeenCalled();
});
// Regression coverage for the underlying bug (#42510): this modal could
// render BEHIND another already-open modal (e.g. a draggable "View query"
// modal). Ant Design only assigns a Modal a higher z-index automatically
// when it's nested inside another *currently open* Modal's React tree --
// two top-level siblings (this modal's Modal and whatever it's interrupting
// are always siblings, never nested in each other) both fall back to the
// same static z-index, tie-broken by DOM order: whichever `.ant-modal-wrap`
// comes later in the document paints on top. So the invariant this modal
// actually needs to hold isn't "higher z-index than the other modal" (both
// are legitimately unset/tied by design) -- it's "always ends up later in
// the DOM than whatever it's interrupting, no matter what already happened
// on the page." A modal's wrap node is created once, lazily, on first open,
// and normally stays in that DOM position forever; `destroyOnHidden` is
// what makes every open recreate it fresh at the end of the document.
function dialogWrap(titleText: string) {
const dialogs = screen.queryAllByRole('dialog');
// rc-util's `useId` hook always returns the same mocked id ("test-id") in
// test environments, so with two dialogs open at once their
// `aria-labelledby` ids collide and `getByRole('dialog', { name })` can't
// tell them apart. Find each by its title text instead.
const dialog = dialogs.find(d => within(d).queryByText(titleText));
return dialog?.closest<HTMLElement>('.ant-modal-wrap') ?? null;
}
test('renders above an already-open modal without a hardcoded z-index', async () => {
render(
<>
<Modal show title="Other open modal" onHide={() => {}}>
<div>Other modal content</div>
</Modal>
<UnsavedChangesModal
showModal
onHide={() => {}}
handleSave={() => {}}
onConfirmNavigation={() => {}}
/>
</>,
);
const otherWrap = await waitFor(() => {
const wrap = dialogWrap('Other open modal');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
const unsavedChangesWrap = await waitFor(() => {
const wrap = dialogWrap('Unsaved Changes');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
// eslint-disable-next-line no-bitwise
expect(
otherWrap.compareDocumentPosition(unsavedChangesWrap) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
test('still renders on top after being opened, closed, and reopened once the other modal is already open', async () => {
function Harness() {
const [showOther, setShowOther] = useState(false);
const [showUnsaved, setShowUnsaved] = useState(false);
return (
<>
<button type="button" onClick={() => setShowOther(true)}>
open other
</button>
<button type="button" onClick={() => setShowUnsaved(true)}>
open unsaved
</button>
<Modal
show={showOther}
title="Other open modal"
onHide={() => setShowOther(false)}
>
<div>Other modal content</div>
</Modal>
<UnsavedChangesModal
showModal={showUnsaved}
onHide={() => setShowUnsaved(false)}
handleSave={() => {}}
// Mirrors real callers: confirming navigation is what dismisses
// this modal, not `onHide` directly (see the Discard-button test
// above -- clicking Discard never calls `onHide` on its own).
onConfirmNavigation={() => setShowUnsaved(false)}
/>
</>
);
}
render(<Harness />);
// Open this modal once -- e.g. some other in-app action tripped it --
// before the modal it's supposed to interrupt has ever been opened. Its
// wrap node gets created now, first in the document.
userEvent.click(screen.getByText('open unsaved'));
await waitFor(() => expect(dialogWrap('Unsaved Changes')).not.toBeNull());
userEvent.click(await screen.findByRole('button', { name: /discard/i }));
await waitFor(() => expect(dialogWrap('Unsaved Changes')).toBeNull());
// Now open the modal it's meant to interrupt for the first time.
userEvent.click(screen.getByText('open other'));
const otherWrap = await waitFor(() => {
const wrap = dialogWrap('Other open modal');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
// Reopen this modal -- the real scenario the bug report describes. If its
// wrap node were still the one created on the first open above, it would
// be stuck earlier in the document than `otherWrap` and render behind it
// again.
userEvent.click(screen.getByText('open unsaved'));
const unsavedChangesWrap = await waitFor(() => {
const wrap = dialogWrap('Unsaved Changes');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
// eslint-disable-next-line no-bitwise
expect(
otherWrap.compareDocumentPosition(unsavedChangesWrap) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});

View File

@@ -20,10 +20,6 @@ import { t } from '@apache-superset/core/translation';
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
import type { FC, ReactElement } from 'react';
// Ant Design's default modal zIndex is 1000. Using a higher value ensures
// this dialog always renders above other open modals (e.g. a draggable View SQL modal).
const UNSAVED_CHANGES_MODAL_Z_INDEX = 1300;
export type UnsavedChangesModalProps = {
showModal: boolean;
onHide: () => void;
@@ -31,7 +27,6 @@ export type UnsavedChangesModalProps = {
onConfirmNavigation: () => void;
title?: string;
body?: string;
zIndex?: number;
};
export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
@@ -41,7 +36,6 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
onConfirmNavigation,
title = 'Unsaved Changes',
body = "If you don't save, changes will be lost.",
zIndex = UNSAVED_CHANGES_MODAL_Z_INDEX,
}: UnsavedChangesModalProps): ReactElement => (
<Modal
centered
@@ -49,7 +43,19 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
onHide={onHide}
show={showModal}
width="444px"
zIndex={zIndex}
// This modal always interrupts something already on screen (a draggable
// "View query" modal, an in-progress form, etc). Ant Design only assigns
// a higher z-index automatically when a Modal is nested inside another
// open Modal's React tree; two top-level siblings both fall back to the
// same static z-index and are tie-broken by DOM order instead. Without
// destroyOnHidden, a Modal's portal node is created once (lazily, on
// first open) and then left in place forever, so if this dialog is ever
// opened once before whatever it's interrupting is opened, a later
// reopen would go right back to that stale, now-too-early DOM position
// and render behind it again. destroyOnHidden tears the portal down on
// every close, so every open recreates it at the end of the DOM and it
// reliably paints on top -- no z-index, hardcoded or otherwise, needed.
destroyOnHidden
title={
<>
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />