Skip to content

Catch boot/chunk-load failures instead of leaving a blank login page#11714

Merged
nbudin merged 1 commit into
mainfrom
boot-error-resilience
Jun 16, 2026
Merged

Catch boot/chunk-load failures instead of leaving a blank login page#11714
nbudin merged 1 commit into
mainfrom
boot-error-resilience

Conversation

@nbudin

@nbudin nbudin commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Purpose

Follow-up to the login work in #11693 — and a response to the round of Summer Larpin' "blank page on login" reports.

After a lot of digging this round, those blanks turned out to be boot-level failures that happen before React renders the route tree — so the useLoginRequired spinner fix (#11693, login-required routes) can't catch them, and the page goes fully blank with no recovery. I couldn't reproduce them in a clean browser (clean prod renders fine, even with Sentry/Rollbar/fonts blocked), which is itself the tell: they need real-user conditions — most commonly a stale deploy ("I did a lot of cacheless refreshing and it eventually worked" is the textbook signature), and for some users an adblocker or odd cache/session state. They're also invisible in Sentry (the failing chunk can be the Sentry SDK itself; adblockers block ingest; boot crashes precede SDK init), so the fix has to degrade gracefully on its own rather than rely on telemetry.

Two gaps made any such failure unrecoverable, and this fixes both:

  1. use(bootstrapPromise) had no error boundary — a rejected bootstrap or any uncaught render error blanked the whole app.
  2. Route lazy: () => import(...) imports weren't wrapped for stale-bundle reload, so a chunk that 404s after a deploy blanked with no auto-reload.

The goal isn't to pin down every individual trigger (the adblocker case still wants an on/off HAR) — it's to turn the whole class of login blanks into either a silent auto-reload (stale deploy) or a visible, reportable "reload" page.

Changes

💻 Engineer-facing

  • BootErrorBoundary wraps the app mount. On a caught boot/render error it reports to ErrorReporting, calls reloadOnAppEntrypointHeadersMismatch() to reload onto a fresh bundle when the deployed entrypoint changed (guarded by a sessionStorage timestamp so it can't loop), and otherwise renders a dependency-free "couldn't finish loading — reload" fallback. Copy is hard-coded English on purpose — i18n may be exactly what failed to load.
  • withEntrypointReloadOnLazy wraps every route's lazy import with the existing checkAppEntrypointHeadersOnError, so a stale-deploy chunk 404 reloads onto the fresh bundle instead of blanking. Non-chunk route errors are unaffected.
  • Reuses the existing checkAppEntrypointHeadersMatch.ts helpers; no new reload machinery.

Risks

Low. The boundary only renders on a thrown error; the lazy wrapping only changes behavior on a failed import (reload-if-stale, else reject as before). The reload-loop guard prevents repeated auto-reloads. Worst case is the fallback "reload" page instead of a blank — strictly better than today.

Testing

tsc --noEmit, eslint, and the existing useLoginRequired / openid tests pass. New BootErrorBoundary.test.tsx covers rendering children normally and rendering the reload fallback (not a blank) when a child throws.

Release plan and notes

🚢 — and this also makes future boot failures visible and reportable instead of silent white screens, so we stop flying blind. Still want an adblocker-on/off HAR to nail Brian's specific case as a follow-up.

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
app/javascript/BootErrorBoundary.tsx 🔴 0% 🟢 90.91% 🟢 90.91%
app/javascript/BuiltInFormControls/AddFileModal.tsx 🔴 33.33% 🔴 0% 🔴 -33.33%
app/javascript/BuiltInFormControls/LiquidInput.tsx 🟠 60% 🟠 57.5% 🔴 -2.5%
app/javascript/checkAppEntrypointHeadersMatch.ts 🔴 8.7% 🔴 30.43% 🟢 21.73%
Overall Coverage 🟢 54.02% 🟢 54.02% ⚪ 0%

Minimum allowed coverage is 0%, this run produced 54.02%

@nbudin nbudin marked this pull request as ready for review June 16, 2026 17:51
@nbudin nbudin merged commit cefb75a into main Jun 16, 2026
24 checks passed
@nbudin nbudin deleted the boot-error-resilience branch June 16, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant