review: the spread was overwriting the stack, and the guard was blind

Acting on the review of #622.  Two bugs, both real, and the second one is a
test of mine that could not fail for its stated reason.

The stack props were set BEFORE `{...props}`, so any call site passing
`closeOnEscape` overwrote the stack's decision and re-armed Mantine's
window-level Escape listener on a modal that is not on top -- reinstating the
bug the change exists to fix.  Three call sites pass it: CollectionReorganizeModal
and BatchReorganizeModal disable Escape while a reorganize runs, Flasher while
it writes an image.  The concrete breakage is the one place the app really does
stack two modals: ConflictResolutionModal opens as a sibling above the
reorganize modal, so the moment the reorganize finished and `closeOnEscape`
returned to true, one Escape closed both.  They are now destructured out and
composed -- `isTop && closeOnEscape` -- because "only the top modal answers
Escape" and "do not dismiss me while I am busy" are both true.  Mantine's own
Modal.Stack applies its stack props after the caller's for the same reason.
Two Cypress cases, one per direction; planting the old order reddens the first.

`Confirm` was still passing Mantine's raw `sm`, and the audit guard I added in
the same PR was supposed to catch exactly that.  It did not, and the reason is
worse than the miss: the scanner matched `<Modal\b[^>]*?\bsize='...'`, and
`onClose={() => ...}` contains a `>`, so it stopped at the arrow and never saw
the rest of the tag.  It read 44 of the 56 modals that declare a size; the
twelve it skipped included the single offender in the codebase, in this very
file.  A guard that scans a fifth of nothing looks identical to a clean
codebase.  It now walks the tag with a paren counter, accepts either quote
style, and asserts it scanned more than fifty tags first -- so a scanner that
silently matches nothing fails instead of passing.  Confirm says `tiny`.

Also from the review: a bare `expect(titles()).to.not.include('Parent')` in the
Cypress spec ran during the synchronous phase, before any cy command executed
and while nothing was open, so it always passed and guarded nothing.  Deleted
rather than repaired -- the retried assertion above it already makes the claim.

An unregistered-but-open modal reported `isTop` even with a parent registered,
which gave both the same z-index for the frame before the effect ran -- a visible
flash of the wrong modal in front.  It is only top now if the stack is empty.

Nit: the comment claimed Mantine ignores unknown size names.  It ignores unknown
SEMANTIC names and falls back to 440px; it recognises and honours its own.  Two
different hazards, both silent, and the comment now says which is which.

1124 jest / 62 suites, 10 in modal-stack.cy.js, 196 in ui-layout.cy.js,
clean npm run build.
This commit is contained in:
Roland 2026-08-02 15:39:43 -06:00
parent afa127a992
commit ad78cdee7c
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 141 additions and 25 deletions

View file

@ -50,8 +50,6 @@ const Nested = ({onParentClose, onChildClose}) => {
</>
};
const titles = () => [...Cypress.$('.mantine-Modal-title')].map(el => el.textContent);
const openBoth = () => {
cy.mountUI(<Nested/>);
cy.get('[data-testid="open-parent"]').click();
@ -194,6 +192,64 @@ describe('a single modal', () => {
pressEscape();
cy.contains('.mantine-Modal-title', 'Parent').should('not.exist');
expect(titles()).to.not.include('Parent');
});
});
describe('a parent that disables Escape while it is busy', () => {
/*
* Three call sites pass `closeOnEscape` of their own: CollectionReorganizeModal and
* BatchReorganizeModal disable it while a reorganize runs, Flasher while it writes an
* image. The stack props were set BEFORE the `{...props}` spread, so those call sites
* overwrote the stack's decision and re-armed Mantine's window-level listener on a modal
* that is not on top.
*
* The concrete breakage: ConflictResolutionModal opens as a sibling above the reorganize
* modal, so as soon as the reorganize finished and `closeOnEscape` went back to true, one
* Escape closed both again -- the original bug, in the one place the app really does
* stack two modals.
*
* "Only the top modal answers Escape" and "do not dismiss me while I am busy" are both
* true; they compose with AND.
*/
const Busy = ({busy}) => {
const [parent, setParent] = React.useState(true);
const [child, setChild] = React.useState(true);
return <>
<Modal open={parent} size='large' closeOnEscape={!busy}
onClose={() => setParent(false)}>
<Modal.Header>Reorganize</Modal.Header>
<Modal.Content><p>working</p></Modal.Content>
</Modal>
<Modal open={child} size='small' onClose={() => setChild(false)}>
<Modal.Header>Conflicts</Modal.Header>
<Modal.Content><p>resolve these</p></Modal.Content>
</Modal>
</>
};
it('still closes only the child when the parent has re-enabled Escape', () => {
cy.mountUI(<Busy busy={false}/>);
cy.contains('.mantine-Modal-title', 'Conflicts').should('exist');
pressEscape();
cy.contains('.mantine-Modal-title', 'Conflicts').should('not.exist');
cy.contains('.mantine-Modal-title', 'Reorganize').should('exist');
});
it('honours the parent\'s own refusal once it is on top again', () => {
/*
* The other direction, and the reason the two are composed rather than the stack
* simply winning: a busy parent must stay put even when nothing is above it.
*/
cy.mountUI(<Busy busy/>);
cy.contains('.mantine-Modal-title', 'Conflicts').should('exist');
pressEscape();
cy.contains('.mantine-Modal-title', 'Conflicts').should('not.exist');
pressEscape();
cy.contains('.mantine-Modal-title', 'Reorganize')
.should('exist');
});
});