Skip to content

Commit b64d750

Browse files
committed
Recover lazy chunk failures after frontend deploys
1 parent 89dd75f commit b64d750

6 files changed

Lines changed: 257 additions & 22 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import '@testing-library/jest-dom'
2+
3+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
4+
5+
import * as chunkLoadErrorRecovery from 'lib/utils/chunkLoadErrorRecovery'
6+
7+
import { initKeaTests } from '~/test/init'
8+
9+
import { ErrorBoundary } from './ErrorBoundary'
10+
11+
function ThrowChunkLoadError(): never {
12+
throw new Error('Failed to fetch dynamically imported module: /static/chunk.js')
13+
}
14+
15+
describe('ErrorBoundary', () => {
16+
let consoleErrorSpy: jest.SpyInstance
17+
let reloadAfterChunkLoadErrorSpy: jest.SpyInstance
18+
19+
beforeEach(() => {
20+
initKeaTests()
21+
chunkLoadErrorRecovery.clearChunkLoadReloadAttempt()
22+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
23+
reloadAfterChunkLoadErrorSpy = jest
24+
.spyOn(chunkLoadErrorRecovery, 'reloadAfterChunkLoadError')
25+
.mockImplementation(() => undefined)
26+
})
27+
28+
afterEach(() => {
29+
cleanup()
30+
consoleErrorSpy.mockRestore()
31+
reloadAfterChunkLoadErrorSpy.mockRestore()
32+
})
33+
34+
it('reloads once for a chunk load error before showing the generic fallback', async () => {
35+
render(
36+
<ErrorBoundary>
37+
<ThrowChunkLoadError />
38+
</ErrorBoundary>
39+
)
40+
41+
expect(await screen.findByText('Refreshing…')).toBeInTheDocument()
42+
43+
await waitFor(() => {
44+
expect(reloadAfterChunkLoadErrorSpy).toHaveBeenCalledTimes(1)
45+
})
46+
47+
expect(screen.queryByText('An error has occurred')).not.toBeInTheDocument()
48+
})
49+
50+
it('shows the network error state after a recent reload attempt', async () => {
51+
chunkLoadErrorRecovery.markChunkLoadReloadAttempt()
52+
53+
render(
54+
<ErrorBoundary>
55+
<ThrowChunkLoadError />
56+
</ErrorBoundary>
57+
)
58+
59+
expect(await screen.findByText('Network error')).toBeInTheDocument()
60+
expect(screen.getByText('There was an issue loading the requested resource.')).toBeInTheDocument()
61+
expect(reloadAfterChunkLoadErrorSpy).not.toHaveBeenCalled()
62+
expect(screen.queryByText('An error has occurred')).not.toBeInTheDocument()
63+
})
64+
})

frontend/src/layout/ErrorBoundary/ErrorBoundary.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ import './ErrorBoundary.scss'
22

33
import clsx from 'clsx'
44
import { useActions, useValues } from 'kea'
5+
import { useEffect } from 'react'
56

67
import { IconCopy } from '@posthog/icons'
78
import { PostHogErrorBoundary, type PostHogErrorBoundaryFallbackProps } from '@posthog/react'
89

910
import { SupportTicketExceptionEvent, supportLogic } from 'lib/components/Support/supportLogic'
1011
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
1112
import { LemonButton } from 'lib/lemon-ui/LemonButton'
13+
import { getChunkLoadRecoveryAction, reloadAfterChunkLoadError } from 'lib/utils/chunkLoadErrorRecovery'
1214
import { copyToClipboard } from 'lib/utils/copyToClipboard'
1315
import { teamLogic } from 'scenes/teamLogic'
1416

