From dbd15ee27b7388ede5498eba491145d7673485c4 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Thu, 2 Apr 2026 16:13:01 +0200 Subject: [PATCH 1/3] [02/04 16:13]: error tracking --- .../src/app/RootErrorBoundary.tsx | 57 +++++++++++++++++++ .../src/features/analytics/events.ts | 4 +- .../src/i18n/en/errors.json | 5 ++ src/governance-app-frontend/src/main.tsx | 7 ++- 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 src/governance-app-frontend/src/app/RootErrorBoundary.tsx diff --git a/src/governance-app-frontend/src/app/RootErrorBoundary.tsx b/src/governance-app-frontend/src/app/RootErrorBoundary.tsx new file mode 100644 index 000000000..80c92945c --- /dev/null +++ b/src/governance-app-frontend/src/app/RootErrorBoundary.tsx @@ -0,0 +1,57 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +import { analytics } from '@features/analytics/service'; +import { AnalyticsEvent } from '@features/analytics/events'; +import i18n from '@/i18n/config'; + +type Props = { + children: ReactNode; +}; + +type State = { + hasError: boolean; +}; + +/** Extracts the first component name from a React componentStack string. */ +const extractFirstComponent = (componentStack: string): string => { + const match = /\s+at\s+(\w+)/.exec(componentStack); + return match?.[1] ?? 'Unknown'; +}; + +export class RootErrorBoundary extends Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + analytics.event(AnalyticsEvent.FrontendError, { + error_type: error.name, + component: extractFirstComponent(errorInfo.componentStack ?? ''), + }); + } + + render(): ReactNode { + if (this.state.hasError) { + return ( +
+

+ {i18n.t(($) => $.errors.errorBoundary.title)} +

+

+ {i18n.t(($) => $.errors.errorBoundary.description)} +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/src/governance-app-frontend/src/features/analytics/events.ts b/src/governance-app-frontend/src/features/analytics/events.ts index 7583636f1..f5f6991ca 100644 --- a/src/governance-app-frontend/src/features/analytics/events.ts +++ b/src/governance-app-frontend/src/features/analytics/events.ts @@ -24,9 +24,11 @@ export enum AnalyticsEvent { FollowingPickerApplyError = 'following_picker_apply_error', FollowingRemoveFollowee = 'following_remove_followee', FollowingClearAll = 'following_clear_all', + + // Error tracking + FrontendError = 'frontend_error', } // @TODO: -// - successful login // - click on the navigation items // - click on dashboard buttons (deposit / withdraw / staking - apy warning icons) // - view proposal link diff --git a/src/governance-app-frontend/src/i18n/en/errors.json b/src/governance-app-frontend/src/i18n/en/errors.json index 4d49fdd09..9e2e2bc98 100644 --- a/src/governance-app-frontend/src/i18n/en/errors.json +++ b/src/governance-app-frontend/src/i18n/en/errors.json @@ -1,4 +1,9 @@ { + "errorBoundary": { + "title": "Something went wrong", + "description": "Please refresh the page. If the problem persists, try clearing your browser cache.", + "tryAgain": "Try again" + }, "nnsGovernanceErrors": { "CouldNotClaimNeuronError": "Could not find the neuron to claim.", "InsufficientAmountError": "The amount is not enough.", diff --git a/src/governance-app-frontend/src/main.tsx b/src/governance-app-frontend/src/main.tsx index 9ebc7ea84..64e5c8cac 100644 --- a/src/governance-app-frontend/src/main.tsx +++ b/src/governance-app-frontend/src/main.tsx @@ -1,6 +1,11 @@ import ReactDOM from 'react-dom/client'; import { App } from './app/App'; +import { RootErrorBoundary } from './app/RootErrorBoundary'; const rootElement = document.getElementById('root') as HTMLElement; -ReactDOM.createRoot(rootElement).render(); +ReactDOM.createRoot(rootElement).render( + + + , +); From b333ec4edb435cee6d29ef5e5ecb381681182330 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Thu, 2 Apr 2026 16:33:43 +0200 Subject: [PATCH 2/3] [02/04 16:33]: util --- .../src/app/RootErrorBoundary.tsx | 52 +++++++++++-------- .../src/common/utils/error.spec.ts | 23 ++++++++ .../src/common/utils/error.ts | 3 ++ 3 files changed, 57 insertions(+), 21 deletions(-) create mode 100644 src/governance-app-frontend/src/common/utils/error.spec.ts diff --git a/src/governance-app-frontend/src/app/RootErrorBoundary.tsx b/src/governance-app-frontend/src/app/RootErrorBoundary.tsx index 80c92945c..406514d9a 100644 --- a/src/governance-app-frontend/src/app/RootErrorBoundary.tsx +++ b/src/governance-app-frontend/src/app/RootErrorBoundary.tsx @@ -1,7 +1,9 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; -import { analytics } from '@features/analytics/service'; +import { Button } from '@components/button'; import { AnalyticsEvent } from '@features/analytics/events'; +import { analytics } from '@features/analytics/service'; +import { firstComponentFromStack } from '@utils/error'; import i18n from '@/i18n/config'; type Props = { @@ -12,12 +14,6 @@ type State = { hasError: boolean; }; -/** Extracts the first component name from a React componentStack string. */ -const extractFirstComponent = (componentStack: string): string => { - const match = /\s+at\s+(\w+)/.exec(componentStack); - return match?.[1] ?? 'Unknown'; -}; - export class RootErrorBoundary extends Component { state: State = { hasError: false }; @@ -26,28 +22,42 @@ export class RootErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + const component = firstComponentFromStack(errorInfo.componentStack ?? ''); + analytics.event(AnalyticsEvent.FrontendError, { error_type: error.name, - component: extractFirstComponent(errorInfo.componentStack ?? ''), + component, }); } render(): ReactNode { if (this.state.hasError) { return ( -
-

- {i18n.t(($) => $.errors.errorBoundary.title)} -

-

- {i18n.t(($) => $.errors.errorBoundary.description)} -

- +
+
+ {i18n.t(($) $.common.alt.icpLogo)} + className="h-6 w-fit dark:invert" + /> +
+
+
+
+

+ {i18n.t(($) => $.errors.errorBoundary.title)} +

+

+ {i18n.t(($) => $.errors.errorBoundary.description)} +

+
+
+ +
+
+
); } diff --git a/src/governance-app-frontend/src/common/utils/error.spec.ts b/src/governance-app-frontend/src/common/utils/error.spec.ts new file mode 100644 index 000000000..7ec5a89cb --- /dev/null +++ b/src/governance-app-frontend/src/common/utils/error.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { firstComponentFromStack } from '@utils/error'; + +describe('firstComponentFromStack', () => { + it('should return the first component name from a stack', () => { + const stack = '\n at Button\n at Modal\n at App'; + expect(firstComponentFromStack(stack)).toBe('Button'); + }); + + it('should handle extra whitespace around component names', () => { + const stack = '\n at MyComponent\n at Parent'; + expect(firstComponentFromStack(stack)).toBe('MyComponent'); + }); + + it('should return Unknown for an empty string', () => { + expect(firstComponentFromStack('')).toBe('Unknown'); + }); + + it('should return Unknown when no component is found', () => { + expect(firstComponentFromStack('no match here')).toBe('Unknown'); + }); +}); diff --git a/src/governance-app-frontend/src/common/utils/error.ts b/src/governance-app-frontend/src/common/utils/error.ts index 0a2c86e8b..94491b717 100644 --- a/src/governance-app-frontend/src/common/utils/error.ts +++ b/src/governance-app-frontend/src/common/utils/error.ts @@ -1,3 +1,6 @@ export const errorMessage = (source: string, message: string): Error => { return new Error(`❌ ERROR (${source}): ${message}.`); }; + +export const firstComponentFromStack = (componentStack: string): string => + /\s+at\s+(\w+)/.exec(componentStack)?.[1] ?? 'Unknown'; From 2ac5de8ab870f2ded934758ff4f80f632a52dcc0 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Fri, 3 Apr 2026 00:41:21 +0200 Subject: [PATCH 3/3] [03/04 00:41]: fmt --- src/governance-app-frontend/src/app/RootErrorBoundary.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/governance-app-frontend/src/app/RootErrorBoundary.tsx b/src/governance-app-frontend/src/app/RootErrorBoundary.tsx index 406514d9a..c680a9097 100644 --- a/src/governance-app-frontend/src/app/RootErrorBoundary.tsx +++ b/src/governance-app-frontend/src/app/RootErrorBoundary.tsx @@ -1,9 +1,11 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; -import { Button } from '@components/button'; import { AnalyticsEvent } from '@features/analytics/events'; import { analytics } from '@features/analytics/service'; + +import { Button } from '@components/button'; import { firstComponentFromStack } from '@utils/error'; + import i18n from '@/i18n/config'; type Props = {