Skip to content

Commit 086480c

Browse files
committed
fix(admin/spa): split Modal effect to stop focus flicker on busy toggle
Claude review on b36cfed caught a real focus-management bug: the single useEffect in Modal.tsx mixed two invariants — focus capture/restore (open) and the keyboard handler (open + busy + onClose) — into one effect with deps [open, busy, onClose]. Concrete failure: user clicks Save, parent flips busy=true. React runs the cleanup, which removes the keydown listener AND restores focus to the previously-focused element (the trigger button, outside the dialog). The effect then re-runs, captures the trigger button as the NEW restore target, and snaps focus back. Net: focus flickers out and back on every busy toggle, previouslyFocusedRef gets clobbered with the wrong element, and screen readers announce the dialog as briefly "exited" mid-operation. Fix: split into two effects. - Focus capture/restore — deps [open] only. Runs once on open, once on close. previouslyFocusedRef stays valid for the whole dialog lifetime. - Keyboard handler — deps [open, busy, onClose]. Re-binds when any of those change; only observable side effect is one window listener swap. All three automated reviewers (Claude, Gemini, Codex) flagged this. The split is the standard React idiom — each invariant owns its own state slice. The dist/ placeholder index.html is intentionally NOT regenerated (per .gitignore: assets are built at deploy time, not committed).
1 parent 4569191 commit 086480c

1 file changed

Lines changed: 31 additions & 17 deletions

File tree

web/admin/src/components/Modal.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,41 @@ export function Modal({ title, open, onClose, children, busy }: ModalProps) {
3030
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
3131
const titleId = useId();
3232

33+
// Focus capture / restore is tied to `open` ONLY. Folding `busy`
34+
// into the same effect as the previous version did caused the
35+
// cleanup to fire on every busy toggle (e.g. the user clicks Save
36+
// and the parent flips busy=true): focus would briefly leave the
37+
// dialog, previouslyFocusedRef would get clobbered with the
38+
// trigger button, then the next run would snap focus back. Real
39+
// bug for screen-reader users — the dialog became "exited" mid-
40+
// operation. Splitting the effects keeps each invariant tied to
41+
// its own state slice (Claude review on #650).
3342
useEffect(() => {
3443
if (!open) return;
35-
3644
previouslyFocusedRef.current = (document.activeElement as HTMLElement | null) ?? null;
37-
// Focus the first focusable element so keyboard users land
38-
// inside the dialog instead of the page underneath. Run on the
39-
// next tick so the dialog DOM exists and any autofocus has had
40-
// a chance to settle.
45+
// queueMicrotask defers focus until after sibling useEffect
46+
// hooks (notably any autofocus on form fields inside the
47+
// dialog children) have settled, so we focus the truly-first
48+
// tab stop rather than racing with autofocus.
4149
queueMicrotask(() => focusFirstFocusable(dialogRef.current));
50+
return () => {
51+
// Restore focus to whoever opened the dialog. Guard against
52+
// the trigger being unmounted (e.g. the dialog deleted the
53+
// row whose button opened it) by checking isConnected.
54+
const restore = previouslyFocusedRef.current;
55+
previouslyFocusedRef.current = null;
56+
if (restore && restore.isConnected) {
57+
restore.focus();
58+
}
59+
};
60+
}, [open]);
4261

62+
// Keyboard handler legitimately needs `busy` (Esc gates on it) and
63+
// `onClose`, so it re-binds when either changes. Re-binding is
64+
// cheap — one window listener swap per change — and unlike the
65+
// focus effect it has no observable side effect on the user.
66+
useEffect(() => {
67+
if (!open) return;
4368
const onKey = (e: KeyboardEvent) => {
4469
if (e.key === "Escape" && !busy) {
4570
onClose();
@@ -65,19 +90,8 @@ export function Modal({ title, open, onClose, children, busy }: ModalProps) {
6590
first.focus();
6691
}
6792
};
68-
6993
window.addEventListener("keydown", onKey);
70-
return () => {
71-
window.removeEventListener("keydown", onKey);
72-
// Restore focus to whoever opened the dialog. Guard against
73-
// the trigger being unmounted (e.g. the dialog deleted the
74-
// row whose button opened it) by checking isConnected.
75-
const restore = previouslyFocusedRef.current;
76-
previouslyFocusedRef.current = null;
77-
if (restore && restore.isConnected) {
78-
restore.focus();
79-
}
80-
};
94+
return () => window.removeEventListener("keydown", onKey);
8195
}, [open, busy, onClose]);
8296

8397
if (!open) return null;

0 commit comments

Comments
 (0)