Repository navigation
Report each failed route load to RUM once, and offer Try Again when the server errors or can't be reached - #1809
Conversation
…in for transient ones One failed route load reached Datadog RUM as up to 14 identical error-boundary reports (#1695). TanStack Router re-throws a route's stored error whenever its match object changes, and every reload of the route throws a fresh but identical one, so React's root onCaughtError fired once per catch, not once per failure. Driving the real router with a 403 on the org route, the old handler reported 10 times for one failure. The root handlers now go through reportReactError, which skips a failure identical in name, message, stack, component stack and request endpoint to one reported in the last 10 s (at most 100 signatures held, oldest evicted). A value whose fields throw is still reported, just not deduped. The handlers' devtools copy moves from console.error to console.warn: the RUM SDK reports every console.error as an error of its own, so each boundary catch was also landing as a second, console-sourced event. PanelErrorBoundary and MetricErrorBoundary had the same duplicate in componentDidCatch and move to console.warn too. AGENTS.md records the rule next to the existing console.error note. A route error page for a 5xx or a request that got no answer (ERR_NETWORK, ECONNABORTED, ETIMEDOUT) now says so in words and offers Try Again, which reloads the route via router.invalidate() and is disabled while that reload is pending. 4xx, cancellations and code errors get no retry. The generic title becomes "Something Went Wrong" instead of "Component Error". Closes #1695 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…SDK does A throw from addReactError escaped the root onCaughtError callback and skipped the devtools line for the failure it was reporting. reportReactError now swallows it and leaves the signature unrecorded so the next catch tries again. The real-router test name now says what it proves: one report from the root handlers. React Query's per-attempt console.error is still a second channel, filed as #1808. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a deduplication mechanism for React root errors reported to Datadog RUM, preventing repeated TanStack Router route-load errors from flooding logs. It also refactors the ErrorComponent to support a 'Try Again' button for retryable failures (such as 5xx and network errors) and updates error boundaries to log via console.warn instead of console.error to avoid duplicate reporting. Feedback on these changes includes serializing custom plain objects or arrays in error signatures to prevent incorrect deduplication, making errorInfo optional in reportReactError to avoid potential runtime TypeErrors, and adding a spin animation to the retry icon for improved visual feedback.
| export function reportReactError(error: unknown, errorInfo: ErrorInfo): void { | ||
| let signature: string | undefined; | ||
| try { | ||
| signature = signatureOf(error, errorInfo.componentStack); | ||
| } catch { | ||
| signature = undefined; | ||
| } | ||
| const now = performance.now(); | ||
| if (signature !== undefined) { | ||
| const last = lastReportedAt.get(signature); | ||
| if (last !== undefined && now - last < REPEAT_WINDOW_MS) { return; } | ||
| } | ||
| try { | ||
| addReactError(error as Error, errorInfo); | ||
| } catch { | ||
| // The root callback must not throw on top of the failure it was handed; an unrecorded signature | ||
| // lets the next catch try again. | ||
| return; | ||
| } | ||
| if (signature !== undefined) { remember(signature, now); } | ||
| } |
There was a problem hiding this comment.
To prevent potential runtime TypeErrors if reportReactError is called with an undefined or null errorInfo (e.g., in tests or custom error triggers), we should use optional chaining when accessing errorInfo.componentStack and make errorInfo optional in the function signature.
export function reportReactError(error: unknown, errorInfo?: ErrorInfo): void {
let signature: string | undefined;
try {
signature = signatureOf(error, errorInfo?.componentStack);
} catch {
signature = undefined;
}
const now = performance.now();
if (signature !== undefined) {
const last = lastReportedAt.get(signature);
if (last !== undefined && now - last < REPEAT_WINDOW_MS) { return; }
}
try {
addReactError(error as Error, errorInfo);
} catch {
// The root callback must not throw on top of the failure it was handed; an unrecorded signature
// lets the next catch try again.
return;
}
if (signature !== undefined) { remember(signature, now); }
}There was a problem hiding this comment.
Declined. The only production caller is React itself, through reactRootErrorHandlers (main.tsx:28), and React's root callbacks always receive an errorInfo object: @types/react-dom/client.d.ts:22-32 types it as required on onUncaughtError, onCaughtError and onRecoverableError, and the handlers are checked against that with satisfies RootOptions. Even a missing one couldn't throw here, because the errorInfo.componentStack read sits inside the try (reportReactError.ts:74-78): the signature becomes undefined and the error is still reported, just not deduplicated.
🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
… deduplicating them
Two non-Error values thrown within the repeat window, such as { error: 'Unauthorized' } and { error: 'Forbidden' }, had no name, message, stack or config to differ on, so the second was dropped as a repeat of the first. A non-Error's serialized contents now join its signature, with its keys standing in when it cannot be serialized, so a cyclic object re-thrown by the router is still reported once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e057144
into
claude/1293-forbidden-not-found-error-page
⊙ Problem
One failed route load reaches Datadog RUM as up to 14 identical error-boundary reports (#1695). 30 days showed 170
@error.source:customevents across 84 sessions, every one a data-fetch failure that reached a route error boundary. Because Datadog fingerprints by stack, the burst also inflates one Error Tracking issue roughly 5×, so "is this getting worse?" can't be answered from it. On top of that, each React root handler alsoconsole.errors the same error, which the RUM SDK files as a second, console-sourced event. And a 5xx or network failure lands on a dead-end card with no way to retry.Closes #1695
💡 Solution
CatchBoundaryresets whenever the match object changes (getResetKey={() => match},@tanstack/react-router/src/Match.tsx) andMatchInnerre-throws the storedmatch.error, so one error is caught again on each match update. Each reload (AppRoutedcallsrouter.invalidate()on every auth change) also re-runsbeforeLoadand throws a fresh but identical AxiosError. Driving the real router with one org 403 plus two invalidates, the old handler calledaddReactError10 times across 4 distinct error objects.reportReactError, which skips a failure whose signature was reported in the last 10 s.console.errortoconsole.warn. The SDK only collectsconsole.error(browser-rum-coreerrorCollection.mjs,ConsoleApiName.error), andwarnstill shows in devtools by default, unlikedebug.🔧 Changes
src/integrations/datadog/reportReactError.ts(new):signatureOfbuilds the key from name, message, stack, component stack and, for an axios error, method/baseURL/url, so two different endpoints failing the same way stay separate. A thrown non-Error has none of those fields, socontentsOfadds its serialized contents, or its keys when it can't be serialized. The key never leaves memory.rememberexpires and evicts oldest-first, at most 100 entries.reportReactErrorreports without dedupe if computing a signature throws. A throw fromaddReactErroritself is swallowed and the signature stays unrecorded, so the root callback never throws on top of the failure it was handed and the next catch tries again.reactRootErrorHandlersholds the three root callbacks in a side-effect-free module, so a test can drive them, and logs withconsole.warn.src/main.tsx:createRoottakesreactRootErrorHandlersinstead of inline handlers that calledaddReactErrorand thenconsole.error.src/components/ErrorComponent.tsx:describeFailureadds copy for a 5xx and forERR_NETWORK/ECONNABORTED/ETIMEDOUT. Theresetprop, which TanStack passes only to a route error component, gates Try Again.RetryButtoncallsrouter.invalidate()(aresetalone re-renders a match that still holds the error) and is disabled, with its icon spinning, while the reload is pending. Route loads are reads, so a retry repeats no write. Generic title: "Something Went Wrong".ClusterRouteError.tsxandOrgRouteError.tsxforwardresetto the generic page (cluster, org), so their non-403 failures get the retry too.PanelErrorBoundary.tsxandMetricRenderer.tsx: eachcomponentDidCatchconsole.errored a failure the rootonCaughtErroralready reports, and now usesconsole.warn(panel, metric). This was the planning review's catch.AGENTS.md: the boundary rule sits next to the existingconsole.errornote.src/testSetup/failOnRenderPhaseUpdate.ts: its comment no longer cites the panel log as aconsole.erroruser.⚖️ Alternatives
beforeSend: rejected. The duplication is produced entirely by our React handlers, andbeforeSendwould need the same signature state for every event type. The concurrent [RUM] error.causes[] bypasses every redaction layer in beforeSend #1665 work is also editingbeforeSend.ts.shouldKeepEvent.ts, [RUM] Persistent AxiosError 401 on instance operation polling endpoint #1386).console.error, or only an identity set: rejected. The probe showed 10 catches across 4 distinct objects, so either alone still reports the burst.router.invalidate({ filter })): not done. Try Again reloads every match, so it also re-fetches healthy parents (org, cluster). That costs a few reads per click and is easy to narrow later.✅ Verification
routeErrorReporting.test.tsxdrives the real route chain down to the org layout, withaddReactErrormocked. One 403 plus tworouter.invalidate()s yields oneaddReactErrorcall and no root-handlerconsole.error. A 503 shows Try Again; once the server answers, clicking it re-reads and renders the page. A 403 offers no Try Again.onCaughtErrorrestored to the base body (addReactError+console.error), that test fails withexpected "vi.fn()" to be called 1 times, but got 10 times. Restoring only theconsole.errorfails it on the console assertion. RevertingPanelErrorBoundarytoconsole.errorfails its new test.reportReactError.test.tscovers identity and fresh-identical repeats, window expiry, a different message/endpoint/name/component stack, primitives of different types, plain objects with different contents (and a cyclic one that still dedupes), a proxy whose getters throw, anaddReactErrorthat throws (swallowed, unrecorded, original still warned), and the 100-signature cap.ErrorComponent.test.tsxcovers 5xx and the three no-response codes withreset, no retry for 400/403/404/cancel/TypeError/string, no retry withoutreset, and oneinvalidateper click with the button disabled while pending and its icon spinning until the reload settles.ClusterRouteError.test.tsxchecksresetis forwarded,OrgRouteError.test.tsxchecks a 500 on the org route offers Try Again, andPanelErrorBoundary.test.tsxchecks the panel logs withwarn.vitest run400 files / 3,711 tests pass (pre-commit hook ondbc2bc20; the hook on05181160timed out in the unrelatededitorApiandNewApplicationsuites under load, both pass alone, and that commit ran commitlint,dprint check --stagedand oxlint by hand),tsc -b0,oxlint0,dprint check0,vite build0. One earlier message-only amend skipped the hook because the machine's load average hit ~590 and unrelated suites (editorApi,NewApplication,Scaling) timed out. Those pass in isolation, and the tree was identical to the one that had just passed.no-receipton the 1Password SSH agent (round 1; pruned in round 2), so I triaged the outside findings myself. Resolved: the SDK-throw escape (codex minor / gemini major). Kept open: codex's React Query channel major (above, A failed route load is reported to RUM twice and shown twice — React Query's console.error and toast duplicate the route error page #1808). Declined: gemini's.finally()without.catch()onrouter.invalidate(), because a rejection there is a real router failure and should reach RUM as an unhandled rejection rather than be swallowed. Comment nits: the ticket reference came out of code; the remaining comments state why (warnnoterror,invalidatenotreset).05181160, after the gemini-code-assist fixes below): codex (graded), gemini and the Harper domain leg,independent=true,Adjudicated-Severity: major. That major is the React Query channel above, kept and unchanged (A failed route load is reported to RUM twice and shown twice — React Query's console.error and toast duplicate the route error page #1808; Show a failed page load only on its error page, without a duplicate toast or RUM report #1878 may own it). The domain leg also kept codex'sRetryButtonpoint at minor: a rejectedrouter.invalidate()goes unhandled, and a synchronous throw would leave the button disabled. I left both as they are. The rejection reaching RUM is the earlier deliberate call. The synchronous throw can only come from router-core's own bookkeeping, sinceinvalidatejust maps the committed matches and returns theasyncload()(@tanstack/router-corerouter.js:484,509-554). The comment nit (gemini, domain) was declined for the same reason as before: the remaining comments each state a constraint that a later edit would break.dbc2bc20) and the Try Again icon not showing the pending reload (05181160). Declined: makingerrorInfooptional, because React always passes it (@types/react-dom/client.d.ts) and thecomponentStackread is already inside the signature'stry.@error.source:customper affected session drops toward 1. Not checked in a browser: the shared 5173 preview belonged to another session.🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.
Related PRs: #1804 overlaps (this PR is stacked on it), #1878 overlaps (its route-query claims would close the React Query channel kept open here, #1808), 1 others independent
Complexity: medium
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-composer(no-receipt); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=3; full=1 @ 0518116
Review-Attention: study ~9m (decisions: do-less-alternative, dedup-window-semantics, dedup-layer, retry-scope, retry-gating, generic-title; raised: open major) @ 0518116