Skip to content

Commit 7b441ad

Browse files
iHiDclaude
andauthored
Handle stale chunk errors by reloading page after deployments (#8421)
* Handle stale chunk errors by reloading the page after deployments When assets are redeployed with new content hashes, users with cached pages get TypeError when dynamically imported chunks (React.lazy) no longer exist on the CDN. This adds graceful recovery by detecting these errors and triggering a page reload with loop prevention. - Create chunk-load-error-handler utility with browser-variant detection (Chrome, Firefox, Safari all use different error messages) - Add ErrorBoundary fallback in react-bootloader to catch chunk errors in the React tree and reload instead of showing blank components - Add global unhandledrejection handler as safety net for failures outside the React error boundary tree - Fix Sentry beforeSend filter to match all browser error variants (was only matching Chrome's "Failed to fetch dynamically imported module") - Guard ErrorFallback auto-reset against chunk errors to prevent infinite retry loops Closes #8369 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add retry logic for dynamic imports to handle transient failures Old chunk files persist on the CDN, so import failures are transient (network blips, CDN hiccups) rather than missing files. Instead of immediately reloading the page, retry the import up to 3 times with a 1-second delay between attempts. - Create lazy-with-retry utility as drop-in replacement for React.lazy - Update all 6 files that use lazy() to import from the retry utility (129 lazy call sites, only import lines change) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Handle chunk load errors at source to prevent Sentry propagation After all retries are exhausted, if the error is a chunk load error, reload the page directly instead of throwing. This ensures the error never reaches Sentry.captureException via ErrorBoundary handlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6c45bf3 commit 7b441ad

10 files changed

Lines changed: 136 additions & 14 deletions

File tree

app/javascript/components/ErrorBoundary.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
} from 'react-error-boundary'
88
import { APIError } from './types'
99
import * as Sentry from '@sentry/react'
10+
import {
11+
isChunkLoadError,
12+
safeReloadForChunkError,
13+
} from '../utils/chunk-load-error-handler'
1014

1115
const ERROR_MESSAGE_TIMEOUT_IN_MS = 500
1216

@@ -60,10 +64,15 @@ export const ErrorFallback = ({
6064
resetErrorBoundary,
6165
}: FallbackProps): JSX.Element => {
6266
useEffect(() => {
67+
if (isChunkLoadError(error)) {
68+
safeReloadForChunkError()
69+
return
70+
}
71+
6372
const timer = setTimeout(resetErrorBoundary, ERROR_MESSAGE_TIMEOUT_IN_MS)
6473

6574
return () => clearTimeout(timer)
66-
}, [resetErrorBoundary])
75+
}, [error, resetErrorBoundary])
6776

