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 }