Skip to content

Commit cefb75a

Browse files
authored
Merge pull request #11714 from neinteractiveliterature/boot-error-resilience
Catch boot/chunk-load failures instead of leaving a blank login page
2 parents f7936af + 8963da1 commit cefb75a

3 files changed

Lines changed: 161 additions & 6 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/* eslint-disable i18next/no-literal-string -- This boundary is the last line of
2+
defense for a broken boot; it must not depend on i18n (which may be exactly
3+
what failed to load), so its copy is intentionally hard-coded English. */
4+
import { Component, ErrorInfo, ReactNode, CSSProperties } from 'react';
5+
import { reloadOnAppEntrypointHeadersMismatch } from './checkAppEntrypointHeadersMatch';
6+
import errorReporting from 'ErrorReporting';
7+
8+
// Guard against an auto-reload loop: if we reloaded very recently, show the
9+
// manual fallback instead of reloading again.
10+
const RELOAD_GUARD_KEY = 'intercode.bootErrorReloadAt';
11+
const RELOAD_GUARD_WINDOW_MS = 15_000;
12+
13+
function reloadedWithinGuardWindow(): boolean {
14+
try {
15+
const last = Number(sessionStorage.getItem(RELOAD_GUARD_KEY));
16+
return Number.isFinite(last) && last > 0 && Date.now() - last < RELOAD_GUARD_WINDOW_MS;
17+
} catch {
18+
return false;
19+
}
20+
}
21+
22+
function markReloadAttempt(): void {
23+
try {
24+
sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now()));
25+
} catch {
26+
// sessionStorage may be unavailable (private mode / blocked); best effort.
27+
}
28+
}
29+
30+
const containerStyle: CSSProperties = {
31+
maxWidth: '32rem',
32+
margin: '4rem auto',
33+
padding: '0 1rem',
34+
fontFamily: 'system-ui, sans-serif',
35+
textAlign: 'center',
36+
lineHeight: 1.5,
37+
};
38+
39+
const buttonStyle: CSSProperties = {
40+
marginTop: '1rem',
41+
padding: '0.5rem 1.25rem',
42+
fontSize: '1rem',
43+
cursor: 'pointer',
44+
};
45+
46+
function BootErrorFallback() {
47+
return (
48+
<div style={containerStyle} role="alert">
49+
<h1 style={{ fontSize: '1.25rem' }}>We couldn’t finish loading this page</h1>
50+
<p>This is usually temporary and fixed by reloading. If it keeps happening, try again in a few minutes.</p>
51+
<button type="button" style={buttonStyle} onClick={() => window.location.reload()}>
52+
Reload the page
53+
</button>
54+
</div>
55+
);
56+
}
57+
58+
type Props = { children: ReactNode };
59+
type State = { hasError: boolean };
60+
61+
// Catches failures during the SPA's initial boot/render — a rejected
62+
// bootstrapPromise, or an uncaught error rendering the route tree — that would
63+
// otherwise leave a blank white page with no recovery. A stale deploy (a chunk
64+
// hash that no longer exists) is the most common cause, so we try to reload
65+
// onto the fresh bundle; anything else gets a visible, reportable error instead
66+
// of a blank screen.
67+
export default class BootErrorBoundary extends Component<Props, State> {
68+
state: State = { hasError: false };
69+
70+
static getDerivedStateFromError(): State {
71+
return { hasError: true };
72+
}
73+
74+
componentDidCatch(error: Error, info: ErrorInfo): void {
75+
try {
76+
errorReporting().error(error, { tags: { context: 'boot' }, componentStack: info.componentStack });
77+
} catch {
78+
// Error reporting itself may be unavailable (e.g. its SDK was the chunk
79+
// that failed to load); don't let that mask the fallback UI.
80+
}
81+
82+
// Very often a blank boot is a stale deploy. If the deployed entrypoint has
83+
// changed under this tab, reload to pick up the fresh bundle — but only if
84+
// we didn't just try, so we never get stuck in a reload loop.
85+
if (!reloadedWithinGuardWindow()) {
86+
markReloadAttempt();
87+
void reloadOnAppEntrypointHeadersMismatch();
88+
}
89+
}
90+
91+
render(): ReactNode {
92+
if (this.state.hasError) {
93+
return <BootErrorFallback />;
94+
}
95+
return this.props.children;
96+
}
97+
}

