Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions tests/ui/helpers/chunkError.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Unit tests for the chunkError helper module.
*
* These helpers back the ChunkErrorBoundary, which recovers from failures to
* load a lazily-imported route bundle. After a deploy, old content-hashed
* chunks are deleted, so a tab still running the pre-deploy runtime gets a
* ChunkLoadError when it navigates to a not-yet-loaded route. This module
* decides whether such an error should trigger a one-time reload (to pick up
* the current assets) without getting stuck in a reload loop.
*
* This suite covers:
* - isChunkLoadError: classifying chunk-load failures vs. ordinary errors
* - reloadOnChunkError: the reload-with-cooldown decision logic
*/

import {
isChunkLoadError,
reloadOnChunkError,
} from '../../../ui/helpers/chunkError';

const chunkError = (message, name = 'ChunkLoadError') => {
const error = new Error(message);
error.name = name;
return error;
};

describe('chunkError helper', () => {
describe('isChunkLoadError', () => {
it('returns true for an error named ChunkLoadError', () => {
expect(isChunkLoadError(chunkError('boom'))).toBe(true);
});

it('returns true for a webpack/rspack "Loading chunk N failed" message', () => {
const error = chunkError('Loading chunk 437 failed.', 'Error');
expect(isChunkLoadError(error)).toBe(true);
});

it('returns true for a "Loading CSS chunk" failure message', () => {
const error = chunkError('Loading CSS chunk 12 failed.', 'Error');
expect(isChunkLoadError(error)).toBe(true);
});

it('returns true for a dynamically-imported-module fetch failure', () => {
const error = chunkError(
'Failed to fetch dynamically imported module: https://th/assets/logviewer.abcd1234.js',
'TypeError',
);
expect(isChunkLoadError(error)).toBe(true);
});

it('returns false for an ordinary application error', () => {
expect(isChunkLoadError(new Error('Cannot read properties of undefined'))).toBe(
false,
);
});

it('returns false for null/undefined', () => {
expect(isChunkLoadError(null)).toBe(false);
expect(isChunkLoadError(undefined)).toBe(false);
});
});

describe('reloadOnChunkError', () => {
let storage;
let location;

beforeEach(() => {
const store = {};
storage = {
getItem: (key) => (key in store ? store[key] : null),
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: (key) => {
delete store[key];
},
};
location = { reload: jest.fn() };
});

it('reloads once when a chunk error is seen for the first time', () => {
const reloaded = reloadOnChunkError(chunkError('Loading chunk 1 failed.'), {
storage,
location,
now: 1000,
});

expect(reloaded).toBe(true);
expect(location.reload).toHaveBeenCalledTimes(1);
});

it('does not reload again for a repeat chunk error within the cooldown window', () => {
// First failure triggers a reload and records the timestamp.
reloadOnChunkError(chunkError('Loading chunk 1 failed.'), {
storage,
location,
now: 1000,
});
location.reload.mockClear();

// The reloaded page immediately fails again (e.g. a corrupt immutable
// cache entry that a normal reload cannot bust). We must NOT loop.
const reloaded = reloadOnChunkError(chunkError('Loading chunk 1 failed.'), {
storage,
location,
now: 3000,
});

expect(reloaded).toBe(false);
expect(location.reload).not.toHaveBeenCalled();
});

it('reloads again once the cooldown has elapsed (e.g. a later deploy)', () => {
reloadOnChunkError(chunkError('Loading chunk 1 failed.'), {
storage,
location,
now: 1000,
});
location.reload.mockClear();

const reloaded = reloadOnChunkError(chunkError('Loading chunk 9 failed.'), {
storage,
location,
now: 1000 + 60 * 1000,
});

expect(reloaded).toBe(true);
expect(location.reload).toHaveBeenCalledTimes(1);
});

it('never reloads for a non-chunk error', () => {
const reloaded = reloadOnChunkError(new Error('regular bug'), {
storage,
location,
now: 1000,
});

expect(reloaded).toBe(false);
expect(location.reload).not.toHaveBeenCalled();
});
});
});
104 changes: 104 additions & 0 deletions tests/ui/shared/ChunkErrorBoundary.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Unit tests for the ChunkErrorBoundary component.
*
* The boundary wraps the lazily-loaded routes in ui/App.jsx. When loading a
* route bundle fails (a stale/removed chunk after a deploy, or a corrupt
* cached asset), it should attempt a one-time recovery reload instead of
* leaving the user on an infinite Suspense spinner, and fall back to a
* readable message when a reload cannot fix it.
*
* This suite covers:
* - Rendering children when there is no error
* - Triggering a recovery reload on a chunk-load error
* - Showing a recoverable message when the reload is suppressed (cooldown)
* - Showing a generic fallback for ordinary (non-chunk) errors
*/

import { render, screen } from '@testing-library/react';

import ChunkErrorBoundary from '../../../ui/shared/ChunkErrorBoundary';
import { reloadOnChunkError } from '../../../ui/helpers/chunkError';

jest.mock('../../../ui/helpers/chunkError', () => {
const actual = jest.requireActual('../../../ui/helpers/chunkError');
return {
__esModule: true,
...actual,
reloadOnChunkError: jest.fn(),
};
});

