fix(renderer): clear the stalled-restart notice on a resetKey reset and pin the boundary's relaunch-timer teardown

A resetKey reset cleared the error but kept relaunchStalled, so a later
unrelated error's fallback showed "Restarting didn't complete. Try again"
with no Restart button to try. Also pins the settle-timer unmount teardown
(clear on unmount, no scheduling into a torn-down instance), which no test
covered.
This commit is contained in:
Brennan Benson
2026-08-16 18:32:02 -07:00
parent 6c4a66b595
commit d79669b6c9
2 changed files with 75 additions and 1 deletions
@@ -172,6 +172,78 @@ describe('RecoverableRenderErrorBoundary Restart Orca button', () => {
expect(container!.textContent).not.toContain("Restarting didn't complete.")
})
it('does not leak the stalled-restart notice into a later error after a resetKey reset', async () => {
vi.useFakeTimers()
const relaunch = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
;(window as unknown as { api: unknown }).api = { app: { relaunch } }
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
const renderWith = (resetKey: string, error: Error): void => {
act(() => {
root?.render(
<RecoverableRenderErrorBoundary
boundaryId="right-sidebar"
surface="right-sidebar"
resetKey={resetKey}
>
<ThrowingChild error={error} />
</RecoverableRenderErrorBoundary>
)
})
}
renderWith('doc-a', new LazyChunkLoadError(new SyntaxError("Unexpected token '}'")))
act(() => findRestartButton(container!)?.click())
await act(async () => {
await vi.advanceTimersByTimeAsync(RELAUNCH_SETTLE_GRACE_MS + 1)
})
expect(container!.textContent).toContain("Restarting didn't complete.")
// A reset abandons that fallback; the next, unrelated error must not carry
// a stalled-restart notice whose "Try again" has no Restart button.
renderWith('doc-b', new Error('ordinary render failure'))
expect(container!.querySelector('[role="alert"]')).not.toBeNull()
expect(findRestartButton(container!)).toBeNull()
expect(container!.textContent).not.toContain("Restarting didn't complete.")
})
it('drops the settle timer when the boundary unmounts around the relaunch', async () => {
vi.useFakeTimers()
let resolveRelaunch: () => void = () => undefined
const relaunch = vi.fn<() => Promise<void>>().mockReturnValue(
new Promise((resolve) => {
resolveRelaunch = resolve
})
)
;(window as unknown as { api: unknown }).api = { app: { relaunch } }
// Unmount while the invoke is pending: the resolve must not schedule a timer
// into a torn-down instance.
renderBoundaryWith(new LazyChunkLoadError(new SyntaxError("Unexpected token '}'")))
act(() => findRestartButton(container!)?.click())
act(() => root?.unmount())
root = null
await act(async () => {
resolveRelaunch()
await Promise.resolve()
})
expect(vi.getTimerCount()).toBe(0)
// Unmount after the invoke resolved: the already-armed timer must be cleared.
container?.remove()
renderBoundaryWith(new LazyChunkLoadError(new SyntaxError("Unexpected token '}'")))
act(() => findRestartButton(container!)?.click())
await act(async () => {
await Promise.resolve()
})
expect(vi.getTimerCount()).toBe(1)
act(() => root?.unmount())
root = null
expect(vi.getTimerCount()).toBe(0)
})
it('re-enables the button when the pre-relaunch checkpoint refuses', async () => {
let rejectRelaunch: (error: Error) => void = () => undefined
const relaunch = vi.fn<() => Promise<void>>().mockReturnValue(
@@ -54,7 +54,9 @@ export class RecoverableRenderErrorBoundary extends React.Component<Props, State
static getDerivedStateFromProps(props: Props, state: State): Partial<State> | null {
if (props.resetKey !== state.resetKey) {
return { error: null, resetKey: props.resetKey }
// A reset abandons the old fallback, so a later error must not inherit its
// stalled-restart notice ("Try again" would name a button that isn't there).
return { error: null, resetKey: props.resetKey, relaunchStalled: false }
}
return null
}