fix(renderer): extend the stale-chunk Restart Orca recovery row to the rich-markdown editor boundary

Extract the reviewer-hardened stale-chunk action row (Restart Orca primary,
Retry secondary, sync double-click guard, resolve-anchored settle grace,
stalled notice, unmount teardown) out of RecoverableRenderErrorBoundary into
the shared StaleChunkRecoveryActions component, and render it from
RichMarkdownErrorBoundary's fallback for LazyChunkLoadError on
relaunch-capable hosts — the editor surface where the 9-hour field incident
actually happened previously offered only a Retry that re-fetches dead assets
forever.
This commit is contained in:
Brennan Benson
2026-08-16 18:32:02 -07:00
parent eed2c14899
commit 13e14eb80d
6 changed files with 344 additions and 109 deletions
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
// The editor boundary is where the 9-hour field incident happened: an app
// update swaps assets under a live renderer, Retry re-fetches dead chunks
// forever, and without a "Restart Orca" affordance the user has no recovery.
// These tests pin that the shared stale-chunk recovery row renders here too —
// and only for stale chunks on relaunch-capable hosts.
import { act, type ReactElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LazyChunkLoadError } from '@/lib/lazy-with-retry'
import { RELAUNCH_SETTLE_GRACE_MS } from '@/components/error-boundaries/StaleChunkRecoveryActions'
import {
clearLazyChunkBreadcrumbDedupeForTest,
RichMarkdownErrorBoundary
} from './RichMarkdownErrorBoundary'
const reportCrashMock = vi.hoisted(() => vi.fn())
const recordBreadcrumbMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/react-error-boundary-reporting', () => ({
reportReactErrorBoundaryCrash: reportCrashMock
}))
vi.mock('@/lib/crash-breadcrumb-recorder', () => ({
recordRendererCrashBreadcrumb: recordBreadcrumbMock
}))
globalThis.IS_REACT_ACT_ENVIRONMENT = true
function ThrowingChild({ error }: { error: Error }): ReactElement {
throw error
}
function findButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null {
return (
Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) =>
button.textContent?.includes(text)
) ?? null
)
}
describe('RichMarkdownErrorBoundary Restart Orca row', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
let consoleError: ReturnType<typeof vi.spyOn>
beforeEach(() => {
reportCrashMock.mockReset()
recordBreadcrumbMock.mockReset()
clearLazyChunkBreadcrumbDedupeForTest()
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
delete (window as unknown as { api?: unknown }).api
consoleError.mockRestore()
vi.useRealTimers()
})
function renderBoundaryWith(error: Error, fileId = 'file-1'): void {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
<RichMarkdownErrorBoundary fileId={fileId}>
<ThrowingChild error={error} />
</RichMarkdownErrorBoundary>
)
})
}
it('renders the shared recovery row for a stale chunk when the host can relaunch', () => {
const relaunch = vi.fn<() => Promise<void>>().mockReturnValue(new Promise(() => undefined))
;(window as unknown as { api: unknown }).api = { app: { relaunch } }
renderBoundaryWith(new LazyChunkLoadError(new SyntaxError("Unexpected token '}'")))
expect(container!.textContent).toContain('This part of Orca could not load.')
const restart = findButtonByText(container!, 'Restart Orca')
expect(restart).not.toBeNull()
// Restart leads as primary; Retry is the lower-emphasis sibling.
const buttons = Array.from(container!.querySelectorAll<HTMLButtonElement>('button'))
expect(buttons[0]).toBe(restart)
expect(restart?.dataset.variant).toBe('default')
expect(findButtonByText(container!, 'Retry')?.dataset.variant).toBe('secondary')
act(() => restart?.click())
expect(relaunch).toHaveBeenCalledTimes(1)
expect(restart?.disabled).toBe(true)
expect(findButtonByText(container!, 'Retry')?.disabled).toBe(true)
})
it('keeps the plain editor fallback for ordinary errors even with the bridge', () => {
;(window as unknown as { api: unknown }).api = { app: { relaunch: vi.fn() } }
renderBoundaryWith(new Error('ordinary render failure'))
expect(findButtonByText(container!, 'Restart Orca')).toBeNull()
expect(container!.textContent).toContain('rich markdown editor hit an unexpected error')
expect(findButtonByText(container!, 'Retry')).not.toBeNull()
})
it('keeps the plain editor fallback for a stale chunk on hosts without the bridge', () => {
delete (window as unknown as { api?: unknown }).api
renderBoundaryWith(new LazyChunkLoadError(new SyntaxError("Unexpected token '}'")))
expect(findButtonByText(container!, 'Restart Orca')).toBeNull()
expect(container!.textContent).toContain('rich markdown editor hit an unexpected error')
expect(findButtonByText(container!, 'Retry')).not.toBeNull()
})
it('does not leak the stalled-restart notice into a later error after a fileId re-key', 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 = (fileId: string, error: Error): void => {
act(() => {
root?.render(
<RichMarkdownErrorBoundary fileId={fileId}>
<ThrowingChild error={error} />
</RichMarkdownErrorBoundary>
)
})
}
renderWith('file-a', new LazyChunkLoadError(new SyntaxError("Unexpected token '}'")))
act(() => findButtonByText(container!, 'Restart Orca')?.click())
await act(async () => {
await vi.advanceTimersByTimeAsync(RELAUNCH_SETTLE_GRACE_MS + 1)
})
expect(container!.textContent).toContain("Restarting didn't complete.")
// The re-key abandons that fallback; a fresh stale error must start clean.
renderWith('file-b', new LazyChunkLoadError(new SyntaxError("Unexpected token ']'")))
expect(findButtonByText(container!, 'Restart Orca')).not.toBeNull()
expect(container!.textContent).not.toContain("Restarting didn't complete.")
})
})
@@ -2,6 +2,8 @@ import React from 'react'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
import { advanceLazyChunkRetryEpoch, isLazyChunkLoadError } from '@/lib/lazy-with-retry'
import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting'
import { isAppRelaunchCapable } from '@/components/error-boundaries/app-relaunch-capability'
import { StaleChunkRecoveryActions } from '@/components/error-boundaries/StaleChunkRecoveryActions'
import { translate } from '@/i18n/i18n'
type Props = {
@@ -83,6 +85,34 @@ export class RichMarkdownErrorBoundary extends React.Component<Props, State> {
render(): React.ReactNode {
if (this.state.error) {
// Stale chunks after a background update: Retry alone re-fetches dead
// assets forever, so lead with the main-driven restart (same row as
// RecoverableRenderErrorBoundary) whenever the host can relaunch.
const canRelaunch = isLazyChunkLoadError(this.state.error) && isAppRelaunchCapable()
if (canRelaunch) {
return (
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<div>
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.staleChunkTitle',
'This part of Orca could not load.'
)}
</div>
<div className="max-w-md text-xs opacity-70">
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.staleChunkDescription',
'Orca may have updated in the background. Restart Orca to finish applying the update, or retry this part.'
)}
</div>
{/* Catching an error rebuilds this subtree, so the row remounts fresh
per error — no stalled-notice leak (pinned by the leak test). */}
<StaleChunkRecoveryActions
boundaryId="editor.rich-markdown"
onRetry={this.handleReset}
/>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<div>
@@ -13,10 +13,8 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LazyChunkLoadError } from '@/lib/lazy-with-retry'
import {
RecoverableRenderErrorBoundary,
RELAUNCH_SETTLE_GRACE_MS
} from './RecoverableRenderErrorBoundary'
import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary'
import { RELAUNCH_SETTLE_GRACE_MS } from './StaleChunkRecoveryActions'
const reportCrashMock = vi.hoisted(() => vi.fn())
@@ -207,6 +205,12 @@ describe('RecoverableRenderErrorBoundary Restart Orca button', () => {
expect(container!.querySelector('[role="alert"]')).not.toBeNull()
expect(findRestartButton(container!)).toBeNull()
expect(container!.textContent).not.toContain("Restarting didn't complete.")
// A later stale error renders the row again — remounted fresh, not with the
// doc-a row's stalled/disabled state (catching an error rebuilds the subtree).
renderWith('doc-c', new LazyChunkLoadError(new SyntaxError("Unexpected token ']'")))
expect(findRestartButton(container!)?.disabled).toBe(false)
expect(container!.textContent).not.toContain("Restarting didn't complete.")
})
it('drops the settle timer when the boundary unmounts around the relaunch', async () => {
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { advanceLazyChunkRetryEpoch, isLazyChunkLoadError } from '@/lib/lazy-with-retry'
import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting'
import { isAppRelaunchCapable } from '@/components/error-boundaries/app-relaunch-capability'
import { StaleChunkRecoveryActions } from '@/components/error-boundaries/StaleChunkRecoveryActions'
import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting'
import { translate } from '@/i18n/i18n'
@@ -28,35 +30,17 @@ type Props = {
type State = {
error: Error | null
resetKey: Props['resetKey']
relaunching: boolean
relaunchStalled: boolean
}
// How long a clicked relaunch may leave this document alive before the fallback
// takes the buttons back. An Electron relaunch tears the window down ~150ms after
// the IPC resolves; a paired-web relaunch is an in-place reload that a broken
// document can swallow entirely, which would otherwise pin the button disabled.
export const RELAUNCH_SETTLE_GRACE_MS = 5_000
export class RecoverableRenderErrorBoundary extends React.Component<Props, State> {
state: State = {
error: null,
resetKey: this.props.resetKey,
relaunching: false,
relaunchStalled: false
resetKey: this.props.resetKey
}
// Sync double-click guard: the second click can reach the handler before the
// disabled re-render commits, and a doubled app.relaunch() spawns two instances.
private relaunchRequested = false
private relaunchSettleTimer: ReturnType<typeof setTimeout> | null = null
private unmounted = false
static getDerivedStateFromProps(props: Props, state: State): Partial<State> | null {
if (props.resetKey !== state.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 { error: null, resetKey: props.resetKey }
}
return null
}
@@ -86,55 +70,8 @@ export class RecoverableRenderErrorBoundary extends React.Component<Props, State
})
}
componentWillUnmount(): void {
this.unmounted = true
this.clearRelaunchSettleTimer()
}
handleReset = (): void => {
this.setState({ error: null, relaunchStalled: false })
}
handleRelaunchApp = (): void => {
if (this.relaunchRequested) {
return
}
this.relaunchRequested = true
this.setState({ relaunching: true, relaunchStalled: false })
void window.api.app.relaunch().then(
() => {
// Why: a resolved relaunch that leaves this document alive went nowhere
// (swallowed in-place reload, teardown that never came); give the
// buttons back with a notice instead of leaving a dead disabled control.
// The grace must NOT start while the invoke is still pending — a slow
// pre-relaunch checkpoint is normal, and re-arming the button mid-invoke
// could double app.relaunch() into two replacement instances.
if (!this.unmounted) {
this.relaunchSettleTimer = setTimeout(
() => this.markRelaunchStalled(),
RELAUNCH_SETTLE_GRACE_MS
)
}
},
(error: unknown) => {
// Why: a refused pre-relaunch checkpoint keeps the app open; re-enable the button.
console.error(`[${this.props.boundaryId}] app relaunch failed`, error)
this.markRelaunchStalled()
}
)
}
private clearRelaunchSettleTimer(): void {
if (this.relaunchSettleTimer !== null) {
clearTimeout(this.relaunchSettleTimer)
this.relaunchSettleTimer = null
}
}
private markRelaunchStalled(): void {
this.clearRelaunchSettleTimer()
this.relaunchRequested = false
this.setState({ relaunching: false, relaunchStalled: true })
this.setState({ error: null })
}
render(): React.ReactNode {
@@ -149,7 +86,7 @@ export class RecoverableRenderErrorBoundary extends React.Component<Props, State
// A contained chunk failure usually means the app updated under this renderer;
// a main-driven relaunch is the recovery that a swallowed in-place reload isn't.
const staleChunk = isLazyChunkLoadError(this.state.error)
const canRelaunch = staleChunk && typeof window.api?.app?.relaunch === 'function'
const canRelaunch = staleChunk && isAppRelaunchCapable()
return (
<div
@@ -189,46 +126,25 @@ export class RecoverableRenderErrorBoundary extends React.Component<Props, State
))}
</div>
</div>
<div className="flex items-center gap-2">
{canRelaunch ? (
// A broken document is not the place to recover itself: the main-driven
// relaunch leads, and the in-place Retry stays as the lighter sibling
// for the case where assets have already settled.
<Button
type="button"
variant="default"
size="sm"
disabled={this.state.relaunching}
onClick={this.handleRelaunchApp}
>
{canRelaunch ? (
// No state can leak from an earlier fallback: catching an error
// rebuilds the boundary's subtree, so the row remounts fresh per
// error (pinned by the stalled-notice leak tests).
<StaleChunkRecoveryActions
boundaryId={this.props.boundaryId}
onRetry={this.handleReset}
/>
) : (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={this.handleReset}>
<RotateCw className="size-3.5" />
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.restartOrca',
'Restart Orca'
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.55001880db',
'Retry'
)}
</Button>
) : null}
<Button
type="button"
variant={canRelaunch ? 'secondary' : 'outline'}
size="sm"
disabled={this.state.relaunching}
onClick={this.handleReset}
>
<RotateCw className="size-3.5" />
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.55001880db',
'Retry'
)}
</Button>
</div>
{this.state.relaunchStalled ? (
<div className="max-w-md text-xs">
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.restartStalled',
"Restarting didn't complete. Try again, or retry this part of Orca."
)}
</div>
) : null}
)}
</div>
)
}
@@ -0,0 +1,126 @@
import { useEffect, useRef, useState, type ReactElement } from 'react'
import { RotateCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
// How long a clicked relaunch may leave this document alive before the fallback
// takes the buttons back. An Electron relaunch tears the window down ~150ms after
// the IPC resolves; a paired-web relaunch is an in-place reload that a broken
// document can swallow entirely, which would otherwise pin the button disabled.
export const RELAUNCH_SETTLE_GRACE_MS = 5_000
type Props = {
/** Names the owning boundary on relaunch-failure logs. */
boundaryId: string
onRetry: () => void
}
/**
* The stale-chunk recovery row shared by every lazy-chunk error boundary: a
* broken document is not the place to recover itself, so the main-driven
* "Restart Orca" leads and the in-place Retry stays as the lighter sibling for
* the case where assets have already settled.
*
* Render only from an error boundary's fallback: catching an error rebuilds the
* boundary's subtree, which is what keeps a stalled-restart notice from leaking
* onto a later error's fallback (pinned by the boundaries' leak tests).
*/
export function StaleChunkRecoveryActions({ boundaryId, onRetry }: Props): ReactElement {
const [relaunching, setRelaunching] = useState(false)
const [relaunchStalled, setRelaunchStalled] = useState(false)
// Sync double-click guard: the second click can reach the handler before the
// disabled re-render commits, and a doubled app.relaunch() spawns two instances.
const relaunchRequestedRef = useRef(false)
const relaunchSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const unmountedRef = useRef(false)
useEffect(() => {
// Re-arm after a StrictMode probe cleanup; real teardown must both stop a
// pending invoke from scheduling and drop an already-armed settle timer.
unmountedRef.current = false
return () => {
unmountedRef.current = true
if (relaunchSettleTimerRef.current !== null) {
clearTimeout(relaunchSettleTimerRef.current)
relaunchSettleTimerRef.current = null
}
}
}, [])
const markRelaunchStalled = (): void => {
if (relaunchSettleTimerRef.current !== null) {
clearTimeout(relaunchSettleTimerRef.current)
relaunchSettleTimerRef.current = null
}
relaunchRequestedRef.current = false
setRelaunching(false)
setRelaunchStalled(true)
}
const handleRelaunchApp = (): void => {
if (relaunchRequestedRef.current) {
return
}
relaunchRequestedRef.current = true
setRelaunching(true)
setRelaunchStalled(false)
void window.api.app.relaunch().then(
() => {
// Why: a resolved relaunch that leaves this document alive went nowhere
// (swallowed in-place reload, teardown that never came); give the
// buttons back with a notice instead of leaving a dead disabled control.
// The grace must NOT start while the invoke is still pending — a slow
// pre-relaunch checkpoint is normal, and re-arming the button mid-invoke
// could double app.relaunch() into two replacement instances.
if (!unmountedRef.current) {
relaunchSettleTimerRef.current = setTimeout(markRelaunchStalled, RELAUNCH_SETTLE_GRACE_MS)
}
},
(error: unknown) => {
// Why: a refused pre-relaunch checkpoint keeps the app open; re-enable the button.
console.error(`[${boundaryId}] app relaunch failed`, error)
markRelaunchStalled()
}
)
}
return (
<>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
type="button"
variant="default"
size="sm"
disabled={relaunching}
onClick={handleRelaunchApp}
>
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.restartOrca',
'Restart Orca'
)}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={relaunching}
onClick={onRetry}
>
<RotateCw className="size-3.5" />
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.55001880db',
'Retry'
)}
</Button>
</div>
{relaunchStalled ? (
<div className="max-w-md text-xs">
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.restartStalled',
"Restarting didn't complete. Try again, or retry this part of Orca."
)}
</div>
) : null}
</>
)
}
@@ -0,0 +1,5 @@
// Degraded/partial preload hosts without the app bridge simply do not get a
// "Restart Orca" affordance; recovery falls back to plain Retry.
export function isAppRelaunchCapable(): boolean {
return typeof window.api?.app?.relaunch === 'function'
}