const chunkLoadError = () => {
const error = new Error('Loading chunk 437 failed.');
error.name = 'ChunkLoadError';
return error;
};

const Bomb = ({ error }) => {
throw error;
};

describe('ChunkErrorBoundary', () => {
let consoleErrorSpy;

beforeEach(() => {
reloadOnChunkError.mockReset();
// React logs caught render errors to console.error; keep test output clean.
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
consoleErrorSpy.mockRestore();
});

it('renders its children when nothing throws', () => {
render(
<ChunkErrorBoundary>
<div>healthy content</div>
</ChunkErrorBoundary>,
);

expect(screen.getByText('healthy content')).toBeInTheDocument();
expect(reloadOnChunkError).not.toHaveBeenCalled();
});

it('attempts a recovery reload when a chunk fails to load', () => {
reloadOnChunkError.mockReturnValue(true);
const error = chunkLoadError();

render(
<ChunkErrorBoundary>
<Bomb error={error} />
</ChunkErrorBoundary>,
);

expect(reloadOnChunkError).toHaveBeenCalledWith(error);
expect(screen.getByText(/reloading/i)).toBeInTheDocument();
});

it('shows a recoverable message when a reload cannot fix the chunk error', () => {
reloadOnChunkError.mockReturnValue(false);

render(
<ChunkErrorBoundary>
<Bomb error={chunkLoadError()} />
</ChunkErrorBoundary>,
);

expect(reloadOnChunkError).toHaveBeenCalled();
expect(screen.getByText(/refresh/i)).toBeInTheDocument();
});

it('shows a generic fallback for an ordinary error without reloading', () => {
reloadOnChunkError.mockReturnValue(false);

render(
<ChunkErrorBoundary>
<Bomb error={new Error('something unrelated broke')} />
</ChunkErrorBoundary>,
);

expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
expect(screen.queryByText(/reloading/i)).not.toBeInTheDocument();
});
});
11 changes: 7 additions & 4 deletions ui/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from 'react-router';
import { permaLinkPrefix } from './perfherder/perf-helpers/constants';
import LoadingSpinner from './shared/LoadingSpinner';
import ChunkErrorBoundary from './shared/ChunkErrorBoundary';
import LoginCallback from './login-callback/LoginCallback';
import TaskclusterCallback from './taskcluster-auth-callback/TaskclusterCallback';
import UserGuideApp from './userguide/App';
Expand Down Expand Up @@ -125,8 +126,9 @@ const UrlUpdater = ({ children }) => {
const AppRoutes = () => {
return (
<UrlUpdater>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<ChunkErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/login" element={<LoginCallback />} />
<Route path="/taskcluster-auth" element={<TaskclusterCallback />} />
<Route
Expand Down Expand Up @@ -171,8 +173,9 @@ const AppRoutes = () => {
/>
<Route path="/docs/*" element={<RedocApp />} />
<Route path="*" element={<Navigate to="/jobs" replace />} />
</Routes>
</Suspense>
</Routes>
</Suspense>
</ChunkErrorBoundary>
</UrlUpdater>
);
};
Expand Down
63 changes: 63 additions & 0 deletions ui/helpers/chunkError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Helpers for recovering from failures to load a lazily-imported route bundle.
//
// The UI code-splits each route (see `lazy(() => import(...))` in ui/App.jsx).
// Bundles are content-hashed and old ones are removed on every deploy, so a
// long-lived tab running a pre-deploy runtime can request a chunk URL that no
// longer exists and fail with a ChunkLoadError. Reloading fetches the current
// (always no-store) HTML shell and the matching assets. We reload at most once
// per cooldown window so a failure that a reload cannot fix (e.g. a corrupt
// immutable cache entry) surfaces an error instead of looping forever.

const RELOAD_TIMESTAMP_KEY = 'thChunkReloadAt';
const RELOAD_COOLDOWN_MS = 10 * 1000;

export const isChunkLoadError = function isChunkLoadError(error) {
if (!error) {
return false;
}

const name = error.name || '';
const message = error.message || '';

return (
name === 'ChunkLoadError' ||
/Loading (CSS )?chunk [\d]+ failed/i.test(message) ||
/(failed to fetch|error loading) dynamically imported module/i.test(message)
);
};

export const reloadOnChunkError = function reloadOnChunkError(
error,
{
storage = window.sessionStorage,
location = window.location,
now = Date.now(),
} = {},
) {
if (!isChunkLoadError(error)) {
return false;
}

let lastReloadAt = null;
try {
const stored = parseInt(storage.getItem(RELOAD_TIMESTAMP_KEY), 10);
lastReloadAt = Number.isNaN(stored) ? null : stored;
} catch (_storageError) {
lastReloadAt = null;
}

// A failure within the cooldown means the reload we already triggered did not
// fix it; don't loop. A missing timestamp means we have not reloaded yet.
if (lastReloadAt !== null && now - lastReloadAt < RELOAD_COOLDOWN_MS) {
return false;
}

try {
storage.setItem(RELOAD_TIMESTAMP_KEY, String(now));
} catch (_storageError) {
// sessionStorage can be unavailable (private mode quotas); reload anyway.
}

location.reload();
return true;
};
Loading