diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.retry-after-never-landed.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.retry-after-never-landed.test.tsx new file mode 100644 index 00000000000..adf1e89fb27 --- /dev/null +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.retry-after-never-landed.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom + +// The user-recovery gap behind the contained lazy-chunk crashes: after a corrupt +// chunk and a recovery reload that never lands, the boundary's Retry re-renders +// the same React.lazy, whose cached rejection can never re-invoke the factory — +// so a surface whose chunk has become loadable again (assets settled after an +// update) still stays dead until a full app restart. + +import { Suspense, act, type ReactElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { lazyWithRetry, resetLazyChunkReloadRequestsForTest } from '@/lib/lazy-with-retry' +import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary' + +const reportCrashMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +const RELOAD_SETTLE_GRACE_MS = 10_000 + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +type Breadcrumb = { name: string; data: Record } + +function installBreadcrumbSink(): Breadcrumb[] { + const breadcrumbs: Breadcrumb[] = [] + ;(window as unknown as { api: unknown }).api = { + crashReports: { + recordBreadcrumb: (crumb: Breadcrumb) => { + breadcrumbs.push(crumb) + } + } + } + return breadcrumbs +} + +function BoundaryHarness({ children }: { children: ReactNode }): ReactElement { + return ( + + Loading...}>{children} + + ) +} + +describe('RecoverableRenderErrorBoundary Retry after a recovery reload never lands', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + reportCrashMock.mockReset() + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + // Production truth: location.reload() produced zero navigations in the shipped bundles. + vi.spyOn(window.location, 'reload').mockImplementation(() => undefined) + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + delete (window as unknown as { api?: unknown }).api + vi.restoreAllMocks() + vi.useRealTimers() + consoleError.mockRestore() + }) + + it('re-invokes the chunk factory on Retry and recovers the surface once assets settle', async () => { + const breadcrumbs = installBreadcrumbSink() + const HealedSurface = (): ReactElement =>
healed
+ // First fetch reads the mid-update asset set; the retry reads the settled one. + const chunkFactory = vi + .fn<() => Promise<{ default: typeof HealedSurface }>>() + .mockRejectedValueOnce(new SyntaxError("Unexpected token '}'")) + .mockResolvedValue({ default: HealedSurface }) + // No reloadKey: matches the shipped call sites (reloadKey=unknown breadcrumbs). + const LazyHealingChunk = lazyWithRetry(chunkFactory, { retries: 0 }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render( + + + + ) + }) + // Outlive the reload settle grace window, then let React commit the rejection. + await act(async () => { + await vi.advanceTimersByTimeAsync(RELOAD_SETTLE_GRACE_MS + 50) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(container.querySelector('[role="alert"]')).not.toBeNull() + expect(chunkFactory).toHaveBeenCalledTimes(1) + expect(reportCrashMock).not.toHaveBeenCalled() + const reload = breadcrumbs.find((crumb) => crumb.name === 'lazy_chunk_reload') + expect(reload?.data.reloadKey).toBe('unknown') + + const retryButton = container.querySelector('[role="alert"] button') + expect(retryButton).not.toBeNull() + await act(async () => { + retryButton?.click() + await vi.advanceTimersByTimeAsync(0) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + // The whole defect: React.lazy cached the rejection, so Retry never re-invokes + // the factory and the surface stays dead even though the chunk now loads. + expect(chunkFactory).toHaveBeenCalledTimes(2) + expect(container.querySelector('[data-testid="healed-surface"]')).not.toBeNull() + expect(container.querySelector('[role="alert"]')).toBeNull() + expect(reportCrashMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx index e4c03e96aa0..9779b571816 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx @@ -2,7 +2,7 @@ import React from 'react' import { AlertTriangle, RotateCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -import { isLazyChunkLoadError } from '@/lib/lazy-with-retry' +import { advanceLazyChunkRetryEpoch, isLazyChunkLoadError } from '@/lib/lazy-with-retry' import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting' import { translate } from '@/i18n/i18n' @@ -28,10 +28,11 @@ type Props = { type State = { error: Error | null resetKey: Props['resetKey'] + relaunching: boolean } export class RecoverableRenderErrorBoundary extends React.Component { - state: State = { error: null, resetKey: this.props.resetKey } + state: State = { error: null, resetKey: this.props.resetKey, relaunching: false } static getDerivedStateFromProps(props: Props, state: State): Partial | null { if (props.resetKey !== state.resetKey) { @@ -46,6 +47,10 @@ export class RecoverableRenderErrorBoundary extends React.Component { + this.setState({ relaunching: true }) + void window.api.app.relaunch().catch((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.setState({ relaunching: false }) + }) + } + render(): React.ReactNode { if (!this.state.error) { return this.props.children @@ -74,6 +88,11 @@ export class RecoverableRenderErrorBoundary extends React.Component
{this.props.title ?? - translate( - 'auto.components.error.boundaries.RecoverableRenderErrorBoundary.ab855c11f4', - 'This part of Orca hit an error.' - )} + (staleChunk + ? translate( + 'auto.components.error.boundaries.RecoverableRenderErrorBoundary.staleChunkTitle', + 'This part of Orca could not load.' + ) + : translate( + 'auto.components.error.boundaries.RecoverableRenderErrorBoundary.ab855c11f4', + 'This part of Orca hit an error.' + ))}
{this.props.description ?? - translate( - 'auto.components.error.boundaries.RecoverableRenderErrorBoundary.34a189ae0f', - 'The rest of the app is still running. Retry this surface or switch away and come back.' - )} + (staleChunk + ? translate( + 'auto.components.error.boundaries.RecoverableRenderErrorBoundary.staleChunkDescription', + 'Orca may have updated in the background. Retry, or restart Orca to finish applying the update.' + ) + : translate( + 'auto.components.error.boundaries.RecoverableRenderErrorBoundary.34a189ae0f', + 'The rest of the app is still running. Retry this surface or switch away and come back.' + ))}
- +
+ + {canRelaunch ? ( + + ) : null} +
) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f5a4b142808..0ceeae564dc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13767,7 +13767,10 @@ "RecoverableRenderErrorBoundary": { "55001880db": "Retry", "34a189ae0f": "The rest of the app is still running. Retry this surface or switch away and come back.", - "ab855c11f4": "This part of Orca hit an error." + "ab855c11f4": "This part of Orca hit an error.", + "restartOrca": "Restart Orca", + "staleChunkDescription": "Orca may have updated in the background. Retry, or restart Orca to finish applying the update.", + "staleChunkTitle": "This part of Orca could not load." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ec8b4bf5433..6c4c9f4f0d6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12468,7 +12468,10 @@ "RecoverableRenderErrorBoundary": { "55001880db": "Reintentar", "34a189ae0f": "El resto de la app sigue ejecutándose. Reintenta esta vista o cambia a otra y vuelve.", - "ab855c11f4": "Esta parte de Orca encontró un error." + "ab855c11f4": "Esta parte de Orca encontró un error.", + "restartOrca": "Reiniciar Orca", + "staleChunkDescription": "Es posible que Orca se haya actualizado en segundo plano. Reintenta, o reinicia Orca para terminar de aplicar la actualización.", + "staleChunkTitle": "No se pudo cargar esta parte de Orca." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7a3e03aacc3..a7663ebda7c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12468,7 +12468,10 @@ "RecoverableRenderErrorBoundary": { "55001880db": "再試行", "34a189ae0f": "アプリの残りの部分はまだ実行されています。このサーフェスを再試行するか、切り替えて戻ってきてください。", - "ab855c11f4": "Orca のこの部分でエラーが発生しました。" + "ab855c11f4": "Orca のこの部分でエラーが発生しました。", + "restartOrca": "Orca を再起動", + "staleChunkDescription": "バックグラウンドで Orca が更新された可能性があります。再試行するか、Orca を再起動して更新の適用を完了してください。", + "staleChunkTitle": "Orca のこの部分を読み込めませんでした。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3060a5b34c0..a61265a976d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12468,7 +12468,10 @@ "RecoverableRenderErrorBoundary": { "55001880db": "다시 시도", "34a189ae0f": "앱의 나머지 부분은 아직 실행 중입니다. 이 표면을 다시 시도하거나 다른 곳으로 전환했다가 다시 돌아오세요.", - "ab855c11f4": "Orca의 이 부분에 오류가 발생했습니다." + "ab855c11f4": "Orca의 이 부분에 오류가 발생했습니다.", + "restartOrca": "Orca 재시작", + "staleChunkDescription": "백그라운드에서 Orca가 업데이트되었을 수 있습니다. 다시 시도하거나 Orca를 재시작하여 업데이트 적용을 완료하세요.", + "staleChunkTitle": "Orca의 이 부분을 불러오지 못했습니다." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index aac4af7f801..6d512dee67f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12488,7 +12488,10 @@ "RecoverableRenderErrorBoundary": { "55001880db": "重试", "34a189ae0f": "应用程序的其余部分仍在运行。重试此表面或切换出去然后再回来。", - "ab855c11f4": "Orca 的这一部分遇到了错误。" + "ab855c11f4": "Orca 的这一部分遇到了错误。", + "restartOrca": "重启 Orca", + "staleChunkDescription": "Orca 可能已在后台完成更新。请重试,或重启 Orca 以完成更新。", + "staleChunkTitle": "无法加载 Orca 的这一部分。" } } }, diff --git a/src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts b/src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts index fafd6eeda59..3250afa8ece 100644 --- a/src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts +++ b/src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts @@ -129,6 +129,28 @@ describe('loadLazyWithRetry when the recovery reload never lands', () => { expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBeNull() }) + it('caps reload requests at two per document even when every call site defaults to reloadKey unknown', async () => { + const breadcrumbs = installBreadcrumbSink() + + // Three sequential failures without a reloadKey: the guard is keyless, so + // 'unknown' must not defeat the per-document cap. + for (let failure = 0; failure < 3; failure += 1) { + const settled = loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0 + }).catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(RELOAD_SETTLE_GRACE_MS + 1) + expect(isLazyChunkLoadError(await settled)).toBe(true) + } + + const reloads = breadcrumbs.filter((crumb) => crumb.name === 'lazy_chunk_reload') + expect(reloads).toHaveLength(2) + expect(reloads.every((crumb) => crumb.data.reloadKey === 'unknown')).toBe(true) + const vetoed = breadcrumbs.filter((crumb) => crumb.name === 'lazy_chunk_reload_vetoed') + expect(vetoed).toHaveLength(2) + expect(vetoed.every((crumb) => crumb.data.outcome === 'never-landed')).toBe(true) + }) + it('still surfaces ordinary evaluation bugs after a never-landed reload attempt', async () => { const error = new Error('render bug from lazy module evaluation') const settled = loadLazyWithRetry(() => Promise.reject(error), { retries: 0 }).catch( diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index 77650b67ded..12564e0617c 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -1,4 +1,12 @@ -import { lazy, type ComponentType, type LazyExoticComponent } from 'react' +import { + createElement, + lazy, + useState, + type ComponentProps, + type ComponentType, + type LazyExoticComponent, + type ReactElement +} from 'react' import { requestLazyChunkRecoveryReload, @@ -250,9 +258,50 @@ export async function loadLazyWithRetry( throw lastError } +// Epoch advances only when a boundary catches a chunk failure (a committed, +// user-visible fallback). A remount from a LATER epoch is a deliberate reset +// (Retry press or resetKey change); a remount from the SAME epoch is React +// retrying the suspended tree after the load promise settled, and minting there +// would re-fetch a broken chunk in an unbounded loop. +let chunkRetryEpoch = 0 + +export function advanceLazyChunkRetryEpoch(): void { + chunkRetryEpoch += 1 +} + export function lazyWithRetry( factory: LazyFactory, options?: LazyWithRetryOptions -): LazyExoticComponent { - return lazy(() => loadLazyWithRetry(factory, options)) +): ComponentType> { + // -1 = last load did not fail; otherwise the epoch the failure settled in. + let failedAtEpoch = -1 + const mintLazy = (): LazyExoticComponent => + lazy(() => + loadLazyWithRetry(factory, options).then( + (loaded) => { + failedAtEpoch = -1 + return loaded + }, + (error: unknown) => { + failedAtEpoch = chunkRetryEpoch + throw error + } + ) + ) + let current = mintLazy() + + // Why: React.lazy caches a rejection forever, so a boundary Retry that re-renders + // the same Lazy can never recover it. A reset remounts this wrapper; minting a + // fresh Lazy then lets the retry re-invoke the factory (the chunk may load fine + // after an update finishes). Bounded: one mint per caught failure per reset. + return function LazyWithRetry(props: ComponentProps): ReactElement { + const [Component] = useState>(() => { + if (failedAtEpoch !== -1 && failedAtEpoch !== chunkRetryEpoch) { + failedAtEpoch = -1 + current = mintLazy() + } + return current + }) + return createElement(Component, props) + } }