diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx index 3376be9102a..728960cf4f8 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -43,15 +43,45 @@ let apply: (next: DashboardSnapshot) => void let applyStatus: (next: AgentStatusIpcPayload) => void let applyClear: (next: AgentStatusClearIpcPayload) => void const requestSnapshot = vi.fn(async () => {}) +let readyRejection: unknown = null +let hideDocumentAfterTransitionStart = false const startViewTransition = vi.fn((cb: () => void) => { cb() + if (hideDocumentAfterTransitionStart) { + hideDocument() + } return { finished: Promise.resolve(), - ready: Promise.resolve(), + ready: readyRejection !== null ? Promise.reject(readyRejection) : Promise.resolve(), updateCallbackDone: Promise.resolve() } }) +const originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState') +const UNHANDLED_REJECTION_SETTLE_MS = 20 + +async function collectUnhandledRejections(run: () => void): Promise { + const reasons: unknown[] = [] + const onUnhandledRejection = (reason: unknown): void => { + reasons.push(reason) + } + + process.on('unhandledRejection', onUnhandledRejection) + try { + run() + await new Promise((resolve) => setTimeout(resolve, UNHANDLED_REJECTION_SETTLE_MS)) + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + + return reasons +} + +/** The pop-out is hidden whenever the main window covers it or it is minimized. */ +function hideDocument(): void { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }) +} + /** A Radix dialog marks its open content with role + data-state; mimic that so * the hook's top-layer-conflict guard sees an "open terminal". */ function openTerminalDialog(): HTMLElement { @@ -89,6 +119,13 @@ describe('useDashboardSnapshot', () => { }) afterEach(() => { document.body.innerHTML = '' + readyRejection = null + hideDocumentAfterTransitionStart = false + if (originalVisibilityState) { + Object.defineProperty(document, 'visibilityState', originalVisibilityState) + } else { + Reflect.deleteProperty(document, 'visibilityState') + } vi.clearAllMocks() }) @@ -115,6 +152,69 @@ describe('useDashboardSnapshot', () => { expect(result.current.cards[0].bucket).toBe('working') }) + it('skips the view transition — but still applies the update — while the window is hidden', () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ bucket: 'idle' })]))) + startViewTransition.mockClear() + hideDocument() + + act(() => apply(snapshot([card({ bucket: 'working' })]))) + expect(startViewTransition).not.toHaveBeenCalled() + expect(result.current.cards[0].bucket).toBe('working') + }) + + it('swallows the ready rejection when the window is occluded mid-transition', async () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ bucket: 'idle' })]))) + readyRejection = new DOMException( + 'Transition was aborted because of invalid state', + 'InvalidStateError' + ) + hideDocumentAfterTransitionStart = true + + const reasons = await collectUnhandledRejections(() => { + act(() => apply(snapshot([card({ bucket: 'working' })]))) + }) + + expect(startViewTransition).toHaveBeenCalled() + expect(reasons).toEqual([]) + expect(result.current.cards[0].bucket).toBe('working') + }) + + it('reports an unrelated ready error when the window becomes hidden', async () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ bucket: 'idle' })]))) + const readyError = new TypeError('Transition callback failed') + readyRejection = readyError + hideDocumentAfterTransitionStart = true + + const reasons = await collectUnhandledRejections(() => { + act(() => apply(snapshot([card({ bucket: 'working' })]))) + }) + + expect(startViewTransition).toHaveBeenCalled() + expect(reasons).toEqual([readyError]) + expect(result.current.cards[0].bucket).toBe('working') + }) + + it('reports an InvalidStateError when the visible transition fails', async () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ bucket: 'idle' })]))) + const readyError = new DOMException( + 'Duplicate transition names are not allowed', + 'InvalidStateError' + ) + readyRejection = readyError + + const reasons = await collectUnhandledRejections(() => { + act(() => apply(snapshot([card({ bucket: 'working' })]))) + }) + + expect(startViewTransition).toHaveBeenCalled() + expect(reasons).toEqual([readyError]) + expect(result.current.cards[0].bucket).toBe('working') + }) + // Why: the bridge stops re-sending repoIconsByRepoId on throttled republishes // once it is unchanged, so the icons would disappear from the board on the // very next tick if this window did not hold on to the last map it was given. diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts index 28e43d54e5b..86570e49a94 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts @@ -23,6 +23,12 @@ function prefersReducedMotion(): boolean { return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true } +/** Why: a View Transition started on a hidden document rejects `ready` with + * InvalidStateError. The pop-out is hidden whenever the main window covers it. */ +function documentIsHidden(): boolean { + return document.visibilityState === 'hidden' +} + /** Why: View Transition snapshots render in the browser top layer, which paints * above any z-index — so a card morphing columns would flicker OVER the open * terminal dialog (a z-50 Radix portal). Skip the transition while it's open; @@ -114,6 +120,7 @@ export function useDashboardSnapshot(): DashboardSnapshot { const startViewTransition = document.startViewTransition?.bind(document) if ( !layoutChanged || + documentIsHidden() || prefersReducedMotion() || terminalDialogIsOpen() || !startViewTransition @@ -123,8 +130,18 @@ export function useDashboardSnapshot(): DashboardSnapshot { } // flushSync so the DOM reflects `next` synchronously inside the transition // callback — the browser captures the "after" state from it. - startViewTransition(() => { + // Consume only the abort caused by occlusion; other ready failures remain visible. + void startViewTransition(() => { flushSync(() => setSnapshot(next)) + }).ready.catch((error: unknown) => { + if ( + documentIsHidden() && + error instanceof DOMException && + error.name === 'InvalidStateError' + ) { + return + } + throw error }) }