Skip to content

fix(web): recover from chunk-preload failures + hide debug stack in prod - #211

Merged
JustAGhosT merged 1 commit into
mainfrom
fix/chunk-preload-resilience
Jun 22, 2026
Merged

fix(web): recover from chunk-preload failures + hide debug stack in prod#211
JustAGhosT merged 1 commit into
mainfrom
fix/chunk-preload-resilience

Conversation

@JustAGhosT

@JustAGhosT JustAGhosT commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Why

A visitor hit a raw "Oops! / DEBUG INFO (share this with developer)" error screen on the live site at /portfolio/airkey:

Error: Unable to preload CSS for /assets/ProjectDetail-DcaeMC4r.css

Diagnosis: the asset is actually present and served 200 (text/css, 9778 bytes) and the live index.html references the exact index-Bj1QSY6L.js the 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 from React.lazy and the top-level ErrorBoundary then 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's vite:preloadError (and chunk-shaped unhandledrejections); preventDefault() and reload once for a fresh index.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

  • No behaviour change for genuine (non-chunk) errors except the debug stack is no longer dumped to end users in production.
  • Reloads are capped per session; a genuinely-missing asset can't trap a visitor in a reload loop.
  • Verified: changed files are type-clean (pre-existing repo tsc errors are all in unrelated theme/demo files) and ESLint-clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling during app updates by automatically recovering from chunk-load failures, reducing instances where users encounter error screens during deployment transitions.
    • Enhanced error boundary to gracefully suppress debug information in production while retaining it during development.
  • New Features

    • Added automatic recovery mechanism for transient chunk-load failures with intelligent retry logic.

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.
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new chunkErrorRecovery.ts utility with three exports for detecting chunk/CSS load failures, attempting a session-capped page reload, and resetting the reload counter. ErrorBoundary.tsx integrates these helpers to render null during a pending reload and hide debug output outside development. main.tsx installs global vite:preloadError and unhandledrejection handlers that use the same helpers, and resets the counter 5 seconds after app start.

Changes

Chunk-load Error Recovery

Layer / File(s) Summary
Chunk recovery utility: detection, reload, and counter reset
apps/web/src/utils/chunkErrorRecovery.ts
New module with isChunkLoadError (regex heuristic over error name/message), attemptChunkReload (reads/writes sessionStorage reload counter, reloads page, returns true/false), and resetChunkReloadCounter (removes the counter key).
ErrorBoundary chunk-recovery integration
apps/web/src/features/error/ErrorBoundary.tsx
Adds isReloading to State; getDerivedStateFromError sets it on chunk errors; componentDidCatch calls attemptChunkReload() and returns early or clears isReloading; render returns null while reloading; raw error details gated to import.meta.env.DEV.
Global window chunk-error handlers
apps/web/src/main.tsx
Registers vite:preloadError and unhandledrejection listeners that suppress default behavior and call attemptChunkReload(); schedules resetChunkReloadCounter after 5 seconds.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Hoppity-hop through the code I go,
Chunks that went stale? No more woe!
A counter in storage keeps reloads in check,
null while we wait — no flash, no wreck.
The boundary holds still, the page reloads free,
Bouncing back gracefully, just like me! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: recovering from chunk-preload failures and hiding debug stack in production.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/chunk-preload-resilience

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/main.tsx
Comment on lines +21 to +22
event.preventDefault();
attemptChunkReload();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +57 to +60
} catch {
// sessionStorage unavailable (private mode, etc.) — reload once anyway.
}
window.location.reload();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between dd14f0a and 50daf4c.

📒 Files selected for processing (3)
  • apps/web/src/features/error/ErrorBoundary.tsx
  • apps/web/src/main.tsx
  • apps/web/src/utils/chunkErrorRecovery.ts

Comment thread apps/web/src/main.tsx
Comment on lines +19 to +23
window.addEventListener("vite:preloadError", (event) => {
// Suppress Vite's default rethrow; we recover by reloading instead.
event.preventDefault();
attemptChunkReload();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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:


🏁 Script executed:

# First, let's find the attemptChunkReload function definition
rg "attemptChunkReload" -B 2 -A 10

Repository: 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.

Suggested change
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.

Comment on lines +51 to +60
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

@JustAGhosT
JustAGhosT merged commit 4f39132 into main Jun 22, 2026
2 checks passed
@JustAGhosT
JustAGhosT deleted the fix/chunk-preload-resilience branch June 22, 2026 20:29
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