mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Keep pages usable after lazy chunk reload failures (#6206)
This commit is contained in:
+112
@@ -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 { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary'
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
function BoundaryHarness({ children }: { children: ReactNode }): ReactElement {
|
||||
return (
|
||||
<RecoverableRenderErrorBoundary boundaryId="page.automations" surface="page">
|
||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
async function flushReactWork(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
}
|
||||
|
||||
describe('RecoverableRenderErrorBoundary lazy chunk containment', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let consoleError: ReturnType<typeof vi.spyOn>
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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 TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js')
|
||||
),
|
||||
{ retries: 0 }
|
||||
)
|
||||
;({ container, root } = createContainer())
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<BoundaryHarness>
|
||||
<LazyRejectingImport />
|
||||
</BoundaryHarness>
|
||||
)
|
||||
})
|
||||
await flushReactWork()
|
||||
await flushReactWork()
|
||||
|
||||
expect(container?.querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(reportCrashMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports ordinary render errors', async () => {
|
||||
const error = new Error('ordinary render failure')
|
||||
function BrokenSurface(): ReactElement {
|
||||
throw error
|
||||
}
|
||||
;({ container, root } = createContainer())
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<BoundaryHarness>
|
||||
<BrokenSurface />
|
||||
</BoundaryHarness>
|
||||
)
|
||||
})
|
||||
|
||||
expect(container?.querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(reportCrashMock).toHaveBeenCalledTimes(1)
|
||||
expect(reportCrashMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
boundaryId: 'page.automations',
|
||||
surface: 'page',
|
||||
error
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { AlertTriangle, RotateCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isLazyChunkLoadError } from '@/lib/lazy-with-retry'
|
||||
import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting'
|
||||
import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -48,6 +49,9 @@ export class RecoverableRenderErrorBoundary extends React.Component<Props, State
|
||||
if (this.props.reportAsCrash === false) {
|
||||
return
|
||||
}
|
||||
if (isLazyChunkLoadError(error)) {
|
||||
return
|
||||
}
|
||||
void reportReactErrorBoundaryCrash({
|
||||
boundaryId: this.props.boundaryId,
|
||||
surface: this.props.surface,
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
import { loadLazyWithRetry } from './lazy-with-retry'
|
||||
import { isLazyChunkLoadError, 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 ']'")
|
||||
const chunkFetchError = (): TypeError =>
|
||||
new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js')
|
||||
|
||||
function spyOnReload(): ReturnType<typeof vi.fn> {
|
||||
const reload = vi.fn()
|
||||
@@ -107,21 +109,58 @@ describe('loadLazyWithRetry', () => {
|
||||
expect(settled).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT reload twice — re-throws once the guard is already set', async () => {
|
||||
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')
|
||||
const error = chunkParseError()
|
||||
const error = chunkFetchError()
|
||||
const factory = vi.fn(() => Promise.reject(error))
|
||||
|
||||
const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 })
|
||||
const assertion = expect(loaded).rejects.toMatchObject({
|
||||
name: 'LazyChunkLoadError',
|
||||
cause: error
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await assertion
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
const caught = await loaded.catch((rejection) => rejection)
|
||||
expect(isLazyChunkLoadError(caught)).toBe(true)
|
||||
})
|
||||
|
||||
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')
|
||||
const error = new Error('render bug from lazy module evaluation')
|
||||
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()
|
||||
const caught = await loaded.catch((rejection) => rejection)
|
||||
expect(isLazyChunkLoadError(caught)).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed (re-throws, never reloads) when sessionStorage reads throw', async () => {
|
||||
it('preserves bare parse errors so lazy module evaluation bugs still report', 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: 1, baseDelayMs: 100 })
|
||||
const assertion = expect(loaded).rejects.toBe(error)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await assertion
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
const caught = await loaded.catch((rejection) => rejection)
|
||||
expect(isLazyChunkLoadError(caught)).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed with the original error 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.
|
||||
@@ -135,6 +174,8 @@ describe('loadLazyWithRetry', () => {
|
||||
await assertion
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
const caught = await loaded.catch((rejection) => rejection)
|
||||
expect(isLazyChunkLoadError(caught)).toBe(false)
|
||||
})
|
||||
|
||||
it('records a lazy_chunk_reload breadcrumb (with reloadKey) before reloading', async () => {
|
||||
@@ -166,7 +207,7 @@ describe('loadLazyWithRetry', () => {
|
||||
expect(settled).toBe(false)
|
||||
})
|
||||
|
||||
it('re-throws without reloading when there is no window (SSR / node)', async () => {
|
||||
it('re-throws the original error without reloading when there is no window (SSR / node)', async () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
const error = chunkParseError()
|
||||
const factory = vi.fn(() => Promise.reject(error))
|
||||
@@ -177,6 +218,8 @@ describe('loadLazyWithRetry', () => {
|
||||
await assertion
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(2)
|
||||
const caught = await loaded.catch((rejection) => rejection)
|
||||
expect(isLazyChunkLoadError(caught)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the reload guard set across a successful load (no second reload in one session)', async () => {
|
||||
|
||||
@@ -18,6 +18,8 @@ type AnyComponent = ComponentType<any>
|
||||
|
||||
type LazyFactory<T extends AnyComponent> = () => Promise<{ default: T }>
|
||||
|
||||
type ReloadGuardState = 'not-attempted' | 'attempted' | 'unavailable'
|
||||
|
||||
export type LazyWithRetryOptions = {
|
||||
retries?: number
|
||||
baseDelayMs?: number
|
||||
@@ -25,6 +27,18 @@ export type LazyWithRetryOptions = {
|
||||
reloadKey?: string
|
||||
}
|
||||
|
||||
export class LazyChunkLoadError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super('Lazy chunk load failed after reload recovery was exhausted')
|
||||
this.name = 'LazyChunkLoadError'
|
||||
;(this as { cause?: unknown }).cause = cause
|
||||
}
|
||||
}
|
||||
|
||||
export function isLazyChunkLoadError(error: unknown): error is LazyChunkLoadError {
|
||||
return error instanceof LazyChunkLoadError
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -35,21 +49,26 @@ const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
|
||||
const DEFAULT_RETRIES = 2
|
||||
const DEFAULT_BASE_DELAY_MS = 250
|
||||
|
||||
function hasAttemptedChunkReload(): boolean {
|
||||
function readChunkReloadGuardState(): ReloadGuardState {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'unavailable'
|
||||
}
|
||||
try {
|
||||
return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1'
|
||||
return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' ? 'attempted' : 'not-attempted'
|
||||
} 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
|
||||
// Why: when storage is blocked we cannot prove a reload happened, but still
|
||||
// fail closed on reloads so a broken chunk never loops.
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
function markChunkReloadAttempted(): void {
|
||||
function markChunkReloadAttempted(): boolean {
|
||||
try {
|
||||
window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
|
||||
return true
|
||||
} catch {
|
||||
// Best-effort; if writing throws, hasAttemptedChunkReload() also fails closed.
|
||||
// A reload without a durable guard can loop, so treat write failure as unavailable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +90,24 @@ const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(
|
||||
// down, so the error fallback never flashes in the moment before the reload lands.
|
||||
const SUSPEND_UNTIL_RELOAD = new Promise<never>(() => undefined)
|
||||
|
||||
function isKnownDynamicImportFailure(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (error.name === 'ChunkLoadError') {
|
||||
return true
|
||||
}
|
||||
|
||||
return [
|
||||
/failed to fetch dynamically imported module/i,
|
||||
/error loading dynamically imported module/i,
|
||||
/importing a module script failed/i,
|
||||
/failed to load module script/i,
|
||||
/loading chunk .+ failed/i
|
||||
].some((pattern) => pattern.test(error.message))
|
||||
}
|
||||
|
||||
export async function loadLazyWithRetry<T extends AnyComponent>(
|
||||
factory: LazyFactory<T>,
|
||||
options: LazyWithRetryOptions = {}
|
||||
@@ -91,8 +128,11 @@ export async function loadLazyWithRetry<T extends AnyComponent>(
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !hasAttemptedChunkReload()) {
|
||||
markChunkReloadAttempted()
|
||||
const reloadGuardState = readChunkReloadGuardState()
|
||||
if (typeof window !== 'undefined' && reloadGuardState === 'not-attempted') {
|
||||
if (!markChunkReloadAttempted()) {
|
||||
throw lastError
|
||||
}
|
||||
recordReloadBreadcrumb(
|
||||
options.reloadKey ?? 'unknown',
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
@@ -101,8 +141,12 @@ export async function loadLazyWithRetry<T extends AnyComponent>(
|
||||
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.
|
||||
if (reloadGuardState === 'attempted' && 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.
|
||||
throw lastError
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user