diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx
index eb8afa2d5c9..8e3ac1e0b5b 100644
--- a/src/renderer/src/components/editor/EditorContent.tsx
+++ b/src/renderer/src/components/editor/EditorContent.tsx
@@ -33,7 +33,11 @@ import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
const MonacoEditor = lazy(() => import('./MonacoEditor'))
const DiffViewer = lazy(() => import('./DiffViewer'))
const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer'))
-const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'))
+// Why keyed: the reload breadcrumb is the only pre-crash evidence, and an unkeyed
+// site logs `reloadKey: "unknown"` (see crash b860def2).
+const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'), {
+ reloadKey: 'rich-markdown-editor'
+})
const MarkdownPreview = lazy(() => import('./MarkdownPreview'))
const ImageViewer = lazy(() => import('./ImageViewer'))
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx
index 90046bf0d9c..7c69952fa9e 100644
--- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
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'
@@ -8,12 +10,20 @@ import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { 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
+}))
+
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
+// A guard left by a *different* document is what proves the reload landed; only then
+// does lazy-with-retry produce the sentinel this boundary suppresses.
+const LANDED_RELOAD_GUARD_VALUE = 'doc-before-the-reload'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
@@ -23,11 +33,20 @@ function createContainer(): { container: HTMLDivElement; root: Root } {
return { container, root: createRoot(container) }
}
-// Mirrors EditorContent: the Suspense sits above the boundary, which wraps the lazy editor.
-function BoundaryHarness({ children }: { children: ReactNode }): ReactElement {
+// Mirrors EditorContent: the Suspense sits above the boundary, which EditorContent
+// re-keys per open file (`key={viewStateScopeId}`), and which wraps the lazy editor.
+function BoundaryHarness({
+ children,
+ boundaryKey = 'pane-a'
+}: {
+ children: ReactNode
+ boundaryKey?: string
+}): ReactElement {
return (
Loading...}>
- {children}
+
+ {children}
+
)
}
@@ -38,6 +57,8 @@ async function flushReactWork(): Promise {
})
}
+// The breadcrumb dedupe is module-scoped (it has to outlive boundary remounts), so
+// every test below must use a distinct parse-error message to start from a clean slate.
describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
@@ -45,6 +66,7 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
beforeEach(() => {
reportCrashMock.mockReset()
+ recordBreadcrumbMock.mockReset()
window.sessionStorage.clear()
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
@@ -60,10 +82,11 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
consoleError.mockRestore()
})
- // Repro for crash b860def2: an app update swapped chunk hashes, lazy-with-retry
- // burned its one guarded reload, then surfaced LazyChunkLoadError here.
+ // The only honest sentinel case: a landed reload already refetched the chunk and it
+ // still fails to parse, so the report is not actionable. (b860def2 itself is NOT this
+ // case — its reload was vetoed, so lazy-with-retry now re-throws the real error.)
it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => {
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
const LazyRejectingImport = lazyWithRetry(
() => Promise.reject(new SyntaxError("Unexpected token ':'")),
{ retries: 0 }
@@ -82,6 +105,70 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
expect(container?.textContent).toContain('rich markdown editor')
expect(reportCrashMock).not.toHaveBeenCalled()
+ // The suppressed report was the only artifact naming this surface; keep evidence.
+ expect(recordBreadcrumbMock).toHaveBeenCalledWith('lazy_chunk_boundary_degraded', {
+ boundaryId: 'editor.rich-markdown',
+ cause: "SyntaxError: Unexpected token ':'"
+ })
+ })
+
+ // The breadcrumb ring holds 30 entries and does not coalesce this name, so an
+ // impatient Retry streak on a permanently rejected chunk would erase the trail.
+ it('records the degraded breadcrumb once across repeated Retry clicks', async () => {
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
+ const LazyRejectingImport = lazyWithRetry(
+ () => Promise.reject(new SyntaxError("Unexpected token '}'")),
+ { retries: 0 }
+ )
+ ;({ container, root } = createContainer())
+
+ await act(async () => {
+ root?.render(
+
+
+
+ )
+ })
+ await flushReactWork()
+ await flushReactWork()
+
+ for (let click = 0; click < 3; click += 1) {
+ const retry = container?.querySelector('button')
+ expect(retry).not.toBeNull()
+ await act(async () => {
+ retry?.click()
+ })
+ await flushReactWork()
+ }
+
+ expect(container?.textContent).toContain('rich markdown editor')
+ expect(recordBreadcrumbMock).toHaveBeenCalledTimes(1)
+ })
+
+ // EditorContent re-keys the boundary per open file, so instance-scoped dedupe would
+ // re-record on every markdown tab switch — the same ring flush the Retry streak causes.
+ it('records the degraded breadcrumb once across boundary remounts', async () => {
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
+ const LazyRejectingImport = lazyWithRetry(
+ () => Promise.reject(new SyntaxError("Unexpected token '<'")),
+ { retries: 0 }
+ )
+ ;({ container, root } = createContainer())
+
+ for (const boundaryKey of ['pane-a', 'pane-b', 'pane-c']) {
+ await act(async () => {
+ root?.render(
+
+
+
+ )
+ })
+ await flushReactWork()
+ await flushReactWork()
+ }
+
+ expect(container?.textContent).toContain('rich markdown editor')
+ expect(recordBreadcrumbMock).toHaveBeenCalledTimes(1)
})
it('still reports ordinary render errors', async () => {
@@ -100,6 +187,7 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
})
expect(container?.textContent).toContain('rich markdown editor')
+ expect(recordBreadcrumbMock).not.toHaveBeenCalled()
expect(reportCrashMock).toHaveBeenCalledTimes(1)
expect(reportCrashMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -109,4 +197,14 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
})
)
})
+
+ // b860def2's pre-crash breadcrumb read `reloadKey: "unknown"`, which cannot tell
+ // the rich editor chunk apart from the seven other lazy chunks in EditorContent.
+ it('names the rich editor chunk at the EditorContent lazy call site', () => {
+ const source = readFileSync(
+ join(process.cwd(), 'src/renderer/src/components/editor/EditorContent.tsx'),
+ 'utf8'
+ )
+ expect(source).toContain("reloadKey: 'rich-markdown-editor'")
+ })
})
diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx
index 68ae1ed819b..e6a5376f31f 100644
--- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
import { isLazyChunkLoadError } from '@/lib/lazy-with-retry'
import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting'
import { translate } from '@/i18n/i18n'
@@ -13,6 +14,20 @@ type State = {
fileId: string
}
+// Module-scoped, not per instance: EditorContent re-keys this boundary per open file,
+// so an instance field would re-record on every markdown tab switch and flush the
+// 30-entry breadcrumb ring that this very evidence lives in. Bounded by the number of
+// distinct chunk failures a document can see (effectively one).
+const recordedLazyChunkCauses = new Set()
+
+function describeLazyChunkCause(error: Error): string {
+ const cause = (error as { cause?: unknown }).cause
+ if (cause instanceof Error) {
+ return `${cause.name}: ${cause.message}`
+ }
+ return cause === undefined ? 'unknown' : String(cause)
+}
+
// Why: a thrown exception inside the TipTap/ProseMirror render or in the
// effect that runs `setContent` + empty-list repair on external-reload
// would escape to the React root and — without this boundary — cause React
@@ -39,10 +54,18 @@ export class RichMarkdownErrorBoundary extends React.Component {
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[RichMarkdownEditor] render crash contained by boundary', error, info)
- // Why: lazy-with-retry already spent its retries and its one guarded reload, so a
- // chunk-hash swap after an app update is expected degradation, not a crash. The
- // fallback below still renders (matches RecoverableRenderErrorBoundary).
+ // Retries and the one guarded reload are already spent, so a crash report is not
+ // actionable — but it was the only artifact naming this surface, so leave a
+ // breadcrumb. Fallback still renders (matches RecoverableRenderErrorBoundary).
if (isLazyChunkLoadError(error)) {
+ const cause = describeLazyChunkCause(error)
+ if (!recordedLazyChunkCauses.has(cause)) {
+ recordedLazyChunkCauses.add(cause)
+ recordRendererCrashBreadcrumb('lazy_chunk_boundary_degraded', {
+ boundaryId: 'editor.rich-markdown',
+ cause
+ })
+ }
return
}
void reportReactErrorBoundaryCrash({
diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx
index a4730f59e92..b6b19b2ec1f 100644
--- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx
+++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx
@@ -14,6 +14,9 @@ vi.mock('@/lib/react-error-boundary-reporting', () => ({
}))
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
+// Only a guard left by a *different* document proves the reload landed, which is
+// what makes lazy-with-retry emit the sentinel this boundary suppresses.
+const LANDED_RELOAD_GUARD_VALUE = 'doc-before-the-reload'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
@@ -60,7 +63,7 @@ describe('RecoverableRenderErrorBoundary lazy chunk containment', () => {
})
it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => {
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
const LazyRejectingImport = lazyWithRetry(
() =>
Promise.reject(
diff --git a/src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts b/src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts
index 01d8b776c16..5746d875d9e 100644
--- a/src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts
+++ b/src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts
@@ -13,8 +13,8 @@ import { isLazyChunkLoadError, loadLazyWithRetry } from './lazy-with-retry'
// const SourceControl = lazy(() => import('./SourceControl')) // lazyWithRetry
//
// So the corrupt-chunk recovery IS wired in. The crash was a BLIND SPOT in that
-// recovery: after the single guarded window.location.reload() has already fired
-// once this session (sessionStorage 'orca:lazy-chunk-reload-attempted' === '1'),
+// recovery: after the single guarded window.location.reload() has already landed
+// once this session (the guard holds a *previous* document's identity),
// loadLazyWithRetry only converted the failure into a recoverable
// LazyChunkLoadError when isKnownDynamicImportFailure(error) was true. A corrupt /
// truncated chunk that parses as invalid JS rejects import() with a native
@@ -25,6 +25,10 @@ import { isLazyChunkLoadError, loadLazyWithRetry } from './lazy-with-retry'
// corrupt-chunk failure; these tests pin that behavior.
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
+// Only a token left by a *different* document proves the reload landed; a token
+// matching this document means the reload was requested and vetoed.
+const LANDED_RELOAD_GUARD_VALUE = 'doc-before-the-reload'
+const VETOED_RELOAD_GUARD_VALUE = (): string => String(performance.timeOrigin)
// The exact error the renderer received from the corrupt right-sidebar chunk.
const reportedCrashError = (): SyntaxError => new SyntaxError("Unexpected token ')'")
@@ -57,9 +61,9 @@ afterEach(() => {
describe('right-sidebar lazy chunk SyntaxError crash (regression)', () => {
it('recovers a corrupt right-sidebar chunk SyntaxError instead of surfacing it to the boundary', async () => {
const reload = spyOnReload()
- // The one guarded reload already happened earlier this session: the user has
- // navigated/reloaded once after the stale chunk was first hit.
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ // The one guarded reload already landed earlier this session: the guard was
+ // written by the document that reloaded, not by this one.
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
// import('./SourceControl') rejects with the native parse error from the
// corrupt chunk — exactly what the crash report captured.
@@ -87,7 +91,7 @@ describe('right-sidebar lazy chunk SyntaxError crash (regression)', () => {
// chunk, surfaced as a fetch failure, IS recovered; surfaced as a parse error,
// it is not. Both are the same unrecoverable corrupt right-sidebar chunk.
const recover = async (makeError: () => Error): Promise => {
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
const factory = vi.fn(() => Promise.reject(makeError()))
const loaded = loadLazyWithRetry(factory, { retries: 0, reloadKey: 'right-sidebar' })
try {
@@ -109,4 +113,31 @@ describe('right-sidebar lazy chunk SyntaxError crash (regression)', () => {
// The parse error from the very same corrupt chunk must be recovered too.
expect(isLazyChunkLoadError(parseOutcome)).toBe(true)
})
+
+ // Deliberate narrowing of the recovery above: a guard this document wrote itself
+ // means its reload() was vetoed, so no refetch ever happened and the sentinel
+ // would hide a live failure from the boundary.
+ it('surfaces the raw SyntaxError when this document is the one whose reload was vetoed', async () => {
+ const reload = spyOnReload()
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, VETOED_RELOAD_GUARD_VALUE())
+ const error = reportedCrashError()
+ const factory = vi.fn(() => Promise.reject(error))
+
+ const loaded = loadLazyWithRetry(factory, { retries: 0, reloadKey: 'right-sidebar' })
+ // Track settlement rather than awaiting: a regression leaves this pending forever.
+ let caught: unknown = 'pending'
+ void loaded.then(
+ (value) => {
+ caught = value
+ },
+ (rejection: unknown) => {
+ caught = rejection
+ }
+ )
+ await vi.advanceTimersByTimeAsync(5000)
+
+ expect(caught).toBe(error)
+ expect(isLazyChunkLoadError(caught)).toBe(false)
+ expect(reload).not.toHaveBeenCalled() // re-arming would loop against the veto
+ })
})
diff --git a/src/renderer/src/lib/lazy-with-retry.test.ts b/src/renderer/src/lib/lazy-with-retry.test.ts
index a5d19e3c4e9..1f8faceb099 100644
--- a/src/renderer/src/lib/lazy-with-retry.test.ts
+++ b/src/renderer/src/lib/lazy-with-retry.test.ts
@@ -5,6 +5,9 @@ import type { ComponentType } from 'react'
import { isLazyChunkLoadError, loadLazyWithRetry } from './lazy-with-retry'
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
+// A guard holding a *different* document's token is the only proof the reload
+// landed; a token matching this document means the reload was vetoed.
+const LANDED_RELOAD_GUARD_VALUE = 'doc-before-the-reload'
const Comp: ComponentType = () => null
const chunkParseError = (): SyntaxError => new SyntaxError("Unexpected token ']'")
const chunkFetchError = (): TypeError =>
@@ -103,15 +106,45 @@ describe('loadLazyWithRetry', () => {
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.
+ // The guard stores the requesting document's identity, not a bare flag.
+ expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe(String(performance.timeOrigin))
+ // The load promise must stay pending across the reload window, so the error
+ // boundary never flashes ahead of the navigation.
expect(settled).toBe(false)
})
+ // Crash b860def2: reload() was called at 19:05 and the document was still alive at
+ // 19:49 (no renderer bootstrap in between), so the requesting pane suspended forever.
+ it('surfaces the original error when the guarded reload never tears the document down', async () => {
+ const reload = spyOnReload()
+ stubCrashReportsBreadcrumb()
+ const error = chunkParseError()
+ const factory = vi.fn(() => Promise.reject(error))
+
+ const loaded = loadLazyWithRetry(factory, { retries: 0 })
+ let settled: unknown = 'pending'
+ void loaded.then(
+ (value) => {
+ settled = value
+ },
+ (rejection) => {
+ settled = rejection
+ }
+ )
+
+ await vi.advanceTimersByTimeAsync(5000)
+ expect(reload).toHaveBeenCalledTimes(1)
+ // Still suspended inside the grace window, so a landed reload shows no fallback.
+ expect(settled).toBe('pending')
+
+ await vi.advanceTimersByTimeAsync(10_000)
+ expect(settled).toBe(error)
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
it('does NOT reload twice — wraps known chunk failures once the guard is already set', async () => {
const reload = spyOnReload()
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
const error = chunkFetchError()
const factory = vi.fn(() => Promise.reject(error))
@@ -128,9 +161,29 @@ describe('loadLazyWithRetry', () => {
expect(isLazyChunkLoadError(caught)).toBe(true)
})
+ // Crash b860def2: the guarded reload() was vetoed, the document lived on for 44
+ // minutes, and the still-set guard turned the next failure into the sentinel that
+ // error boundaries suppress — hiding a recovery that never ran.
+ it('reports the original error when the guard belongs to this same document (reload vetoed)', async () => {
+ const reload = spyOnReload()
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, String(performance.timeOrigin))
+ const error = chunkParseError()
+ const factory = vi.fn(() => Promise.reject(error))
+
+ const loaded = loadLazyWithRetry(factory, { retries: 0 })
+ const assertion = expect(loaded).rejects.toBe(error)
+ await vi.advanceTimersByTimeAsync(5000)
+ await assertion
+
+ // Re-arming the reload here would loop against whatever vetoed the first one.
+ expect(reload).not.toHaveBeenCalled()
+ const caught = await loaded.catch((rejection) => rejection)
+ expect(isLazyChunkLoadError(caught)).toBe(false)
+ })
+
it('preserves the original error when the guarded failure is not a dynamic import failure', async () => {
const reload = spyOnReload()
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
const error = new Error('render bug from lazy module evaluation')
const factory = vi.fn(() => Promise.reject(error))
@@ -152,7 +205,7 @@ describe('loadLazyWithRetry', () => {
// same dead import). Regression guard for crash report e08749bb (right
// sidebar, "Unexpected token ')'").
const reload = spyOnReload()
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
const error = chunkParseError()
const factory = vi.fn(() => Promise.reject(error))
@@ -172,7 +225,7 @@ describe('loadLazyWithRetry', () => {
// An ordinary Error from a lazy module is a genuine evaluation bug, not a
// corrupt chunk; it must still surface raw after the guard is set.
const reload = spyOnReload()
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
const error = new Error('render bug from lazy module evaluation')
const factory = vi.fn(() => Promise.reject(error))
@@ -250,14 +303,14 @@ describe('loadLazyWithRetry', () => {
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')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
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(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe(LANDED_RELOAD_GUARD_VALUE)
expect(reload).not.toHaveBeenCalled()
})
})
diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts
index 26784436524..415d72a25c2 100644
--- a/src/renderer/src/lib/lazy-with-retry.ts
+++ b/src/renderer/src/lib/lazy-with-retry.ts
@@ -18,7 +18,7 @@ type AnyComponent = ComponentType
type LazyFactory = () => Promise<{ default: T }>
-type ReloadGuardState = 'not-attempted' | 'attempted' | 'unavailable'
+type ReloadGuardState = 'not-attempted' | 'reload-landed' | 'reload-not-landed' | 'unavailable'
export type LazyWithRetryOptions = {
retries?: number
@@ -46,15 +46,31 @@ export function isLazyChunkLoadError(error: unknown): error is LazyChunkLoadErro
// 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'
+// Stand-in document identity for hosts without a usable performance.timeOrigin; a
+// reload re-evaluates this module, so a fresh value still means the reload landed.
+const FALLBACK_RELOAD_TOKEN = `doc-${Math.random().toString(36).slice(2)}`
const DEFAULT_RETRIES = 2
const DEFAULT_BASE_DELAY_MS = 250
+// The guard stores the requesting document's identity, not a bare flag: reload() can be
+// vetoed (beforeunload preventDefault → Electron cancels the navigation) and a bare flag
+// would then read as "recovery ran" forever inside a document that never reloaded (crash
+// b860def2). timeOrigin changes per navigation, so a value still ours means no reload.
+function currentDocumentReloadToken(): string {
+ const timeOrigin = typeof performance === 'undefined' ? Number.NaN : performance.timeOrigin
+ return Number.isFinite(timeOrigin) && timeOrigin > 0 ? String(timeOrigin) : FALLBACK_RELOAD_TOKEN
+}
+
function readChunkReloadGuardState(): ReloadGuardState {
if (typeof window === 'undefined') {
return 'unavailable'
}
try {
- return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' ? 'attempted' : 'not-attempted'
+ const stored = window.sessionStorage.getItem(RELOAD_GUARD_KEY)
+ if (stored === null) {
+ return 'not-attempted'
+ }
+ return stored === currentDocumentReloadToken() ? 'reload-not-landed' : 'reload-landed'
} catch {
// Why: when storage is blocked we cannot prove a reload happened, but still
// fail closed on reloads so a broken chunk never loops.
@@ -64,7 +80,7 @@ function readChunkReloadGuardState(): ReloadGuardState {
function markChunkReloadAttempted(): boolean {
try {
- window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ window.sessionStorage.setItem(RELOAD_GUARD_KEY, currentDocumentReloadToken())
return true
} catch {
// A reload without a durable guard can loop, so treat write failure as unavailable.
@@ -86,9 +102,19 @@ function recordReloadBreadcrumb(reloadKey: string, message: string): void {
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)
+// Grace window for reload() to tear the document down. Long enough that the error
+// fallback never flashes ahead of a real navigation.
+const RELOAD_SETTLE_GRACE_MS = 10_000
+
+// Suspends the boundary while reload() tears the page down. Suspending forever would
+// hang the pane on a spinner when the reload is vetoed (Orca preventDefault()s
+// beforeunload for dirty editor tabs), so surface the real failure once the grace
+// window proves the navigation never happened.
+function suspendUntilReloadLands(loadError: unknown): Promise {
+ return new Promise((_resolve, reject) => {
+ setTimeout(() => reject(loadError), RELOAD_SETTLE_GRACE_MS)
+ })
+}
function isKnownDynamicImportFailure(error: unknown): boolean {
if (!(error instanceof Error)) {
@@ -150,15 +176,17 @@ export async function loadLazyWithRetry(
lastError instanceof Error ? lastError.message : String(lastError)
)
window.location.reload()
- return SUSPEND_UNTIL_RELOAD
+ return suspendUntilReloadLands(lastError)
}
- if (reloadGuardState === 'attempted' && isKnownDynamicImportFailure(lastError)) {
+ if (reloadGuardState === 'reload-landed' && isKnownDynamicImportFailure(lastError)) {
throw new LazyChunkLoadError(lastError)
}
- // No proven reload attempt (SSR / node / blocked storage) or unknown failure:
- // re-throw the original error so normal error reporting semantics stay intact.
+ // No proven reload (SSR / node / blocked storage / a reload this document asked
+ // for but never got) or unknown failure: re-throw the original error so normal
+ // error reporting semantics stay intact. Boundaries suppress the sentinel, so
+ // wrapping an unproven recovery here would hide the failure entirely.
throw lastError
}