fix(renderer): recover from failed lazy chunk imports instead of crashing (#5803)

Lazy chunk import() rejections (stale/corrupt chunk -> native SyntaxError, e.g. "Unexpected token ']'", re-thrown by React.lazy) now self-heal via lazyWithRetry: retry with backoff, one sessionStorage-guarded reload, then fall through to the error boundary (loop-safe). Adopted at every renderer lazy site. Fixes the recurring terminal.workbench / right-sidebar / page.mobile react-error-boundary crash.
This commit is contained in:
Neil
2026-06-19 12:05:16 -07:00
committed by GitHub
parent ae77156ab2
commit fb2f04aa4e
17 changed files with 340 additions and 30 deletions
+1 -1
View File
@@ -1,6 +1,5 @@
/* eslint-disable max-lines */
import {
lazy,
Suspense,
useCallback,
useEffect,
@@ -10,6 +9,7 @@ import {
useState,
type SetStateAction
} from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import {
ArrowLeft,
+1 -1
View File
@@ -161,7 +161,7 @@ describe('renderer startup runtime routing', () => {
expect(appSource).toContain('boundaryId="modal.confirm-add-project-from-folder"')
expect(appSource).toContain('boundaryId="modal.project-added"')
expect(appSource).toContain('setTimeout(() =>')
expect(sidebarSource).toContain("React.lazy(() => import('./WorktreeMetaDialog'))")
expect(sidebarSource).toContain("lazyWithRetry(() => import('./WorktreeMetaDialog'))")
expect(sidebarSource).not.toContain("from './AddRepoDialog'")
expect(sidebarSource).not.toContain("React.lazy(() => import('./AddRepoDialog'))")
expect(sidebarSource).not.toContain("React.lazy(() => import('./NonGitFolderDialog'))")
@@ -1,7 +1,6 @@
/* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */
import React, {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
@@ -10,6 +9,7 @@ import React, {
useState,
useSyncExternalStore
} from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useShallow } from 'zustand/react/shallow'
import type { editor as monacoEditor } from 'monaco-editor'
@@ -1,7 +1,6 @@
/* eslint-disable max-lines -- Why: duplicated from GitHubItemDialog so the dedicated PR full-page surface can evolve its Primer-styled header without destabilizing the issue dialog; planned to refactor shared parts out later. */
import React, {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
@@ -10,6 +9,7 @@ import React, {
useState,
useSyncExternalStore
} from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useShallow } from 'zustand/react/shallow'
import type { editor as monacoEditor } from 'monaco-editor'
+2 -1
View File
@@ -1,6 +1,7 @@
/* eslint-disable max-lines */
import React, { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react'
import React, { useEffect, useCallback, useMemo, useRef, useState, Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { createPortal } from 'react-dom'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
@@ -1,4 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { useMountedRef } from '@/hooks/useMountedRef'
import {
REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT,
@@ -1,4 +1,5 @@
import React, { lazy } from 'react'
import React from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import type { OpenFile } from '@/store/slices/editor'
import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types'
import { ConflictBanner } from './ConflictComponents'
@@ -1,4 +1,5 @@
import { lazy, type RefObject } from 'react'
import { type RefObject } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
import { cn } from '@/lib/utils'
@@ -4,7 +4,8 @@ now Changes view mode). Keeping the mode-selection branches colocated is easier
to reason about than scattering the switch across per-mode wrappers. Individual
renderers (MonacoEditor, DiffViewer, ChangesModeView, MarkdownPreview, etc.)
already live in their own modules. */
import React, { lazy } from 'react'
import React from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
@@ -2,16 +2,8 @@
* resizing, orchestration setup, and mixed terminal/browser/editor tab
* handling in one surface so the floating worktree does not drift from the
* main tab model while still keeping the DOM-mounted panes local. */
import {
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { FileText, Globe, Minus, TerminalSquare } from 'lucide-react'
import { toast } from 'sonner'
import BrowserPane from '@/components/browser-pane/BrowserPane'
@@ -1,4 +1,5 @@
import { lazy, Suspense } from 'react'
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import type { ActiveRightSidebarTab } from '@/store/slices/editor'
const FileExplorer = lazy(() => import('./FileExplorer'))
@@ -15,11 +15,12 @@ import { useSidebarProjectDrop } from './useSidebarProjectDrop'
import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel'
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog'))
const RemoveFolderDialog = React.lazy(() => import('./RemoveFolderDialog'))
const WorktreeVisibilityDialog = React.lazy(() => import('./WorktreeVisibilityDialog'))
const OrcaYamlTrustDialog = React.lazy(() => import('./OrcaYamlTrustDialog'))
const WorktreeMetaDialog = lazyWithRetry(() => import('./WorktreeMetaDialog'))
const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog'))
const WorktreeVisibilityDialog = lazyWithRetry(() => import('./WorktreeVisibilityDialog'))
const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog'))
const MIN_WIDTH = 220
const MAX_WIDTH = 500
@@ -14,6 +14,7 @@ import {
Server
} from 'lucide-react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -70,18 +71,18 @@ type StatusBarProps = {
floatingTerminalOpen: boolean
}
const PetStatusSegment = React.lazy(() =>
const PetStatusSegment = lazyWithRetry(() =>
import('./PetStatusSegment').then((module) => ({ default: module.PetStatusSegment }))
)
const ResourceUsageStatusSegment = React.lazy(() =>
const ResourceUsageStatusSegment = lazyWithRetry(() =>
import('./ResourceUsageStatusSegment').then((module) => ({
default: module.ResourceUsageStatusSegment
}))
)
const PortsStatusSegment = React.lazy(() =>
const PortsStatusSegment = lazyWithRetry(() =>
import('./PortsStatusSegment').then((module) => ({ default: module.PortsStatusSegment }))
)
const SshStatusSegment = React.lazy(() =>
const SshStatusSegment = lazyWithRetry(() =>
import('./SshStatusSegment').then((module) => ({ default: module.SshStatusSegment }))
)
@@ -1,4 +1,5 @@
import { lazy, Suspense, useMemo } from 'react'
import { Suspense, useMemo } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { useDroppable } from '@dnd-kit/core'
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
import { useAppStore } from '../../store'
@@ -0,0 +1,194 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentType } from 'react'
import { loadLazyWithRetry } from './lazy-with-retry'
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
const Comp: ComponentType = () => null
const chunkParseError = (): SyntaxError => new SyntaxError("Unexpected token ']'")
function spyOnReload(): ReturnType<typeof vi.fn> {
const reload = vi.fn()
// happy-dom's location.reload is a no-op that would otherwise log; replace it.
vi.spyOn(window.location, 'reload').mockImplementation(reload)
return reload
}
function stubCrashReportsBreadcrumb(): ReturnType<typeof vi.fn> {
const recordBreadcrumb = vi.fn()
Object.assign(window, { api: { crashReports: { recordBreadcrumb } } })
return recordBreadcrumb
}
// Why: happy-dom's Storage is a Proxy that vi.spyOn cannot reliably restore, so
// override window.sessionStorage with a throwing getter and restore the saved
// descriptor in afterEach.
let savedSessionStorageDescriptor: PropertyDescriptor | undefined
function makeSessionStorageThrow(): void {
savedSessionStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'sessionStorage')
Object.defineProperty(window, 'sessionStorage', {
configurable: true,
get() {
throw new Error('storage blocked')
}
})
}
beforeEach(() => {
vi.useFakeTimers()
window.sessionStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
if (savedSessionStorageDescriptor) {
Object.defineProperty(window, 'sessionStorage', savedSessionStorageDescriptor)
savedSessionStorageDescriptor = undefined
}
try {
delete (window as unknown as { api?: unknown }).api
window.sessionStorage.clear()
} catch {
// ignore — environment without storage
}
})
describe('loadLazyWithRetry', () => {
it('retries with exponential backoff (250ms, 500ms) and then resolves', async () => {
const reload = spyOnReload()
const factory = vi
.fn()
.mockRejectedValueOnce(chunkParseError())
.mockRejectedValueOnce(chunkParseError())
.mockResolvedValueOnce({ default: Comp })
const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 })
expect(factory).toHaveBeenCalledTimes(1) // first attempt runs synchronously
await vi.advanceTimersByTimeAsync(200)
expect(factory).toHaveBeenCalledTimes(1) // still inside the 250ms backoff
await vi.advanceTimersByTimeAsync(100)
expect(factory).toHaveBeenCalledTimes(2) // 250ms elapsed -> 2nd attempt
await vi.advanceTimersByTimeAsync(400)
expect(factory).toHaveBeenCalledTimes(2) // still inside the 500ms backoff
await vi.advanceTimersByTimeAsync(100)
expect(factory).toHaveBeenCalledTimes(3) // 500ms elapsed -> 3rd attempt
await expect(loaded).resolves.toEqual({ default: Comp })
expect(reload).not.toHaveBeenCalled()
})
it('performs exactly one guarded reload after retries are exhausted', async () => {
const reload = spyOnReload()
const factory = vi.fn(() => Promise.reject(chunkParseError()))
const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 })
let settled = false
void loaded.then(
() => {
settled = true
},
() => {
settled = true
}
)
await vi.advanceTimersByTimeAsync(5000)
expect(factory).toHaveBeenCalledTimes(3)
expect(reload).toHaveBeenCalledTimes(1)
expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe('1')
// The load promise must suspend (never settle) while the page reloads, so the
// error boundary never flashes.
expect(settled).toBe(false)
})
it('does NOT reload twice — re-throws once the guard is already set', async () => {
const reload = spyOnReload()
window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
const error = chunkParseError()
const factory = vi.fn(() => Promise.reject(error))
const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 })
const assertion = expect(loaded).rejects.toBe(error)
await vi.advanceTimersByTimeAsync(5000)
await assertion
expect(reload).not.toHaveBeenCalled()
})
it('fails closed (re-throws, never reloads) when sessionStorage reads throw', async () => {
const reload = spyOnReload()
// Private-mode / sandboxed storage makes reads throw. The guard must treat
// this as "already reloaded" so a broken chunk can NEVER cause a reload loop.
makeSessionStorageThrow()
const error = chunkParseError()
const factory = vi.fn(() => Promise.reject(error))
const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 })
const assertion = expect(loaded).rejects.toBe(error)
await vi.advanceTimersByTimeAsync(5000)
await assertion
expect(reload).not.toHaveBeenCalled()
})
it('records a lazy_chunk_reload breadcrumb (with reloadKey) before reloading', async () => {
const reload = spyOnReload()
const recordBreadcrumb = stubCrashReportsBreadcrumb()
const factory = vi.fn(() => Promise.reject(chunkParseError()))
const loaded = loadLazyWithRetry(factory, { retries: 0, reloadKey: 'right-sidebar' })
let settled = false
void loaded.then(
() => {
settled = true
},
() => {
settled = true
}
)
await vi.advanceTimersByTimeAsync(5000)
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
expect(recordBreadcrumb).toHaveBeenCalledWith({
name: 'lazy_chunk_reload',
data: { reloadKey: 'right-sidebar', message: "Unexpected token ']'" }
})
// The breadcrumb must land before window.location.reload() tears the page down.
expect(recordBreadcrumb.mock.invocationCallOrder[0]).toBeLessThan(
reload.mock.invocationCallOrder[0]
)
expect(settled).toBe(false)
})
it('re-throws without reloading when there is no window (SSR / node)', async () => {
vi.stubGlobal('window', undefined)
const error = chunkParseError()
const factory = vi.fn(() => Promise.reject(error))
const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 })
const assertion = expect(loaded).rejects.toBe(error)
await vi.advanceTimersByTimeAsync(5000)
await assertion
expect(factory).toHaveBeenCalledTimes(2)
})
it('keeps the reload guard set across a successful load (no second reload in one session)', async () => {
const reload = spyOnReload()
window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
const factory = vi.fn(() => Promise.resolve({ default: Comp }))
await loadLazyWithRetry(factory)
// The guard must survive a healthy load — otherwise a sibling chunk's success
// would re-arm the reload and an auto-mounted corrupt chunk would loop.
expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe('1')
expect(reload).not.toHaveBeenCalled()
})
})
+114
View File
@@ -0,0 +1,114 @@
import { lazy, type ComponentType, type LazyExoticComponent } from 'react'
/**
* Resilient replacement for React.lazy.
*
* Why: a stale, corrupt, or truncated lazy chunk parses as invalid JavaScript and
* rejects its dynamic import() with a native SyntaxError (e.g. "Unexpected token
* ']'"). React.lazy permanently caches that rejection, so the error boundary's
* "Retry" — which just re-renders the same Lazy — can never recover it; the
* surface stays dead and reports a react-error-boundary crash. This wrapper first
* retries transient fetch failures, then performs ONE guarded full reload to
* refetch fresh chunk bytes and rebuild the ES module map, before finally falling
* through to the error boundary.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirror React.lazy's own ComponentType<any> constraint so every existing call site type-checks unchanged.
type AnyComponent = ComponentType<any>
type LazyFactory<T extends AnyComponent> = () => Promise<{ default: T }>
export type LazyWithRetryOptions = {
retries?: number
baseDelayMs?: number
/** Label surfaced in the reload breadcrumb for triage; not used for control flow. */
reloadKey?: string
}
// One recovery reload per session. The guard survives the reload itself (so we
// never loop) but resets when the window/app closes, so a later launch — e.g.
// after an update ships fresh chunks — can earn another reload. sessionStorage
// (not localStorage) gives exactly that lifetime; it is never cleared mid-session,
// otherwise a sibling chunk's healthy load would re-arm the reload and an
// auto-mounted corrupt chunk would loop.
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
const DEFAULT_RETRIES = 2
const DEFAULT_BASE_DELAY_MS = 250
function hasAttemptedChunkReload(): boolean {
try {
return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1'
} catch {
// Why: when sessionStorage is unavailable (private mode / sandboxed), fail
// closed (treat as already-reloaded) so we never risk an infinite reload loop.
return true
}
}
function markChunkReloadAttempted(): void {
try {
window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
} catch {
// Best-effort; if writing throws, hasAttemptedChunkReload() also fails closed.
}
}
function recordReloadBreadcrumb(reloadKey: string, message: string): void {
// Inlined rather than importing crash-diagnostics so this low-level recovery
// primitive stays free of the renderer/webview module graph (keeps it SSR- and
// unit-test-friendly). Mirrors crash-diagnostics' best-effort breadcrumb call.
try {
const api = (window as Window & { api?: Window['api'] }).api
api?.crashReports.recordBreadcrumb({ name: 'lazy_chunk_reload', data: { reloadKey, message } })
} catch {
// Crash evidence is best-effort and must never mask the original failure.
}
}
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
// Suspends the React.lazy boundary while window.location.reload() tears the page
// down, so the error fallback never flashes in the moment before the reload lands.
const SUSPEND_UNTIL_RELOAD = new Promise<never>(() => undefined)
export async function loadLazyWithRetry<T extends AnyComponent>(
factory: LazyFactory<T>,
options: LazyWithRetryOptions = {}
): Promise<{ default: T }> {
const retries = options.retries ?? DEFAULT_RETRIES
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS
let lastError: unknown
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await factory()
} catch (error) {
lastError = error
if (attempt < retries) {
// Exponential backoff absorbs transient fetch hiccups (HTTP / relay / SSH).
await wait(baseDelayMs * 2 ** attempt)
}
}
}
if (typeof window !== 'undefined' && !hasAttemptedChunkReload()) {
markChunkReloadAttempted()
recordReloadBreadcrumb(
options.reloadKey ?? 'unknown',
lastError instanceof Error ? lastError.message : String(lastError)
)
window.location.reload()
return SUSPEND_UNTIL_RELOAD
}
// Already reloaded once this session, or no window (SSR / node): re-throw so
// RecoverableRenderErrorBoundary catches and reports it instead of looping.
throw lastError
}
export function lazyWithRetry<T extends AnyComponent>(
factory: LazyFactory<T>,
options?: LazyWithRetryOptions
): LazyExoticComponent<T> {
return lazy(() => loadLazyWithRetry(factory, options))
}
+2 -1
View File
@@ -1,6 +1,7 @@
import '../assets/main.css'
import { lazy, Suspense, useMemo, useState } from 'react'
import { Suspense, useMemo, useState } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import ReactDOM from 'react-dom/client'
import { useTranslation } from 'react-i18next'
import WebConnect from './WebConnect'