Skip to content

Restore dialog cannot return focus: the page unmounts before it closes #3059

Description

@lidge-jun

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

  1. Open the dashboard Integrations page with at least one undoable journal entry.
  2. Using only the keyboard, Tab to an entry's restore control and press Enter.
  3. Confirm the restore in the dialog with the keyboard.
  4. Press Tab and observe that focus resumes from the top of the document, not
    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

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. lidge-jun commented on Aug 31, 2026

    @lidge-jun
    OwnerAuthor

    리뷰 · 우선순위 62 / 80

    지금 dev(HEAD 93b7ee80a, #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이 작성했습니다

  2. lidge-jun commented on Sep 1, 2026

    @lidge-jun
    OwnerAuthor

    Landed on dev via #3113 at b6e53d8. The repaired test now models the real ordering: the dialog closes while the trigger is connected, history refresh then removes it, and focus remains on the stable region. Closing as fixed.

  3. github-actions commented on Sep 1, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingguiDashboard, tray, settings UI

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions