From 770e39f563ad7c48ac87da91c1aa9250d28626ad Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:17:13 +0900 Subject: [PATCH] fix(dashboard): handle hidden pop-out transition readiness (#24470) * fix(dashboard): skip hidden pop-out view transitions A column change in a hidden Agent Dashboard pop-out started a View Transition whose ready promise rejects with InvalidStateError. Nothing observed that promise, so each move became an unhandled rejection. Skip the transition while the document is hidden, and consume ready when a transition still starts. Fixes #21323 * fix: preserve visible dashboard transition errors * test(dashboard): preserve unrelated hidden transition errors --------- Co-authored-by: Neil --- .../useDashboardSnapshot.test.tsx | 102 +++++++++++++++++- .../dashboard-popout/useDashboardSnapshot.ts | 19 +++- 2 files changed, 119 insertions(+), 2 deletions(-) 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 }) }