mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(editor): prove the lazy-chunk reload landed before suppressing crash reports
- lazy-with-retry: reload guard stores the requesting document's identity, so a vetoed reload() no longer reads as "recovery ran" (crash b860def2) - lazy-with-retry: bound the post-reload suspension so a vetoed navigation surfaces the real error instead of hanging the pane on a spinner - RichMarkdownErrorBoundary: contain the LazyChunkLoadError sentinel without a crash report, but record a lazy_chunk_boundary_degraded breadcrumb - EditorContent: name the rich markdown chunk at the lazy call site Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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'))
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<RichMarkdownErrorBoundary fileId="file-1">{children}</RichMarkdownErrorBoundary>
|
||||
<RichMarkdownErrorBoundary key={boundaryKey} fileId="file-1">
|
||||
{children}
|
||||
</RichMarkdownErrorBoundary>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +57,8 @@ async function flushReactWork(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// 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(
|
||||
<BoundaryHarness>
|
||||
<LazyRejectingImport />
|
||||
</BoundaryHarness>
|
||||
)
|
||||
})
|
||||
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(
|
||||
<BoundaryHarness boundaryKey={boundaryKey}>
|
||||
<LazyRejectingImport />
|
||||
</BoundaryHarness>
|
||||
)
|
||||
})
|
||||
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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
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<Props, State> {
|
||||
|
||||
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({
|
||||
|
||||
+4
-1
@@ -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(
|
||||
|
||||
@@ -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<unknown> => {
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ type AnyComponent = ComponentType<any>
|
||||
|
||||
type LazyFactory<T extends AnyComponent> = () => 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<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)
|
||||
// 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<never> {
|
||||
return new Promise<never>((_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<T extends AnyComponent>(
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user