fix(web): recover from chunk-preload failures + hide debug stack in prod - #211
Conversation
Dynamic-import (JS/CSS chunk) loads can fail transiently — a network blip, a CDN edge miss, or a client holding a stale index.html across a deploy that rotated asset hashes. These surfaced as a raw "DEBUG INFO" error screen on the public site (e.g. /portfolio/airkey: "Unable to preload CSS for ..."). - Add a vite:preloadError + unhandledrejection handler in main.tsx that reloads once for a fresh index.html, capped per session to avoid loops. - ErrorBoundary treats chunk-load errors as recoverable (reload) and renders the raw error/stack only in dev — never to end users in production. - Shared helper in utils/chunkErrorRecovery.ts.
📝 WalkthroughWalkthroughAdds a new ChangesChunk-load Error Recovery
Sequence DiagramsequenceDiagram
participant Browser
participant main.tsx
participant ErrorBoundary
participant chunkErrorRecovery
participant sessionStorage
rect rgba(255, 165, 0, 0.5)
note over Browser,main.tsx: Path A — Global handler (vite:preloadError / unhandledrejection)
Browser->>main.tsx: vite:preloadError or unhandledrejection
main.tsx->>chunkErrorRecovery: isChunkLoadError(error)
chunkErrorRecovery-->>main.tsx: true
main.tsx->>chunkErrorRecovery: attemptChunkReload()
chunkErrorRecovery->>sessionStorage: check/increment counter
chunkErrorRecovery->>Browser: location.reload()
end
rect rgba(100, 149, 237, 0.5)
note over Browser,ErrorBoundary: Path B — React render error caught by ErrorBoundary
Browser->>ErrorBoundary: getDerivedStateFromError(chunkError)
ErrorBoundary->>chunkErrorRecovery: isChunkLoadError(error)
chunkErrorRecovery-->>ErrorBoundary: true → isReloading=true
ErrorBoundary->>ErrorBoundary: render() returns null
ErrorBoundary->>chunkErrorRecovery: attemptChunkReload()
chunkErrorRecovery->>sessionStorage: check/increment counter
chunkErrorRecovery->>Browser: location.reload()
end
rect rgba(144, 238, 144, 0.5)
note over main.tsx,sessionStorage: After successful load
main.tsx->>chunkErrorRecovery: resetChunkReloadCounter() (after 5s)
chunkErrorRecovery->>sessionStorage: removeItem(reloadCount)
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: private package registry requires authentication. Disable ESLint in CodeRabbit settings or use public packages. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 50daf4c67d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| event.preventDefault(); | ||
| attemptChunkReload(); |
There was a problem hiding this comment.
Only suppress preload errors when reloading
For a visitor whose chunk still fails after the two automatic reloads, attemptChunkReload() returns false, but this handler has already called event.preventDefault(). Vite treats preventDefault() on vite:preloadError as handling the import error and does not throw it, so the route's ErrorBoundary may never get the original chunk failure and the user can be left on the loader/invalid lazy result instead of the graceful fallback. Move preventDefault() into the branch where a reload is actually triggered.
Useful? React with 👍 / 👎.
| } catch { | ||
| // sessionStorage unavailable (private mode, etc.) — reload once anyway. | ||
| } | ||
| window.location.reload(); |
There was a problem hiding this comment.
Preserve the reload cap without sessionStorage
When sessionStorage.setItem throws (for example with storage disabled or in a sandboxed/private context), the count is never persisted, so getReloadCount() returns 0 again after every reload and a permanently missing chunk can loop through window.location.reload() indefinitely. Since this path is explicitly handled as “reload once anyway,” use a reload marker that survives the reload or skip the auto-reload if the counter cannot be recorded.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/main.tsx`:
- Around line 19-23: In the `vite:preloadError` event listener, move the
`preventDefault()` call to only execute conditionally based on the result of
`attemptChunkReload()`. Check the return value of `attemptChunkReload()` first,
and only call `preventDefault()` if the reload was successfully triggered or is
still possible. This ensures the error is only suppressed when actual recovery
is occurring, matching the pattern already used in the `unhandledrejection`
handler below.
In `@apps/web/src/utils/chunkErrorRecovery.ts`:
- Around line 51-60: The attemptChunkReload function bypasses the reload cap
when sessionStorage is unavailable because the catch block silently fails to
persist the reload counter and still executes window.location.reload(). To fix
this, implement a fallback counter mechanism that persists the reload count even
when sessionStorage is unavailable (such as using URL parameters, a query string
flag, or another cross-reload storage method). Update the getReloadCount
function to check this fallback mechanism when sessionStorage is inaccessible,
and modify the catch block to increment the fallback counter before reloading,
ensuring the MAX_AUTO_RELOADS cap is enforced consistently regardless of storage
availability.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 45685f90-ebbe-4686-b1bc-71929ea56cae
📒 Files selected for processing (3)
apps/web/src/features/error/ErrorBoundary.tsxapps/web/src/main.tsxapps/web/src/utils/chunkErrorRecovery.ts
| window.addEventListener("vite:preloadError", (event) => { | ||
| // Suppress Vite's default rethrow; we recover by reloading instead. | ||
| event.preventDefault(); | ||
| attemptChunkReload(); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Vite v5, does calling preventDefault() in a "vite:preloadError" listener suppress the default throw/rejection path?
💡 Result:
Yes, calling event.preventDefault in a vite:preloadError event listener suppresses the default throw/rejection path [1][2][3]. When Vite fails to load a dynamic import—often occurring when a user attempts to load a chunk from a previous deployment that has since been removed by the server—it emits a vite:preloadError event [2]. The internal handler for this event checks the event's state; if event.defaultPrevented is true (indicating that preventDefault was called), Vite skips the default behavior of throwing the error, effectively allowing the application to handle the failure manually (e.g., by performing a page reload to fetch the latest assets) [1][2][3]. Technical implementation details: The vite:preloadError event is dispatched with the cancelable property set to true [3][4]. The internal logic follows this pattern [3]: window.dispatchEvent(e); if (!e.defaultPrevented) { throw err; } This ensures that the error is only re-thrown if the event was not suppressed by the listener [3][4]. While this mechanism provides an escape hatch, care should be taken in implementation, as failing to handle or resolve the underlying issue after suppression can sometimes lead to subsequent errors depending on the state of the application [5][6].
Citations:
- 1: feat: emit event to handle chunk load errors vitejs/vite#12084
- 2: https://vitejs-vite.mintlify.app/concepts/build-production
- 3: https://github.com/vitejs/vite/blob/130ef31b/packages/vite/src/node/plugins/importAnalysisBuild.ts
- 4: vitejs/vite@2eca54e
- 5: failed CSS preloads prevent loading dependencies vitejs/vite#18042
- 6: fix(preload): throw error preloading module as well vitejs/vite#18098
🏁 Script executed:
# First, let's find the attemptChunkReload function definition
rg "attemptChunkReload" -B 2 -A 10Repository: phoenixvc/phoenix-website
Length of output: 4772
Only suppress vite:preloadError when a reload is actually triggered.
On line 21, preventDefault() runs before checking whether attemptChunkReload() can still reload. When the cap is already reached, this handler still suppresses the error path but does not recover. The unhandledrejection handler below correctly checks the return value before calling preventDefault(); apply the same pattern here.
Suggested fix
window.addEventListener("vite:preloadError", (event) => {
- // Suppress Vite's default rethrow; we recover by reloading instead.
- event.preventDefault();
- attemptChunkReload();
+ // Suppress only when we are actually recovering via reload.
+ if (attemptChunkReload()) {
+ event.preventDefault();
+ }
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| window.addEventListener("vite:preloadError", (event) => { | |
| // Suppress Vite's default rethrow; we recover by reloading instead. | |
| event.preventDefault(); | |
| attemptChunkReload(); | |
| }); | |
| window.addEventListener("vite:preloadError", (event) => { | |
| // Suppress only when we are actually recovering via reload. | |
| if (attemptChunkReload()) { | |
| event.preventDefault(); | |
| } | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/main.tsx` around lines 19 - 23, In the `vite:preloadError` event
listener, move the `preventDefault()` call to only execute conditionally based
on the result of `attemptChunkReload()`. Check the return value of
`attemptChunkReload()` first, and only call `preventDefault()` if the reload was
successfully triggered or is still possible. This ensures the error is only
suppressed when actual recovery is occurring, matching the pattern already used
in the `unhandledrejection` handler below.
| export function attemptChunkReload(): boolean { | ||
| if (getReloadCount() >= MAX_AUTO_RELOADS) { | ||
| return false; | ||
| } | ||
| try { | ||
| sessionStorage.setItem(RELOAD_COUNT_KEY, String(getReloadCount() + 1)); | ||
| } catch { | ||
| // sessionStorage unavailable (private mode, etc.) — reload once anyway. | ||
| } | ||
| window.location.reload(); |
There was a problem hiding this comment.
Reload cap is bypassed when sessionStorage is unavailable.
On Line 55-Line 60, the catch path still reloads but does not persist any counter, so users with unavailable storage can get stuck in repeated reloads for genuinely missing assets.
💡 Suggested fix (add a cross-reload fallback counter)
const RELOAD_COUNT_KEY = "chunk-reload-count";
const MAX_AUTO_RELOADS = 2;
+const WINDOW_NAME_RELOAD_PREFIX = "__chunk-reload-count__:";
+function getWindowNameReloadCount(): number {
+ const raw = window.name;
+ if (!raw.startsWith(WINDOW_NAME_RELOAD_PREFIX)) return 0;
+ return Number(raw.slice(WINDOW_NAME_RELOAD_PREFIX.length)) || 0;
+}
+
+function setWindowNameReloadCount(count: number): void {
+ window.name = `${WINDOW_NAME_RELOAD_PREFIX}${count}`;
+}
+
export function attemptChunkReload(): boolean {
if (getReloadCount() >= MAX_AUTO_RELOADS) {
return false;
}
try {
sessionStorage.setItem(RELOAD_COUNT_KEY, String(getReloadCount() + 1));
} catch {
- // sessionStorage unavailable (private mode, etc.) — reload once anyway.
+ const fallbackCount = getWindowNameReloadCount();
+ if (fallbackCount >= MAX_AUTO_RELOADS) {
+ return false;
+ }
+ setWindowNameReloadCount(fallbackCount + 1);
}
window.location.reload();
return true;
}
export function resetChunkReloadCounter(): void {
try {
sessionStorage.removeItem(RELOAD_COUNT_KEY);
} catch {
// ignore — nothing to reset
}
+ if (window.name.startsWith(WINDOW_NAME_RELOAD_PREFIX)) {
+ window.name = "";
+ }
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/utils/chunkErrorRecovery.ts` around lines 51 - 60, The
attemptChunkReload function bypasses the reload cap when sessionStorage is
unavailable because the catch block silently fails to persist the reload counter
and still executes window.location.reload(). To fix this, implement a fallback
counter mechanism that persists the reload count even when sessionStorage is
unavailable (such as using URL parameters, a query string flag, or another
cross-reload storage method). Update the getReloadCount function to check this
fallback mechanism when sessionStorage is inaccessible, and modify the catch
block to increment the fallback counter before reloading, ensuring the
MAX_AUTO_RELOADS cap is enforced consistently regardless of storage
availability.
Why
A visitor hit a raw "Oops! / DEBUG INFO (share this with developer)" error screen on the live site at
/portfolio/airkey:Diagnosis: the asset is actually present and served 200 (
text/css, 9778 bytes) and the liveindex.htmlreferences the exactindex-Bj1QSY6L.jsthe browser loaded — so this is not a missing asset or stale-index problem. It's a transient dynamic-import failure (network blip / CDN edge miss / a tab held open across the recent deploy that rotated asset hashes). React surfaces it fromReact.lazyand the top-levelErrorBoundarythen shows a developer debug screen to end users.What
utils/chunkErrorRecovery.ts(new) — shared helper:isChunkLoadError,attemptChunkReload(session-capped at 2 reloads to avoid loops),resetChunkReloadCounter.main.tsx— listen for Vite'svite:preloadError(and chunk-shapedunhandledrejections);preventDefault()and reload once for a freshindex.html. Reset the cap after a successful 5s load so a later deploy can self-heal again.ErrorBoundary.tsx— treat chunk-load errors as recoverable (reload instead of error screen), and render the raw error message + stack only in dev (import.meta.env.DEV). Production users see the friendly message + Go Home / Try Again only.Notes
tscerrors are all in unrelated theme/demo files) and ESLint-clean.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features