@@ -33,6 +33,7 @@ export default function RestoreDialog({
3333 const dialogRef = useRef < HTMLDialogElement > ( null ) ;
3434 const restoreFocusRef = useRef < HTMLElement | null > ( null ) ;
3535 const restoreFallbackRef = useRef < HTMLElement | null > ( null ) ;
36+ const restoredRef = useRef ( false ) ;
3637 const [ drift , setDrift ] = useState ( false ) ;
3738 const [ pending , setPending ] = useState ( false ) ;
3839 const [ failure , setFailure ] = useState < string | null > ( null ) ;
@@ -44,29 +45,38 @@ export default function RestoreDialog({
4445 // focus-restore uses the same tagName check.
4546 const active = document . activeElement ;
4647 restoreFocusRef . current = active ?. tagName === "BUTTON" ? active as HTMLElement : null ;
47- // The trigger may not survive the restore. A row whose snapshot is consumed
48- // re-renders as an `expired` badge with no button (RollbackHistory.tsx:44-46),
49- // so the element captured above is detached by the time the cleanup runs and
50- // focus lands on <body> — a keyboard user dropped at the top of the document
51- // with nothing announced (#3059). Remember the enclosing region too, so there
52- // is somewhere to return to that cannot disappear.
48+ // The trigger may not survive the asynchronous history refresh. A row whose
49+ // snapshot is consumed re-renders as an `expired` badge with no button
50+ // (RollbackHistory.tsx:44-46), so remember the enclosing region too: it
51+ // remains a focus target after the trigger disappears (#3059).
5352 restoreFallbackRef . current =
5453 ( active ?. closest ?.( "section, [role='region'], main" ) as HTMLElement | null ) ?? null ;
5554 if ( dialog && ! dialog . open ) dialog . showModal ( ) ;
5655 return ( ) => {
5756 if ( dialog ?. open ) dialog . close ( ) ;
58- // Prefer the trigger; fall back to its region when the restore removed it.
59- // `isConnected` is the check that matters: a detached node accepts .focus()
60- // silently and focus stays on <body>, which is the reported symptom.
57+ const fallback = restoreFallbackRef . current ;
58+ // A successful restore starts the history refresh before it closes the
59+ // dialog. The trigger is therefore still connected during this cleanup,
60+ // but can disappear when the asynchronous refresh consumes its snapshot.
61+ // Put successful restores on the stable region now; cancellation keeps
62+ // the usual trigger restoration below.
63+ if ( restoredRef . current && fallback ?. isConnected ) {
64+ // A region is not focusable by default; -1 makes it programmatically
65+ // focusable without adding it to the Tab order.
66+ if ( ! fallback . hasAttribute ( "tabindex" ) ) fallback . setAttribute ( "tabindex" , "-1" ) ;
67+ fallback . focus ?.( ) ;
68+ return ;
69+ }
70+ // Prefer the trigger when the user cancelled; fall back to its region
71+ // only if it was already removed. `isConnected` is the check that
72+ // matters: a detached node accepts .focus() silently and focus stays on
73+ // <body>, which is the reported symptom.
6174 const trigger = restoreFocusRef . current ;
6275 if ( trigger ?. isConnected ) {
6376 trigger . focus ?.( ) ;
6477 return ;
6578 }
66- const fallback = restoreFallbackRef . current ;
6779 if ( ! fallback ?. isConnected ) return ;
68- // A region is not focusable by default; -1 makes it programmatically
69- // focusable without adding it to the Tab order.
7080 if ( ! fallback . hasAttribute ( "tabindex" ) ) fallback . setAttribute ( "tabindex" , "-1" ) ;
7181 fallback . focus ?.( ) ;
7282 } ;
@@ -83,6 +93,7 @@ export default function RestoreDialog({
8393 setFailure ( null ) ;
8494 try {
8595 await restoreIntegration ( apiBase , row . opId , drift ) ;
96+ restoredRef . current = true ;
8697 onRestored ( ) ;
8798 onClose ( ) ;
8899 } catch ( error ) {
0 commit comments