mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user