Skip to content

Commit 23c2d7a

Browse files
committed
fix: handle chunk loading failures with retry logic and error boundary
Signed-off-by: Nancy <9d.24.nancy.sangani@gmail.com>
1 parent 51ea54b commit 23c2d7a

5 files changed

Lines changed: 206 additions & 3 deletions

File tree

ui/src/app.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as React from 'react';
66

77
import 'argo-ui/src/styles/main.scss';
88

9+
import {ChunkLoadErrorBoundary} from './app/chunk-load-error-boundary';
910
import {AppRouter} from './app-router';
1011
import {ContextApis, Provider} from './shared/context';
1112

@@ -22,8 +23,10 @@ export function App({history}: {history: History}) {
2223
};
2324

2425
return (
25-
<Provider value={providerContext}>
26-
<AppRouter history={history} notificationsManager={notificationsManager} popupManager={popupManager} />
27-
</Provider>
26+
<ChunkLoadErrorBoundary>
27+
<Provider value={providerContext}>
28+
<AppRouter history={history} notificationsManager={notificationsManager} popupManager={popupManager} />
29+
</Provider>
30+
</ChunkLoadErrorBoundary>
2831
);
2932
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {render, screen} from '@testing-library/react';
2+
import * as React from 'react';
3+
4+
import {ChunkLoadErrorBoundary} from './chunk-load-error-boundary';
5+
6+
// Suppress React's console.error for expected thrown errors in tests.
7+
beforeEach(() => jest.spyOn(console, 'error').mockImplementation(() => {}));
8+
afterEach(() => jest.restoreAllMocks());
9+
10+
function Bomb({error}: {error: Error}) {
11+
throw error;
12+
return null; // unreachable; satisfies TS
13+
}
14+
15+
describe('ChunkLoadErrorBoundary', () => {
16+
const reloadSpy = jest.fn();
17+
18+
beforeEach(() => {
19+
Object.defineProperty(window, 'location', {
20+
writable: true,
21+
value: {reload: reloadSpy}
22+
});
23+
reloadSpy.mockClear();
24+
});
25+
26+
it('renders children when there is no error', () => {
27+
render(
28+
<ChunkLoadErrorBoundary>
29+
<span>hello</span>
30+
</ChunkLoadErrorBoundary>
31+
);
32+
expect(screen.getByText('hello')).toBeTruthy();
33+
});
34+
35+
it('triggers window.location.reload on a ChunkLoadError', () => {
36+
const chunkError = Object.assign(new Error('Loading chunk 775 failed.'), {name: 'ChunkLoadError'});
37+
render(
38+
<ChunkLoadErrorBoundary>
39+
<Bomb error={chunkError} />
40+
</ChunkLoadErrorBoundary>
41+
);
42+
expect(reloadSpy).toHaveBeenCalledTimes(1);
43+
expect(screen.getByText(/Reloading/i)).toBeTruthy();
44+
});
45+
46+
it('re-throws non-chunk errors for parent boundaries to handle', () => {
47+
const genericError = new Error('Some other error');
48+
expect(() =>
49+
render(
50+
<ChunkLoadErrorBoundary>
51+
<Bomb error={genericError} />
52+
</ChunkLoadErrorBoundary>
53+
)
54+
).toThrow('Some other error');
55+
expect(reloadSpy).not.toHaveBeenCalled();
56+
});
57+
});
58+
59+
describe('ChunkLoadErrorBoundary.isChunkLoadError', () => {
60+
it.each([
61+
[Object.assign(new Error('Loading chunk 775 failed.'), {name: 'ChunkLoadError'}), true],
62+
[new Error('Loading chunk 42 failed.'), true],
63+
[new Error('Loading CSS chunk 3 failed.'), true],
64+
[new Error('Something unrelated'), false],
65+
[new Error('TypeError: Cannot read property'), false]
66+
])('correctly classifies %s as %s', (error, expected) => {
67+
expect(ChunkLoadErrorBoundary.isChunkLoadError(error)).toBe(expected);
68+
});
69+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {ErrorInfo} from 'react';
2+
import * as React from 'react';
3+
4+
interface State {
5+
error?: Error & {response?: any};
6+
errorInfo?: ErrorInfo;
7+
isReloading: boolean;
8+
}
9+
10+
/**
11+
* Error boundary specifically for handling chunk loading failures.
12+
* Automatically reloads the page when a chunk fails to load.
13+
* Fixes: https://github.com/argoproj/argo-workflows/issues/15640
14+
*/
15+
export class ChunkLoadErrorBoundary extends React.Component<any, State> {
16+
static isChunkLoadError(error: Error): boolean {
17+
return (
18+
error.message.includes('Loading chunk') ||
19+
error.message.includes('Failed to fetch') ||
20+
error.message.includes('Failed to import') ||
21+
error.message.includes('NetworkError') ||
22+
error.name === 'ChunkLoadError'
23+
);
24+
}
25+
26+
static getDerivedStateFromError(error: Error) {
27+
// Only handle chunk load errors; re-throw others
28+
if (ChunkLoadErrorBoundary.isChunkLoadError(error)) {
29+
return {error, isReloading: true};
30+
}
31+
throw error;
32+
}
33+
34+
constructor(props: any) {
35+
super(props);
36+
this.state = {isReloading: false};
37+
}
38+
39+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
40+
console.error('Chunk load error:', error, errorInfo);
41+
// Auto-reload after a brief delay to allow state update
42+
setTimeout(() => window.location.reload(), 100);
43+
}
44+
45+
render() {
46+
if (this.state.isReloading) {
47+
return (
48+
<div style={{padding: '20px', textAlign: 'center'}}>
49+
<h2>Reloading...</h2>
50+
<p>A required component failed to load. The page will reload automatically.</p>
51+
</div>
52+
);
53+
}
54+
55+
return this.props.children;
56+
}
57+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {lazyImport} from './lazy-import';
2+
3+
// Mock React component for testing
4+
const MockComponent = () => null;
5+
6+
// Helper to create a mock import function that fails `failCount` times then succeeds.
7+
function makeMockImport(failCount: number, result = {default: MockComponent}) {
8+
let calls = 0;
9+
return jest.fn(async () => {
10+
calls++;
11+
if (calls <= failCount) {
12+
throw new Error(`ChunkLoadError: Loading chunk ${calls} failed.`);
13+
}
14+
return result;
15+
});
16+
}
17+
18+
// Speed up timers in tests
19+
jest.useFakeTimers();
20+
21+
describe('lazyImport', () => {
22+
afterEach(() => jest.clearAllMocks());
23+
24+
it('returns the module immediately when the import succeeds on first try', async () => {
25+
const mockImport = makeMockImport(0);
26+
const promise = lazyImport(mockImport, 2, 0);
27+
jest.runAllTimers();
28+
await expect(promise).resolves.toEqual({default: MockComponent});
29+
expect(mockImport).toHaveBeenCalledTimes(1);
30+
});
31+
32+
it('retries and succeeds when the import fails once', async () => {
33+
const mockImport = makeMockImport(1);
34+
const promise = lazyImport(mockImport, 2, 0);
35+
jest.runAllTimers();
36+
await expect(promise).resolves.toEqual({default: MockComponent});
37+
expect(mockImport).toHaveBeenCalledTimes(2);
38+
});
39+
40+
it('retries up to the configured limit and then throws', async () => {
41+
const mockImport = makeMockImport(5); // always fails
42+
const promise = lazyImport(mockImport, 2, 0);
43+
jest.runAllTimers();
44+
await expect(promise).rejects.toThrow('ChunkLoadError');
45+
// 1 initial attempt + 2 retries = 3 total calls
46+
expect(mockImport).toHaveBeenCalledTimes(3);
47+
});
48+
});

ui/src/shared/utils/lazy-import.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {ComponentType} from 'react';
2+
3+
/**
4+
* Lazy import wrapper that retries loading chunks on failure.
5+
* Fixes "Loading chunk X failed" errors by attempting to reload the chunk.
6+
* See: https://github.com/argoproj/argo-workflows/issues/15640
7+
*/
8+
export function lazyImport<T extends ComponentType<any>>(
9+
importFunc: () => Promise<{default: T}>,
10+
retries: number = 3,
11+
delayMs: number = 1000
12+
): Promise<{default: T}> {
13+
let attemptCount = 0;
14+
15+
const attempt = (): Promise<{default: T}> => {
16+
attemptCount++;
17+
return importFunc().catch(error => {
18+
if (attemptCount >= retries + 1) {
19+
throw error;
20+
}
21+
return new Promise(resolve => setTimeout(() => resolve(attempt()), delayMs * attemptCount));
22+
});
23+
};
24+
25+
return attempt();
26+
}

0 commit comments

Comments
 (0)