6877
return (
6978
<div>

app/javascript/components/editor/FileEditorCodeMirror.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import React, {
44
useState,
55
useEffect,
66
createContext,
7-
lazy,
87
Suspense,
98
} from 'react'
9+
import { lazy } from '@/utils/lazy-with-retry'
1010
import { File } from '../types'
1111
import type { Handler } from '../misc/CodeMirror'
1212
import { Tab, TabContext } from '../common/Tab'

app/javascript/components/modals/BegModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// i18n-key-prefix: begModal
22
// i18n-namespace: components/modals/BegModal.tsx
3-
import React, { lazy, Suspense, useCallback, useState } from 'react'
3+
import React, { Suspense, useCallback, useState } from 'react'
4+
import { lazy } from '@/utils/lazy-with-retry'
45
import currency from 'currency.js'
56
import { PaymentIntentType } from '@/components/donations/stripe-form/useStripeForm'
67
import { Request } from '@/hooks/request-query'

app/javascript/components/modals/student/finish-mentor-discussion-modal/DonationStep.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { lazy, Suspense } from 'react'
1+
import React, { Suspense } from 'react'
2+
import { lazy } from '@/utils/lazy-with-retry'
23
import { MentoringSessionDonation } from '@/components/types'
34
import currency from 'currency.js'
45
import { DiscussionActionsLinks } from '@/components/student/mentoring-session/DiscussionActions'

app/javascript/packs/application.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import React, { lazy, Suspense } from 'react'
2+
import React, { Suspense } from 'react'
3+
import { lazy } from '@/utils/lazy-with-retry'
34
import { camelizeKeys } from 'humps'
45
import { camelizeKeysAs } from '@/utils/camelize-keys-as'
56
import { initReact } from '@/utils/react-bootloader'

app/javascript/packs/bootcamp-js.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react'
2-
import { lazy, Suspense } from 'react'
2+
import { Suspense } from 'react'
3+
import { lazy } from '@/utils/lazy-with-retry'
34
import { initReact } from '../utils/react-bootloader'
45
import '@hotwired/turbo-rails'
56
import { camelizeKeysAs } from '@/utils/camelize-keys-as'

app/javascript/packs/internal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
// Absolute, module imports
3-
import React, { Suspense, lazy } from 'react'
3+
import React, { Suspense } from 'react'
4+
import { lazy } from '@/utils/lazy-with-retry'
45
import { camelizeKeys } from 'humps'
56
import currency from 'currency.js'
67
import { initReact } from '@/utils/react-bootloader'
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const RELOAD_KEY = 'exercism:chunk-reload-timestamp'
2+
const RELOAD_COOLDOWN_MS = 10_000
3+
4+
/**
5+
* Detects dynamic import failures across all major browsers.
6+
*
7+
* Chrome/Edge: "Failed to fetch dynamically imported module: <url>"
8+
* Firefox: "error loading dynamically imported module: <url>"
9+
* Safari: "Importing a module script failed."
10+
*/
11+
export function isChunkLoadError(error: unknown): boolean {
12+
if (!(error instanceof Error)) return false
13+
const msg = error.message.toLowerCase()
14+
return (
15+
msg.includes('failed to fetch dynamically imported module') ||
16+
msg.includes('error loading dynamically imported module') ||
17+
msg.includes('importing a module script failed')
18+
)
19+
}
20+
21+
/**
22+
* Reloads the page to pick up new assets after a deployment,
23+
* with sessionStorage-based cooldown to prevent infinite reload loops.
24+
*/
25+
export function safeReloadForChunkError(): void {
26+
try {
27+
const lastReload = sessionStorage.getItem(RELOAD_KEY)
28+
const now = Date.now()
29+
30+
if (lastReload && now - parseInt(lastReload, 10) < RELOAD_COOLDOWN_MS) {
31+
return
32+
}
33+
34+
sessionStorage.setItem(RELOAD_KEY, String(now))
35+
} catch {
36+
// sessionStorage may be unavailable (private browsing, storage full).
37+
// Reload anyway — worst case is one extra reload.
38+
}
39+
40+
window.location.reload()
41+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react'
2+
import {
3+
isChunkLoadError,
4+
safeReloadForChunkError,
5+
} from './chunk-load-error-handler'
6+
7+
const MAX_RETRIES = 3
8+
const RETRY_DELAY_MS = 1000
9+
10+
/**
11+
* Drop-in replacement for React.lazy that retries the dynamic import
12+
* on transient failures (network blips, CDN hiccups, etc.).
13+
*/
14+
export function lazy<T extends React.ComponentType<any>>(
15+
factory: () => Promise<{ default: T }>
16+
): React.LazyExoticComponent<T> {
17+
return React.lazy(() => retryImport(factory))
18+
}
19+
20+
function retryImport<T extends React.ComponentType<any>>(
21+
factory: () => Promise<{ default: T }>,
22+
retriesLeft = MAX_RETRIES
23+
): Promise<{ default: T }> {
24+
return factory().catch((error) => {
25+
if (retriesLeft <= 0) {
26+
if (isChunkLoadError(error)) {
27+
safeReloadForChunkError()
28+
return new Promise<{ default: T }>(() => {})
29+
}
30+
throw error
31+
}
32+
33+
return new Promise<{ default: T }>((resolve) =>
34+
setTimeout(
35+
() => resolve(retryImport(factory, retriesLeft - 1)),
36+
RETRY_DELAY_MS
37+
)
38+
)
39+
})
40+
}

app/javascript/utils/react-bootloader.tsx

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client'
33
import * as Sentry from '@sentry/react'
44
import { ExercismTippy } from '../components/misc/ExercismTippy'
55
import { QueryClientProvider } from '@tanstack/react-query'
6+
import {
7+
isChunkLoadError,
8+
safeReloadForChunkError,
9+
} from './chunk-load-error-handler'
610

711
type ErrorBoundaryType = React.ComponentType<any>
812

@@ -26,12 +30,19 @@ if (process.env.SENTRY_DSN) {
2630
enabled: process.env.NODE_ENV === 'production',
2731
sendDefaultPii: false,
2832
beforeSend: (event) => {
29-
// Drop non-actionable dynamic import failures (network issues, stale chunks)
30-
const isDynamicImportError = event.exception?.values?.some(
31-
(ex) =>
32-
ex.value?.includes('Failed to fetch dynamically imported module') ||
33-
ex.value?.includes('Importing a module script failed')
34-
)
33+
// Drop non-actionable dynamic import failures (network issues, stale chunks).
34+
// Error messages vary by browser:
35+
// Chrome/Edge: "Failed to fetch dynamically imported module: <url>"
36+
// Firefox: "error loading dynamically imported module: <url>"
37+
// Safari: "Importing a module script failed."
38+
const isDynamicImportError = event.exception?.values?.some((ex) => {
39+
const msg = ex.value?.toLowerCase() || ''
40+
return (
41+
msg.includes('failed to fetch dynamically imported module') ||
42+
msg.includes('error loading dynamically imported module') ||
43+
msg.includes('importing a module script failed')
44+
)
45+
})
3546
if (isDynamicImportError) return null
3647

3748
// Drop non-actionable Cloudflare Turnstile widget errors (browser extensions, privacy settings, etc.)
@@ -124,6 +135,22 @@ if (process.env.SENTRY_DSN) {
124135
ErrorBoundary = Sentry.ErrorBoundary
125136
}
126137

138+
// Reload the page when a dynamic import fails due to stale chunks after deployment.
139+
// This catches failures that occur outside React's error boundary tree.
140+
window.addEventListener('unhandledrejection', (event) => {
141+
if (isChunkLoadError(event.reason)) {
142+
event.preventDefault()
143+
safeReloadForChunkError()
144+
}
145+
})
146+
147+
const chunkErrorFallback: Sentry.FallbackRender = ({ error }) => {
148+
if (isChunkLoadError(error)) {
149+
safeReloadForChunkError()
150+
}
151+
return <></>
152+
}
153+
127154
// Asynchronously appends a stylesheet to the head and resolves
128155
// the promise when it's finished loading.
129156
let loadStylesheet = function (url) {
@@ -314,7 +341,7 @@ const render = (elem: HTMLElement, component: React.ReactNode) => {
314341
root.render(
315342
<React.StrictMode>
316343
<QueryClientProvider client={window.queryClient}>
317-
<ErrorBoundary>{component}</ErrorBoundary>
344+
<ErrorBoundary fallback={chunkErrorFallback}>{component}</ErrorBoundary>
318345
</QueryClientProvider>
319346
</React.StrictMode>
320347
)

0 commit comments

Comments
 (0)