app/javascript/packs/application.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import 'regenerator-runtime/runtime';
33
import mountReactComponents from '../mountReactComponents';
44
import { StrictMode, Suspense, use, useLayoutEffect, useMemo, useState } from 'react';
55
import AuthenticityTokensManager, { getAuthenticityTokensURL } from 'AuthenticityTokensContext';
6-
import { createBrowserRouter, RouterContextProvider, RouterProvider } from 'react-router';
6+
import { createBrowserRouter, RouterContextProvider, RouterProvider, RouteObject } from 'react-router';
77
import { buildBrowserApolloClient, GraphQLNotAuthenticatedErrorEvent } from 'useIntercodeApolloClient';
88
import {
99
apolloClientContext,
@@ -18,6 +18,8 @@ import { AuthenticationManager, AuthenticationManagerContext } from '../Authenti
1818
import { PageLoadingIndicator } from '@neinteractiveliterature/litform';
1919
import { ApolloClient } from '@apollo/client';
2020
import { initErrorReporting } from 'ErrorReporting';
21+
import { checkAppEntrypointHeadersOnError } from '../checkAppEntrypointHeadersMatch';
22+
import BootErrorBoundary from '../BootErrorBoundary';
2123

2224
type Bootstrap = {
2325
clientConfiguration: ClientConfiguration;
@@ -118,19 +120,37 @@ function RouterLoadingOverlay({ router }: { router: BrowserRouter }) {
118120
);
119121
}
120122

123+
// Wrap every route's lazy import so a failed chunk load — almost always a stale
124+
// deploy whose hashed chunk no longer exists — reloads onto the fresh bundle
125+
// instead of surfacing as an unrecoverable blank. Applied once to the whole tree.
126+
function withEntrypointReloadOnLazy(routes: RouteObject[]): RouteObject[] {
127+
return routes.map((route) => {
128+
let { lazy } = route;
129+
if (typeof lazy === 'function') {
130+
const load = lazy;
131+
lazy = () => checkAppEntrypointHeadersOnError(load);
132+
}
133+
return {
134+
...route,
135+
lazy,
136+
children: route.children ? withEntrypointReloadOnLazy(route.children) : route.children,
137+
} as RouteObject;
138+
});
139+
}
140+
121141
function DataModeApplicationEntry() {
122142
const bootstrap = use(bootstrapPromise);
123143

124144
const router = useMemo(
125145
() =>
126146
createBrowserRouter(
127-
[
147+
withEntrypointReloadOnLazy([
128148
{
129149
id: 'root',
130150
lazy: () => import('root'),
131151
children: appRootRoutes,
132152
},
133-
],
153+
]),
134154
{
135155
getContext: () => {
136156
const context = new RouterContextProvider();
@@ -158,9 +178,11 @@ function DataModeApplicationEntry() {
158178

159179
function DataModeApplicationEntrySuspenseWrapper() {
160180
return (
161-
<Suspense fallback={<PageLoadingIndicator visible />}>
162-
<DataModeApplicationEntry />
163-
</Suspense>
181+
<BootErrorBoundary>
182+
<Suspense fallback={<PageLoadingIndicator visible />}>
183+
<DataModeApplicationEntry />
184+
</Suspense>
185+
</BootErrorBoundary>
164186
);
165187
}
166188

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { vi } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import BootErrorBoundary from '../../app/javascript/BootErrorBoundary';
4+
5+
function Boom(): never {
6+
throw new Error('boom');
7+
}
8+
9+
describe('BootErrorBoundary', () => {
10+
it('renders its children when nothing throws', () => {
11+
const { getByText } = render(
12+
<BootErrorBoundary>
13+
<div>app content</div>
14+
</BootErrorBoundary>,
15+
);
16+
17+
expect(getByText('app content')).toBeTruthy();
18+
});
19+
20+
it('renders a reload fallback instead of a blank page when a child throws', () => {
21+
// React logs caught boundary errors to console.error; silence it for this case.
22+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
23+
try {
24+
const { getByRole, queryByText } = render(
25+
<BootErrorBoundary>
26+
<Boom />
27+
</BootErrorBoundary>,
28+
);
29+
30+
expect(getByRole('button', { name: /reload/i })).toBeTruthy();
31+
expect(queryByText('app content')).toBeNull();
32+
} finally {
33+
consoleError.mockRestore();
34+
}
35+
});
36+
});

0 commit comments

Comments
 (0)