17+
import { ErrorNetwork } from '../ErrorNetwork'
18+
1519
const DOM_MUTATION_PATTERNS = [
1620
"Failed to execute 'removeChild' on 'Node'",
1721
"Failed to execute 'insertBefore' on 'Node'",
@@ -29,6 +33,36 @@ interface ErrorBoundaryProps {
2933
className?: string
3034
}
3135

36+
function ChunkLoadErrorFallback({
37+
className,
38+
recoveryAction,
39+
}: {
40+
className?: string
41+
recoveryAction: 'reload' | 'show-error'
42+
}): JSX.Element {
43+
useEffect(() => {
44+
if (recoveryAction === 'reload') {
45+
console.error('App assets regenerated in a lazily loaded component. Reloading this page.')
46+
reloadAfterChunkLoadError()
47+
}
48+
}, [recoveryAction])
49+
50+
if (recoveryAction === 'show-error') {
51+
return (
52+
<div className={clsx('ErrorBoundary', className)}>
53+
<ErrorNetwork />
54+
</div>
55+
)
56+
}
57+
58+
return (
59+
<div className={clsx('ErrorBoundary', className)}>
60+
<h2>Refreshing…</h2>
61+
<p>PostHog just updated. Reloading the page now.</p>
62+
</div>
63+
)
64+
}
65+
3266
export function ErrorBoundary({ children, exceptionProps = {}, className }: ErrorBoundaryProps): JSX.Element {
3367
const { currentTeamId } = useValues(teamLogic)
3468
const { openSupportForm } = useActions(supportLogic)
@@ -53,6 +87,7 @@ export function ErrorBoundary({ children, exceptionProps = {}, className }: Erro
5387
const exceptionEvent = props.exceptionEvent as SupportTicketExceptionEvent
5488

5589
const isBrowserExtensionError = isDOMModificationError(normalizedError)
90+
const chunkLoadRecoveryAction = getChunkLoadRecoveryAction(normalizedError)
5691

5792
const errorDetails = [
5893
exceptionEvent?.uuid ? `Exception ID: ${exceptionEvent.uuid}` : null,
@@ -61,6 +96,10 @@ export function ErrorBoundary({ children, exceptionProps = {}, className }: Erro
6196
.filter(Boolean)
6297
.join('\n\n')
6398

99+
if (chunkLoadRecoveryAction !== 'ignore') {
100+
return <ChunkLoadErrorFallback className={className} recoveryAction={chunkLoadRecoveryAction} />
101+
}
102+
64103
return (
65104
<div className={clsx('ErrorBoundary', className)}>
66105
<h2>An error has occurred</h2>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
CHUNK_LOAD_RELOAD_WINDOW_MS,
3+
clearChunkLoadReloadAttempt,
4+
getChunkLoadRecoveryAction,
5+
isChunkLoadError,
6+
markChunkLoadReloadAttempt,
7+
} from './chunkLoadErrorRecovery'
8+
9+
describe('chunkLoadErrorRecovery', () => {
10+
beforeEach(() => {
11+
sessionStorage.clear()
12+
clearChunkLoadReloadAttempt()
13+
})
14+
15+
it('detects webpack chunk load errors', () => {
16+
const error = new Error('Loading chunk 123 failed.')
17+
error.name = 'ChunkLoadError'
18+
19+
expect(isChunkLoadError(error)).toBe(true)
20+
})
21+
22+
it('detects esbuild dynamic import failures', () => {
23+
expect(
24+
isChunkLoadError(new Error('Failed to fetch dynamically imported module: /static/chunk-123.js'))
25+
).toBe(true)
26+
})
27+
28+
it('ignores unrelated errors', () => {
29+
expect(isChunkLoadError(new Error('TypeError: cannot read properties of undefined'))).toBe(false)
30+
expect(getChunkLoadRecoveryAction(new Error('TypeError: cannot read properties of undefined'))).toBe('ignore')
31+
})
32+
33+
it('reloads on the first chunk load failure', () => {
34+
expect(getChunkLoadRecoveryAction(new Error('Failed to fetch dynamically imported module: /static/chunk.js'))).toBe(
35+
'reload'
36+
)
37+
})
38+
39+
it('shows an error after a recent reload attempt', () => {
40+
const now = 200_000
41+
markChunkLoadReloadAttempt(now)
42+
43+
expect(
44+
getChunkLoadRecoveryAction(
45+
new Error('Failed to fetch dynamically imported module: /static/chunk.js'),
46+
now + CHUNK_LOAD_RELOAD_WINDOW_MS - 1
47+
)
48+
).toBe('show-error')
49+
})
50+
51+
it('allows another reload after the cooldown window passes', () => {
52+
const now = 200_000
53+
markChunkLoadReloadAttempt(now)
54+
55+
expect(
56+
getChunkLoadRecoveryAction(
57+
new Error('Failed to fetch dynamically imported module: /static/chunk.js'),
58+
now + CHUNK_LOAD_RELOAD_WINDOW_MS + 1
59+
)
60+
).toBe('reload')
61+
})
62+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const CHUNK_LOAD_RELOAD_MARKER_KEY = 'posthog:chunk-load-reload-at'
2+
export const CHUNK_LOAD_RELOAD_WINDOW_MS = 20_000
3+
4+
export type ChunkLoadRecoveryAction = 'ignore' | 'reload' | 'show-error'
5+
6+
function getSessionStorage(): Storage | null {
7+
if (typeof window === 'undefined') {
8+
return null
9+
}
10+
11+
try {
12+
return window.sessionStorage
13+
} catch {
14+
return null
15+
}
16+
}
17+
18+
function getChunkLoadReloadMarker(): number | null {
19+
const marker = getSessionStorage()?.getItem(CHUNK_LOAD_RELOAD_MARKER_KEY)
20+
if (!marker) {
21+
return null
22+
}
23+
24+
const parsed = Number(marker)
25+
return Number.isFinite(parsed) ? parsed : null
26+
}
27+
28+
export function isChunkLoadError(error: unknown): boolean {
29+
if (!(error instanceof Error)) {
30+
return false
31+
}
32+
33+
return error.name === 'ChunkLoadError' || error.message.includes('Failed to fetch dynamically imported module')
34+
}
35+
36+
export function getChunkLoadRecoveryAction(error: unknown, now = Date.now()): ChunkLoadRecoveryAction {
37+
if (!isChunkLoadError(error)) {
38+
return 'ignore'
39+
}
40+
41+
const lastReloadAt = getChunkLoadReloadMarker()
42+
return lastReloadAt && lastReloadAt > now - CHUNK_LOAD_RELOAD_WINDOW_MS ? 'show-error' : 'reload'
43+
}
44+
45+
export function markChunkLoadReloadAttempt(now = Date.now()): void {
46+
getSessionStorage()?.setItem(CHUNK_LOAD_RELOAD_MARKER_KEY, String(now))
47+
}
48+
49+
export function reloadAfterChunkLoadError(now = Date.now()): void {
50+
markChunkLoadReloadAttempt(now)
51+
window.location.reload()
52+
}
53+
54+
export function clearChunkLoadReloadAttempt(): void {
55+
getSessionStorage()?.removeItem(CHUNK_LOAD_RELOAD_MARKER_KEY)
56+
}

frontend/src/scenes/sceneLogic.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { router } from 'kea-router'
33
import { expectLogic, partial, truth } from 'kea-test-utils'
44

55
import api from 'lib/api'
6+
import * as chunkLoadErrorRecovery from 'lib/utils/chunkLoadErrorRecovery'
67
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
78
import { Scene } from 'scenes/sceneTypes'
89
import { teamLogic } from 'scenes/teamLogic'
@@ -50,6 +51,11 @@ describe('sceneLogic', () => {
5051
await expectLogic(logic).delay(1)
5152
})
5253

54+
afterEach(() => {
55+
logic.unmount()
56+
featureFlagLogic.unmount()
57+
})
58+
5359
it('has preloaded some scenes', async () => {
5460
const preloadedScenes = [Scene.Error404, Scene.ErrorNetwork, Scene.ErrorProjectUnavailable]
5561
await expectLogic(logic).toMatchValues({
@@ -60,6 +66,32 @@ describe('sceneLogic', () => {
6066
})
6167
})
6268

69+
it('routes to the network error scene after a repeated lazy import failure', async () => {
70+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
71+
72+
try {
73+
logic.unmount()
74+
chunkLoadErrorRecovery.markChunkLoadReloadAttempt()
75+
76+
const brokenScenes = {
77+
...testScenes,
78+
[Scene.DataManagement]: async () => {
79+
throw new Error('Failed to fetch dynamically imported module: /static/chunk.js')
80+
},
81+
}
82+
83+
logic = sceneLogic.build({ scenes: brokenScenes })
84+
logic.cache.tabsLoaded = false
85+
logic.mount()
86+
87+
await expectLogic(logic).delay(1)
88+
89+
expect(logic.values.sceneId).toBe(Scene.ErrorNetwork)
90+
} finally {
91+
consoleErrorSpy.mockRestore()
92+
}
93+
})
94+
6395
it('changing URL runs openScene, loadScene and setScene', async () => {
6496
await expectLogic(logic).toDispatchActions(['openScene', 'loadScene', 'setScene']).toMatchValues({
6597
sceneId: Scene.DataManagement,

frontend/src/scenes/sceneLogic.tsx

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FEATURE_FLAGS, TeamMembershipLevel } from 'lib/constants'
1111
import { trackFileSystemLogView } from 'lib/hooks/useFileSystemLogView'
1212
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
1313
import { Spinner } from 'lib/lemon-ui/Spinner'
14+
import { getChunkLoadRecoveryAction, isChunkLoadError, reloadAfterChunkLoadError } from 'lib/utils/chunkLoadErrorRecovery'
1415
import { getRelativeNextPath, identifierToHuman } from 'lib/utils'
1516
import { getAppContext, getCurrentTeamIdOrNone } from 'lib/utils/getAppContext'
1617
import { NEW_INTERNAL_TAB } from 'lib/utils/newInternalTab'
@@ -396,8 +397,6 @@ export const sceneLogic = kea<sceneLogicType>([
396397
tabId,
397398
params,
398399
}),
399-
reloadBrowserDueToImportError: true,
400-
401400
newTab: (
402401
href?: string | null,
403402
options?: { activate?: boolean; skipNavigate?: boolean; id?: string; source?: TabOpenSource }
@@ -640,13 +639,6 @@ export const sceneLogic = kea<sceneLogicType>([
640639
setScene: () => null,
641640
},
642641
],
643-
lastReloadAt: [
644-
null as number | null,
645-
{ persist: true },
646-
{
647-
reloadBrowserDueToImportError: () => new Date().valueOf(),
648-
},
649-
],
650642
lastSetScenePayload: [
651643
{} as Record<string, any>,
652644
{
@@ -1294,20 +1286,13 @@ export const sceneLogic = kea<sceneLogicType>([
12941286
window.ESBUILD_LOAD_CHUNKS?.(sceneId)
12951287
importedScene = await props.scenes[sceneId]()
12961288
} catch (error: any) {
1297-
if (
1298-
error.name === 'ChunkLoadError' || // webpack
1299-
error.message?.includes('Failed to fetch dynamically imported module') // esbuild
1300-
) {
1301-
// Reloaded once in the last 20 seconds and now reloading again? Show network error
1302-
if (
1303-
values.lastReloadAt &&
1304-
parseInt(String(values.lastReloadAt)) > new Date().valueOf() - 20000
1305-
) {
1289+
if (isChunkLoadError(error)) {
1290+
if (getChunkLoadRecoveryAction(error) === 'show-error') {
13061291
console.error('App assets regenerated. Showing error page.')
13071292
actions.setScene(Scene.ErrorNetwork, undefined, tabId, emptySceneParams, clickedLink)
13081293
} else {
13091294
console.error('App assets regenerated. Reloading this page.')
1310-
actions.reloadBrowserDueToImportError()
1295+
reloadAfterChunkLoadError()
13111296
}
13121297
return
13131298
}
@@ -1345,9 +1330,6 @@ export const sceneLogic = kea<sceneLogicType>([
13451330
}
13461331
actions.setScene(sceneId, sceneKey, tabId, params, clickedLink || wasNotLoaded, exportedScene)
13471332
},
1348-
reloadBrowserDueToImportError: () => {
1349-
window.location.reload()
1350-
},
13511333
})),
13521334

13531335
// keep this above subscriptions

0 commit comments

Comments
 (0)