From 7a501a12a7398128afde8d98ba6108e103eee7bf Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Sat, 20 Jun 2026 06:50:49 -0700 Subject: [PATCH] Fix suspense lazy load on deploy --- tests/ui/helpers/chunkError.test.js | 142 ++++++++++++++++++++ tests/ui/shared/ChunkErrorBoundary.test.jsx | 104 ++++++++++++++ ui/App.jsx | 11 +- ui/helpers/chunkError.js | 63 +++++++++ ui/shared/ChunkErrorBoundary.jsx | 79 +++++++++++ 5 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 tests/ui/helpers/chunkError.test.js create mode 100644 tests/ui/shared/ChunkErrorBoundary.test.jsx create mode 100644 ui/helpers/chunkError.js create mode 100644 ui/shared/ChunkErrorBoundary.jsx diff --git a/tests/ui/helpers/chunkError.test.js b/tests/ui/helpers/chunkError.test.js new file mode 100644 index 00000000000..821ae6c6b5e --- /dev/null +++ b/tests/ui/helpers/chunkError.test.js @@ -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(); + }); + }); +}); diff --git a/tests/ui/shared/ChunkErrorBoundary.test.jsx b/tests/ui/shared/ChunkErrorBoundary.test.jsx new file mode 100644 index 00000000000..6e79d90e700 --- /dev/null +++ b/tests/ui/shared/ChunkErrorBoundary.test.jsx @@ -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( + +
healthy content
+
, + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(reloadOnChunkError).toHaveBeenCalled(); + expect(screen.getByText(/refresh/i)).toBeInTheDocument(); + }); + + it('shows a generic fallback for an ordinary error without reloading', () => { + reloadOnChunkError.mockReturnValue(false); + + render( + + + , + ); + + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + expect(screen.queryByText(/reloading/i)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/App.jsx b/ui/App.jsx index fe272e4a0fe..db688a109d1 100644 --- a/ui/App.jsx +++ b/ui/App.jsx @@ -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'; @@ -125,8 +126,9 @@ const UrlUpdater = ({ children }) => { const AppRoutes = () => { return ( - }> - + + }> + } /> } /> { /> } /> } /> - - + + + ); }; diff --git a/ui/helpers/chunkError.js b/ui/helpers/chunkError.js new file mode 100644 index 00000000000..33f81b80522 --- /dev/null +++ b/ui/helpers/chunkError.js @@ -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; +}; diff --git a/ui/shared/ChunkErrorBoundary.jsx b/ui/shared/ChunkErrorBoundary.jsx new file mode 100644 index 00000000000..95162aae423 --- /dev/null +++ b/ui/shared/ChunkErrorBoundary.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { isChunkLoadError, reloadOnChunkError } from '../helpers/chunkError'; + +import LoadingSpinner from './LoadingSpinner'; + +/** + * Error boundary for the lazily-loaded routes in App.jsx. + * + * `` only handles the pending state of a dynamic import; it does not + * catch a rejected import. Without this boundary a failure to load a route + * bundle (a stale chunk removed by a deploy, or a corrupt cached asset) leaves + * the user stuck on the loading spinner. Here we attempt a one-time recovery + * reload to pick up the current assets, and otherwise show a readable message + * instead of spinning forever. + */ +export default class ChunkErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, isChunkError: false, reloadTriggered: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, isChunkError: isChunkLoadError(error) }; + } + + componentDidCatch(error) { + // For a chunk-load failure, try to reload onto the current assets. The + // helper reloads at most once per cooldown so it can't loop on a failure a + // reload won't fix. + const reloadTriggered = reloadOnChunkError(error); + if (reloadTriggered) { + this.setState({ reloadTriggered: true }); + } + } + + render() { + const { children = null } = this.props; + const { hasError, isChunkError, reloadTriggered } = this.state; + + if (!hasError) { + return children; + } + + if (isChunkError && reloadTriggered) { + // A reload is in flight; show the spinner until the page navigates away. + return ( +
+ +

Reloading to load the latest version…

+
+ ); + } + + if (isChunkError) { + return ( +
+

Part of the app failed to load.

+

+ Please refresh the page. If the problem persists, do a hard refresh + (Cmd/Ctrl+Shift+R) to clear cached files. +

+
+ ); + } + + return ( +
+

Something went wrong.

+

Please refresh the page to try again.

+
+ ); + } +} + +ChunkErrorBoundary.propTypes = { + children: PropTypes.node, +};