diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 138405745fa..acff1427db2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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, diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 9cf833d458e..fa5802dbd57 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -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'))") diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c0522afddcf..f2c12955447 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -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' diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 4154b4e482e..053ff6265e1 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -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' diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 1c41f158c1e..905d18c3bbe 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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' diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index de66ea79796..fe2ba9b9024 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -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, diff --git a/src/renderer/src/components/editor/ChangesModeView.tsx b/src/renderer/src/components/editor/ChangesModeView.tsx index aa472de07aa..27d2ce943b3 100644 --- a/src/renderer/src/components/editor/ChangesModeView.tsx +++ b/src/renderer/src/components/editor/ChangesModeView.tsx @@ -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' diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 0c3200c1841..7792e902e17 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -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' diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index e045f77189a..bc6d64e5a58 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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' diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 88197dbce8a..d4919c0b55f 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -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' diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx index 8e1e6c21894..f26f427152e 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx @@ -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')) diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index fd6801d27b2..a980f9cf8f8 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -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 diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index b67aa4cfa7d..6a4083f4bc0 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -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 })) ) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 4ad1eeaf382..668fbb8da92 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -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' diff --git a/src/renderer/src/lib/lazy-with-retry.test.ts b/src/renderer/src/lib/lazy-with-retry.test.ts new file mode 100644 index 00000000000..7ef2f26fdec --- /dev/null +++ b/src/renderer/src/lib/lazy-with-retry.test.ts @@ -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 { + 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 { + 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() + }) +}) diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts new file mode 100644 index 00000000000..5a09f6b2a95 --- /dev/null +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -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 constraint so every existing call site type-checks unchanged. +type AnyComponent = ComponentType + +type LazyFactory = () => 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 => 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(() => undefined) + +export async function loadLazyWithRetry( + factory: LazyFactory, + 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( + factory: LazyFactory, + options?: LazyWithRetryOptions +): LazyExoticComponent { + return lazy(() => loadLazyWithRetry(factory, options)) +} diff --git a/src/renderer/src/web/main.tsx b/src/renderer/src/web/main.tsx index 7cfd3abec91..da0cda067d5 100644 --- a/src/renderer/src/web/main.tsx +++ b/src/renderer/src/web/main.tsx @@ -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'