Repository navigation
Restore dialog cannot return focus: the page unmounts before it closes #3059
Description
Activity
리뷰 · 우선순위 62 / 80
지금
dev(HEAD93b7ee80a, #3048 직후)의 Integrations GUI는 #3050에서 만든 복원 확인 모달(gui/src/pages/integrations/RestoreDialog.tsx)과 파일 연동 페이지(gui/src/pages/integrations/FileIntegrationPage.tsx)가 붙어 있습니다. 이슈가 말하는 문제는 아주 단순합니다. 키보드로 복원을 확인하면, 닫힌 뒤에 포커스가 원래 버튼으로 돌아가야 하는데 그 버튼이 이미 화면에서 사라져 있어서<body>로 떨어집니다. 스크린 리더나 Tab만 쓰는 사람은 페이지 맨 위에서 다시 찾아야 합니다. 이건 WCAG 2.4.3(Focus Order)에 걸립니다.원인을 코드로 따라가면 이렇게 됩니다.
RestoreDialog는 열릴 때document.activeElement를restoreFocusRef에 담고, effect 정리(cleanup)에서restoreFocusRef.current?.focus?.()를 호출합니다. 주석에도 "버튼이 DOM에서 사라지면 best effort"라고 이미 적혀 있습니다. 그런데submit()은 성공 직후onRestored()를 먼저 부르고 그다음onClose()를 부릅니다. 파일 페이지에서는onRestored=``refresh``라서, refresh가 도는 동안status가 비면FileIntegrationPage.tsx128줄 근처의if (!status)` 로딩 분기로 통째로 바뀝니다. 그 순간 트리거 버튼과 다이얼로그 트리 전체가 언마운트됩니다. 그래서 cleanup의 focus()는 죽은 노드를 가리키거나, 아예 포커스할 곳이 없습니다. 부모에 ref를 올려도 같은 이유로 소용없습니다. 이슈 본문의 재현 단계(키보드만으로 복원 확인 → Tab이 문서 맨 위부터)와도 정확히 맞습니다.#3050이 만든 모달 포커스 트랩 자체는 예전 비모달
<dialog open>보다 낫습니다. Tab이 파괴적 확인 밖으로 새던 문제는 막았습니다. 지금 남는 건 "확인 후 자리로 돌아가기"입니다. 고치는 자리는 다이얼로그 한 파일이 아니라 페이지의 데이터 로딩 계약입니다. refresh가 끝날 때까지 트리거 자리를 유지하거나, 로딩 중에도 같은 섹션/트리거 id를 살려 두고, 데이터가 돌아온 뒤 그 id로 포커스를 되돌려야 합니다. 개요 쪽(IntegrationsOverview.tsx)에도restoreFocusRef와 refresh 경로가 있으니, 파일 페이지만 고치면 개요에서 같은 구멍이 남을 수 있습니다.라인 66 - RestoreDialog submit 성공 경로가 onRestored()를 onClose()보다 먼저 호출해서, 부모가 refresh로 status를 비우는 순간 트리거가 언마운트된다
라인 51 - cleanup의 restoreFocusRef.current?.focus?.()는 트리거가 살아 있을 때만 의미가 있는데, 위 순서면 대개 이미 죽은 노드다
라인 128 - FileIntegrationPage의 if (!status) 로딩 분기가 섹션 전체를 갈아엎어 복원 버튼과 다이얼로그 트리를 같이 지운다
경로 FileIntegrationPage onRestored=refresh - 포커스 복원을 다이얼로그 best effort에만 맡기고, 페이지는 refresh 전후 트리거 정체성(id)을 지키지 않는다
경로 IntegrationsOverview restoreFocusRef/refresh - 같은 패턴이 개요에도 있어 파일 페이지만 고치면 구멍이 남을 수 있다메인테이너의 판단이 필요한 지점
- refresh 중에도 기존 status/트리거를 화면에 남길지, 아니면 로딩 스켈레톤을 쓰되 data-restore-trigger 같은 안정 id로 포커스만 되돌릴지
- 개요(IntegrationsOverview)와 파일 페이지를 한 PR에서 같이 고칠지, 파일 페이지만 먼저 할지
- 회귀 테스트를 happy-dom에서 activeElement까지 断言할지, 아니면 언마운트 순서 단위 테스트로 충분할지
너의 추천
이슈를 열린 채로 두고, #3050 후속으로 작은 GUI PR을 받으세요. 권장 방향은 submit 성공 후 포커스 목표 id를 페이지가 기억한 뒤, refresh가 끝난 다음에 그 id로 focus() 하는 계약으로 바꾸는 것입니다. RestoreDialog의 best effort cleanup만 손보는 패치는 부족합니다. 개요 경로도 같은 PR 또는 바로 다음 PR에서 같이 막으세요.이 댓글은 grok-bot이 작성했습니다
- added a commit that references this issue
on Aug 31, 2026 - added a commit that references this issue
on Sep 1, 2026 Automated translation bookkeeping — detected language: English.
Client or integration
OpenCodex dashboard
Area
Dashboard
Summary
After confirming a restore in the Integrations rollback dialog, keyboard focus
falls to instead of returning to the control that opened the dialog, so
a keyboard user loses their place in the page and has to Tab back from the top.
This is WCAG 2.4.3 (Focus Order).
RestoreDialog stores the opening trigger in restoreFocusRef and calls
restoreFocusRef.current?.focus?.() on close, which the code itself documents as
best effort. The reason it cannot succeed is upstream: submit() calls
onRestored() -> refresh() before onClose(), and while the refetch is in flight
FileIntegrationPage.tsx:128 drops the whole section to its
if (!status)loading branch. Every node in the subtree unmounts, including the trigger, so
there is no element left to focus. Holding the ref in the parent does not help
for the same reason.
A correct fix restores focus by identity after the refetch settles, which is a
change to the page's data-loading contract rather than to the dialog. Filed as
a follow-up to #3050, which introduced the modal focus trap; the current
behavior is still an improvement over the previous non-modal ,
where Tab walked out of a destructive confirmation entirely.
Reproduction
from the control that opened the dialog. document.activeElement is .
Version
2.38.0 (dev at 93b7ee8)
Operating system
macOS 15 (not platform specific)
Provider and model
Not applicable, dashboard UI only
Logs or error output
Not applicable
Screenshots and supporting files
Not applicable
Redacted configuration
Not applicable
Checks