Repository navigation
fix(web): arm a dialog's escape in the commit that shows it - #24284
KiroCrewCaptain merged 1 commit into
Conversation
Modal attached its window Escape listener in a passive effect, and so did useDialogFocusTrap's keydown trap. When a dialog opens from a non-sync update (a fetch answering, as the unscanned-download confirm does), React can run passive effects a task after the commit. An Escape pressed in that gap reached a dialog with no listener and was dropped: the user's first Escape did nothing, and the unscannedDownload "Escape cancels" test hung until its 15 s timeout. Both listeners now attach in a layout effect, which runs in the same commit that puts the dialog in the DOM. New tests press Escape from the MutationObserver callback that first sees the dialog; both fail on main and pass with the fix.
UX Review (Opus 5.5, fork) — ✅ PASSUX-level review of UX-Verdict: PASS Nothing to check. The diff only moves two Escape/Tab keydown listeners from [UX-REVIEWED] 476c26c Description read[DESCRIPTION-READ] 3df2ab1dbd5e95015002c854a781e2df39154bc49df4e64bf63d1335c5a23110 That sha256 names everything this verdict read: the PR description, plus the 3 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with |
Design Review (Opus 5.5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Nothing blocks. The fix targets the real cause: the listener attached in a passive effect, which can run after the dialog is already painted. Only the attach timing changes. Notes
[DESIGN-REVIEWED] 476c26c Description read[DESCRIPTION-READ] dd5c4b5e25a5aded6edfca376f6b0f1d090ab9c3e9724d6fc305b8bb3f389d30 That sha256 names everything this verdict read: the PR description, plus the 1 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with |
First Principles Review (Opus 5.5, fork) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS Nothing blocks. Two keydown listeners now attach in a layout effect, which fixes the gap between the dialog's commit and React's passive-effect flush. A test that fails on base shows the defect, the PR closes #24282, and the unfixed siblings are tracked in #24283. What this change shipsInventory (4 items) — 4 justifiedIntent: Escape closes a dialog as soon as it appears, even when a network response opened it. FIX.
[FIRST-PRINCIPLES-REVIEWED] 476c26c Description read[DESCRIPTION-READ] 0f0fc6aa0a1d1730c129659e0b208ecdf9f4a86fa3cf34ba24cdca1a73368988 That sha256 names the PR description this verdict read. Recompute it with |
GPT 6.1 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 5.5 Review (fork) — ✅ no blocking findingsReviewed |
bolichen97
left a comment
There was a problem hiding this comment.
Reviewed at 476c26c.
The change is the smallest fix for the stated cause. Both keydown listeners now attach in a layout effect, which runs in the same commit that inserts the dialog. The handler bodies, deps and phases are unchanged, and the scroll lock stays in its own passive effect.
What I checked:
- The new tests press Escape from a MutationObserver callback. That callback is a microtask, and React runs layout effects synchronously inside the commit, so with the fix the listener is always attached first. The pass side is deterministic.
- Listener order: Modal's bubble listener now registers before any passive effect listener mounted in the same commit. The nested layers that consume Escape (InfoTip, AgentSelector, AgentSkillsEditor) listen in the capture phase and only while open, so they still run first and the defaultPrevented check still holds.
- 4 files, the linked issue is a bug, no security path is touched, and all review lanes pass on this head.
This looks good to ship. The remaining check is PR Readiness, which is still pending on this head because the backend and frontend test shards are running. I will approve after readiness passes on this same head.
KiroCrewCaptain
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, security checklist all-NO, no reviewer-lane findings, readiness signal fresh for this head SHA, declared category matches the diff. Category: a dialog's Escape listener now attaches in a layout effect, so it is armed in the commit that shows the dialog (closes #24282), with tests.
Problem / Motivation
Goal: Escape closes a dialog as soon as the dialog is on screen, even when it opened from a network answer instead of a click.
A dialog that opens from something other than a click can ignore the first Escape. The unscanned-download confirm ("Too large to scan") opens when the download request answers 409. An Escape pressed right as it appears does nothing: the dialog stays open and the download keeps waiting. CI caught this as a flake in
unscannedDownload.test.tsx> "a download over the scan limit asks first" > "Escape cancels" (Frontend Tests (4), run 40182366810, on PR #10132, which cannot reach this code):Test timed out in 15000ms.Why it matters
Escape is the keyboard way out of every
Modaland every dialog built onuseDialogFocusTrap. A first key press that does nothing makes the dialog look stuck to a keyboard user. The unscanned-download confirm is opened from the app root after the menu that started it has closed, so a dropped Escape leaves the download waiting on a dialog the user thinks they dismissed. The same race also turns a real product test into a cross-PR flake.Not a goal
Modal's handler logic, itsdefaultPreventedcheck and its bubble phase stay the same.Modal's body scroll lock. It stays in its ownuseEffect, exactly as on main.What changed (motivation → approach → change)
onClose, and the confirm promise never settles.Modalattached its window Escape listener in auseEffect, anduseDialogFocusTrapattached its keydown trap the same way. When the open comes from a default-priority update (a fetch answering) and the render takes longer than React's 5 ms scheduler slice, React commits the DOM and then yields to the host before it runs passive effects. In that gap the dialog is visible and nothing is listening, so the key is dropped.useLayoutEffect, which runs in the same commit that inserts the dialog. The handlers, their deps and their phases are unchanged. InModal, the scroll lock is split out and stays in a passiveuseEffect, so its ordering against other scroll lockers is the same as on main.Modalwindow Escape (bubble)useEffectuseLayoutEffectuseDialogFocusTrapwindow keydown (capture: Escape + Tab trap)useEffectuseLayoutEffectModalbody scroll lockuseEffectuseEffect(unchanged)useDialogFocusTrapfocus-in / restoreuseEffectuseEffect(unchanged)Backwards compatibility
Compatible: the same listeners run with the same logic. They are now attached one task earlier, in the commit that shows the dialog, and they are removed at the same unmount.
Removes nothing: no path, record, validator, key or function is removed. Two listeners change only when they attach.
Tests
Proof: not provable -- frontend test (prove.py runs Python only)
Two new deterministic tests. Each opens a dialog with a default-priority update outside
act(), and gives it a render that takes 20 ms, longer than the scheduler slice, so React must yield before passive effects. Then it presses Escape from theMutationObservercallback that first sees[role="dialog"], which is the momentfindByRoleresolves on. Nothing waits on wall-clock time, and elapsed time is not asserted.Modal.escape.test.tsx> "an Escape pressed the moment the dialog appears still closes it": locks in thatonCloseis called once.useDialogFocusTrap.arming.test.tsx> "an Escape pressed the moment the dialog appears reaches onEscape": locks in thatonEscapeis called once.Red/green evidence (head
476c26c5f8):origin/mainproduct codeModal.escapenew test, 10 separate vitest runsexpected "vi.fn()" to be called 1 times, but got 0 times)useDialogFocusTrap.armingnew test, 5 runsModaltest with the 20 ms render removed (control), 3 runs{ repeats: 40, timeout: 3000 }, 32 parallel vitest processes beside 32 CPU-hog processes on 32 coresTest timed out in 3000ms, the CI signature)Determinism: the two new test files plus
unscannedDownload.test.tsx, 20 repeats, 20/20 green. A shuffled run of those three plus the four other Modal and focus-trap test files (--sequence.shuffle --sequence.seed=20310): 54/54 green.Local gates: the full
vitest run(3,336 files) was green on the first full run. One file failed on the second:MarkdownPanel.editFocus.test.tsx,Unable to find an element by: [data-testid="pierre-editable"]. That test uses neitherModalnor the trap. It fails onorigin/mainunder the same load too (11 of 32 parallel runs) and passes 10/10 alone with this fix.tsc -p tsconfig.app.json,eslinton the 4 files,test/test_flake_pattern_ratchet.py(60) andsemgrep scan --config semgrep/(1.78.0, diff baseline) all pass.local-gate.py's backend cross-surface set reports 61 failed and 15 errors on this host. A cleanorigin/mainworktree with the same venv reports the same 61 and 15, so CI is the authority there.Manual verification
N/A — unit coverage sufficient: the defect is a window between a DOM commit and React's passive-effect flush. A person cannot press a key inside it on demand, and the new tests force it exactly.
Screenshots / video
Why no screenshot: No pixel changes; only when two keydown listeners attach changes, and the dialogs render identically.
Related Issues
Closes #24282
Sibling sweep
Swept:
git grep -nE "addEventListener\('keydown'" -- website/src(70 files), filtered to listeners that live in a dialog-like component which can open from a non-click updateSibling: website/src/hooks/useDialogFocusTrap.ts:144 -- changed here
Sibling: website/src/components/UpdateFoundModal.tsx:336 -- tracked in #24283
Sibling: website/src/components/notifications/NotificationBanner.tsx:304 -- tracked in #24283
Sibling: website/src/apps/md-notebook/ConfirmDialog.tsx:64 -- not affected -- it opens only from a row's Delete click, and React flushes a click's passive effects within that same commit
Pattern harvest
Rule candidate: review-prompt
Pattern: "a dismissal or key listener for an overlay that can open from async data is attached in useEffect: React may paint before the passive flush, so attach it in useLayoutEffect"
Checklist
feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
Contributions are accepted under the project's Apache-2.0 license; see the Licensing section of CONTRIBUTING.md.