feat(dev): React-based runtime error overlay#1029
Conversation
2894719 to
69db0ab
Compare
commit: |
|
15edc47 to
be3922f
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Good design overall — separate React root so the overlay survives the main tree tearing down, useSyncExternalStore-driven store, DevRecoveryBoundary to keep BrowserRoot alive for HMR. The architecture is sound.
However, CI is red: 4 existing E2E tests fail because the vinext dev overlay's backdrop intercepts pointer events on top of the error boundary UI those tests are trying to click. The tests in error-interactive.spec.ts were updated with the #__vinext_dev_error_overlay_root CSS hiding workaround, but the same treatment wasn't applied to error-nav.spec.ts, layout-persistence.spec.ts, or navigation-flows.spec.ts. These are the exact 4 tests failing in CI. This must be fixed before merge.
See inline comments for all issues found.
| @@ -1,5 +1,8 @@ | |||
| import { reportDevError } from "./dev-error-overlay.js"; | |||
There was a problem hiding this comment.
Prod bundle concern: unconditional import of dev-only module
app-browser-error.ts is imported in both dev and prod (the prod createOnUncaughtError path is used unconditionally). This top-level import { reportDevError } from "./dev-error-overlay.js" pulls the entire overlay module (React component tree, 622 lines of JSX, inline CSS, createRoot import, etc.) into the production client bundle.
reportDevError is only called from devOnCaughtError and devOnUncaughtError, which are only used behind import.meta.env.DEV checks in app-browser-entry.ts. Vite/Rolldown will tree-shake the call sites away in prod, but whether it successfully tree-shakes the imported module itself depends on side-effect analysis. Since dev-error-overlay.tsx has module-level let reactRoot, let installed, and const style objects, the bundler may conservatively keep the module.
Suggestion: gate the import so it's structurally unreachable in prod:
// At the call sites in devOnCaughtError / devOnUncaughtError:
const { reportDevError } = await import("./dev-error-overlay.js");Or move devOnCaughtError and devOnUncaughtError into dev-error-overlay.tsx itself so the entire file is only imported behind import.meta.env.DEV guards in app-browser-entry.ts.
|
|
||
| const FONT_STACK = | ||
| "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; | ||
| const FONT_STACK_LITERAL = FONT_STACK; |
There was a problem hiding this comment.
FONT_STACK_LITERAL is just assigned FONT_STACK and used in exactly one place (the template literal on line 429). This alias serves no purpose — just use FONT_STACK directly. If the intent was to avoid interpolating into the CSS string, it doesn't achieve that since it's still interpolated via ${FONT_STACK_LITERAL}.
|
|
||
| return ( | ||
| <div style={backdropStyle} onClick={onMinimize} data-testid="vinext-dev-error-backdrop"> | ||
| <style>{overlayStylesheet}</style> |
There was a problem hiding this comment.
The <style> tag containing overlayStylesheet is rendered twice — once here in DevErrorOverlay and once in DevErrorIndicator (line 154). Since the overlay app only renders one or the other at a time, this isn't a correctness issue, but:
- If both were somehow mounted (e.g., a future change), you'd have duplicate
@keyframesand class rules. - The stylesheet is ~60 lines of CSS that gets injected/removed on every state transition between minimized and expanded.
Consider rendering the <style> tag once at the DevErrorOverlayApp level, outside the conditional branch, so it's always present when any overlay UI is mounted and doesn't re-inject on minimize/expand toggling.
| } | ||
|
|
||
| componentDidCatch(error: unknown): void { | ||
| this.props.onCatch?.(this.props.resetKey, error); |
There was a problem hiding this comment.
onCatch second argument silently dropped: componentDidCatch calls this.props.onCatch?.(this.props.resetKey, error), passing two args. But the actual callback in app-browser-entry.ts is:
function handleDevRecoveryBoundaryCatch(resetKey: number): void {
drainPrePaintEffects(resetKey);
}It ignores the second error argument. This works at runtime (extra args are silently ignored in JS), and the types are compatible. But it means the error is silently dropped — if someone later wants to log or report it from this callback, they'd need to remember to add the parameter. Consider accepting and discarding it explicitly: (resetKey: number, _error: unknown).
| @@ -0,0 +1,229 @@ | |||
| import { test, expect, type Page } from "@playwright/test"; | |||
There was a problem hiding this comment.
CI is red — the overlay breaks 4 existing E2E tests that were not updated.
The vinext-dev-error-backdrop intercepts pointer events on top of the error boundary UI that these tests need to click:
error-nav.spec.ts:12— "can navigate away from error page" (#link-to-resultblocked)error-nav.spec.ts:54— "navigation to error page then back works" (#link-back-homeblocked)layout-persistence.spec.ts:184— "navigating away from error and back clears the error" ([data-testid="error-go-home"]blocked)navigation-flows.spec.ts:192— "error → reset → navigate flow" (#error-boundary buttonblocked)
The fix applied in error-interactive.spec.ts (adding the CSS rule to hide #__vinext_dev_error_overlay_root) needs to be applied to these test files too. Every test that intentionally triggers an error and then tries to interact with the page underneath the overlay needs this workaround.
| if (installed || typeof window === "undefined") return; | ||
| installed = true; | ||
|
|
||
| window.addEventListener("error", (event: ErrorEvent) => { |
There was a problem hiding this comment.
Missing deduplication with React error callbacks: When a React render error fires, it goes through devOnCaughtError or devOnUncaughtError (which call reportDevError), AND if the error propagates to window.onerror/unhandledrejection, these listeners will also call reportDevError for the same error.
For devOnUncaughtError errors in particular: React fires the callback, then the error continues propagating up to the window error handler. The user will see the same error twice in the overlay — once as "Unhandled Runtime Error" and once as "Unhandled Script Error".
Consider adding dedup logic, e.g. a WeakSet<Error> that tracks errors already reported through React callbacks, and skip them in the window listeners.
| return snapshot; | ||
| } | ||
|
|
||
| export function reportToOverlay(error: ReportedError): void { |
There was a problem hiding this comment.
Unbounded error accumulation: reportToOverlay appends to the errors array without any limit. In a dev scenario with a hot-reloading loop that repeatedly fails (or a useEffect that throws on every render), this could accumulate hundreds or thousands of error objects in memory, each with a full stack trace string.
Consider capping the array at a reasonable limit (e.g., 50 or 100) with a FIFO eviction policy, or at least deduplicating consecutive identical errors (same message + stack).
|
Review posted successfully. Here's a summary of what I flagged: Blocking (CI is red):
Should fix:
Nits:
|
998e946 to
33626ee
Compare
|
/bigbonk review |
1eed865 to
92dc3df
Compare
There was a problem hiding this comment.
All 7 issues from the first review are resolved. CI is green. The code is clean and ready to merge.
What was fixed since last review:
-
CI green — all 4 broken E2E tests (
error-nav.spec.ts,layout-persistence.spec.ts,navigation-flows.spec.ts,error-interactive.spec.ts) now use thedisableDevErrorOverlayhelper. All 30 CI checks pass. -
Prod bundle isolation —
app-browser-error.tsno longer imports the overlay module. The dev-only imports (devOnCaughtError,devOnUncaughtError,dismissOverlay,installDevErrorOverlay) live inapp-browser-entry.tsand every call site is behindimport.meta.env.DEV, so the bundler can statically eliminate them in prod. -
FONT_STACK_LITERAL removed —
FONT_STACKis used directly in the CSS template literal. -
Single
<style>tag — the stylesheet is now rendered once at theDevErrorOverlayApplevel, outside the minimized/expanded conditional. -
_errorexplicitly accepted —handleDevRecoveryBoundaryCatchsignature is now(resetKey: number, _error: unknown)with an explanatory comment. -
Deduplication — a
WeakSet<object>(reportedErrors) tracks errors already routed throughdevOnCaughtError/devOnUncaughtError, and thewindow.onerror/unhandledrejectionlisteners skip them viaalreadyReported(). -
Bounded accumulation —
MAX_ERRORS = 50with FIFO eviction inreportToOverlay.
Architecture is sound: separate React root on a detached div, useSyncExternalStore-driven store, DevRecoveryBoundary keeping BrowserRoot alive for HMR, HMR handler clearing stale errors via dismissOverlay(). No remaining concerns.
|
Review posted. All 7 issues from the first review have been addressed:
Approved the PR -- the architecture is solid and the code is clean. |
92dc3df to
a44dca3
Compare
Surfaces three error sources that previously only reached the console: React render errors caught by an error.tsx boundary (onCaughtError), render errors with no boundary above them (onUncaughtError), and plain script errors / unhandled promise rejections via window listeners. The overlay renders into a separate createRoot mounted on a detached div appended to body, so it survives an unmount of the main hydrateRoot tree — necessary because most errors we want to surface take that tree down. Includes a /dev-overlay-test fixture and Playwright spec covering all three error sources, dismiss, and prev/next pagination across multiple errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a44dca3 to
52dcf5c
Compare
Summary
error.tsxboundary (onCaughtError), render errors with no boundary above them (onUncaughtError), and plain script errors / unhandled promise rejections viawindowlisteners.createRootmounted on a detached<div>appended tobody, so it survives an unmount of the mainhydrateRoot(document, …)tree — necessary because most errors we want to surface take that tree down./dev-overlay-testfixture page and a Playwright spec covering each error source plus dismiss and prev/next pagination across multiple errors.Test plan
PLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/dev-error-overlay.spec.ts— 7 passingPLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/error-handling.spec.ts tests/e2e/app-router/error-interactive.spec.ts— 13 passing (no regression in existing error tests)pnpm exec vp check --fixclean🤖 Generated with Claude Code