Skip to content

Commit 8963da1

Browse files
nbudinclaude
andcommitted
Catch boot/chunk-load failures instead of leaving a blank page
The white-screen-on-login reports are boot-level failures that happen before React renders the route tree, so the useLoginRequired spinner fix can't catch them and the page goes fully blank with no recovery. Two gaps made any such failure unrecoverable: use(bootstrapPromise) had no error boundary, and route lazy() imports weren't wrapped for stale-bundle reload (so a chunk that 404s after a deploy — "cacheless refresh fixes it" — blanked with no auto-reload). - BootErrorBoundary wraps the app mount: on a caught boot/render error it reports to ErrorReporting, reloads onto the fresh bundle when the deployed entrypoint changed (reloadOnAppEntrypointHeadersMismatch, guarded by a sessionStorage timestamp so it can't loop), and otherwise renders a dependency-free "couldn't finish loading — reload" fallback instead of a blank page. The copy is hard-coded English on purpose: i18n may be exactly what failed to load. - withEntrypointReloadOnLazy wraps every route's lazy import with checkAppEntrypointHeadersOnError, so a stale-deploy chunk 404 reloads onto the fresh bundle instead of surfacing as an unrecoverable blank. This turns the whole class of login blanks — stale bundle, adblocker, odd state — into a silent auto-reload or a visible, reportable error. It doesn't pinpoint each trigger (the adblocker case still wants a HAR), but it makes them non-fatal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d694684 commit 8963da1

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)