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 <neil@stably.ai>
This commit is contained in:
Wooseong Kim
2026-10-24 02:17:13 -07:00
committed by GitHub
co-authored by Neil
parent 5ce9d58b2d
commit 770e39f563
2 changed files with 119 additions and 2 deletions
@@ -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<unknown[]> {
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.
@@ -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
})
}