diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 305357346..0ef5bab24 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -1,3 +1,5 @@ +/* oxlint-disable max-lines -- Why: keeping these mocked TabBar wiring cases + * together avoids duplicating the lightweight renderer harness. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const appStoreSnapshot: { @@ -155,6 +157,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ type ReactElementLike = { type: unknown props: Record + ref?: unknown } function findChildrenByType(node: unknown, typeName: string): ReactElementLike[] { @@ -241,6 +244,7 @@ describe('TabBar context menu wiring', () => { callback(0) return 1 }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) vi.stubGlobal('window', { setTimeout, clearTimeout, @@ -324,4 +328,35 @@ describe('TabBar context menu wiring', () => { expect(focusTerminalTabSurface).toHaveBeenCalledWith('new-terminal') expect(focusTerminalTabSurface).not.toHaveBeenCalledWith('old-terminal') }) + + it('cancels delayed menu focus when the tab bar root unmounts', async () => { + vi.useFakeTimers() + Object.assign(window, { setTimeout, clearTimeout }) + const { focusTerminalTabSurface } = await import('@/lib/focus-terminal-tab-surface') + const element = await renderTabBar({ + tabs: [TERMINAL_TAB], + activeTabId: 'old-terminal', + activeTabType: 'terminal', + onNewTerminalTab: () => { + window.setTimeout(() => { + appStoreSnapshot.activeTabId = 'new-terminal' + appStoreSnapshot.activeTabType = 'terminal' + }, 100) + } + }) + + const newTerminalItem = findChildrenByType(element, 'DropdownMenuItem')[0] + const menuContent = findChildrenByType(element, 'DropdownMenuContent')[0] + ;(newTerminalItem.props.onSelect as () => void)() + ;(menuContent.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({ + preventDefault: vi.fn() + }) + + const root = findChildrenByType(element, 'div')[0] + const rootRef = (root.props.ref ?? root.ref) as (node: HTMLDivElement | null) => void + rootRef(null) + + await vi.advanceTimersByTimeAsync(5000) + expect(focusTerminalTabSurface).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index c55cfdb59..35f29286e 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -338,13 +338,24 @@ function TabBarInner({ }) } } - useEffect( - () => () => { + + const clearPendingNewTabMenuFocusOnUnmountRef = useRef< + ((node: HTMLDivElement | null) => void) | null + >(null) + if (clearPendingNewTabMenuFocusOnUnmountRef.current === null) { + clearPendingNewTabMenuFocusOnUnmountRef.current = (node: HTMLDivElement | null): void => { + if (node !== null) { + return + } + // Why: the delayed focus handoff is scoped to this tab bar instance. + // A root ref cleanup cancels it at the DOM owner boundary without an + // otherwise cleanup-only React Effect. clearPendingNewTabMenuFocusAnimation() clearPendingNewTabMenuFocusRetry() - }, - [] - ) + } + } + const clearPendingNewTabMenuFocusOnUnmount = clearPendingNewTabMenuFocusOnUnmountRef.current + useEffect(() => { if (!newTabMenuOpen) { return @@ -517,6 +528,7 @@ function TabBarInner({ return (