Skip to content

Commit e47bd42

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 e47bd42

5 files changed

Lines changed: 155 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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
beforeEach(() => jest.spyOn(console, 'error').mockImplementation(() => {}));
7+
afterEach(() => jest.restoreAllMocks());
8+
9+
function Bomb({error}: {error: Error}): null {
10+
throw error;
11+
}
12+
13+
describe('ChunkLoadErrorBoundary', () => {
14+
it('renders children when there is no error', () => {
15+
render(
16+
<ChunkLoadErrorBoundary>
17+
<span>hello</span>
18+
</ChunkLoadErrorBoundary>
19+
);
20+
expect(screen.getByText('hello')).toBeTruthy();
21+
});
22+
23+
it('catches chunk load errors and displays reload message', () => {
24+
const chunkError = Object.assign(new Error('Loading chunk 775 failed.'), {name: 'ChunkLoadError'});
25+
render(
26+
<ChunkLoadErrorBoundary>
27+
<Bomb error={chunkError} />
28+
</ChunkLoadErrorBoundary>
29+
);
30+
expect(screen.getByText(/Reloading/i)).toBeTruthy();
31+
});
32+
33+
it('re-throws non-chunk errors', () => {
34+
const genericError = new Error('Some other error');
35+
expect(() =>
36+
render(
37+
<ChunkLoadErrorBoundary>
38+
<Bomb error={genericError} />
39+
</ChunkLoadErrorBoundary>
40+
)
41+
).toThrow('Some other error');
42+
});
43+
});
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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as React from 'react';
2+
3+
import {lazyImport} from './lazy-import';
4+
5+
// Mock React component for testing
6+
const MockComponent: React.FC = () => null;
7+
8+
describe('lazyImport', () => {
9+
it('successfully imports a module', async () => {
10+
const mockImport = jest.fn().mockResolvedValue({default: MockComponent});
11+
const promise = lazyImport(mockImport, 2, 100);
12+
const result = await promise;
13+
expect(result.default).toBe(MockComponent);
14+
expect(mockImport).toHaveBeenCalled();
15+
});
16+
17+
it('rejects on import failure', async () => {
18+
const error = new Error('ChunkLoadError: Failed to load');
19+
const mockImport = jest.fn().mockRejectedValue(error);
20+
const promise = lazyImport(mockImport, 0, 0);
21+
await expect(promise).rejects.toThrow('ChunkLoadError');
22+
});
23+
});

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)