mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
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:
@@ -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>
|
||||
|
||||
+8
-4
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user