diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx new file mode 100644 index 00000000000..90046bf0d9c --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +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' + +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary' + +const reportCrashMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function createContainer(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + return { container, root: createRoot(container) } +} + +// Mirrors EditorContent: the Suspense sits above the boundary, which wraps the lazy editor. +function BoundaryHarness({ children }: { children: ReactNode }): ReactElement { + return ( + Loading...}> + {children} + + ) +} + +async function flushReactWork(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe('RichMarkdownErrorBoundary lazy chunk containment', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + reportCrashMock.mockReset() + window.sessionStorage.clear() + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + consoleError.mockRestore() + }) + + // Repro for crash b860def2: an app update swapped chunk hashes, lazy-with-retry + // burned its one guarded reload, then surfaced LazyChunkLoadError here. + it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const LazyRejectingImport = lazyWithRetry( + () => Promise.reject(new SyntaxError("Unexpected token ':'")), + { retries: 0 } + ) + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + await flushReactWork() + await flushReactWork() + + expect(container?.textContent).toContain('rich markdown editor') + expect(reportCrashMock).not.toHaveBeenCalled() + }) + + it('still reports ordinary render errors', async () => { + const error = new Error('ordinary render failure') + function BrokenEditor(): ReactElement { + throw error + } + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + + expect(container?.textContent).toContain('rich markdown editor') + expect(reportCrashMock).toHaveBeenCalledTimes(1) + expect(reportCrashMock).toHaveBeenCalledWith( + expect.objectContaining({ + boundaryId: 'editor.rich-markdown', + surface: 'rich-markdown-editor', + error + }) + ) + }) +}) diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx index 105be54524f..68ae1ed819b 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 { isLazyChunkLoadError } from '@/lib/lazy-with-retry' import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' import { translate } from '@/i18n/i18n' @@ -38,6 +39,12 @@ 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). + if (isLazyChunkLoadError(error)) { + return + } void reportReactErrorBoundaryCrash({ boundaryId: 'editor.rich-markdown', surface: 'rich-markdown-editor',