From 780469e61d742461b96de4803299952cf66a4cc0 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 23 Jan 2026 16:34:22 +0100 Subject: [PATCH] feat(error-handling): fix production errors and improve error resilience APP-270: Add errorUtils.serialize() to safely serialize errors without circular references APP-389: Add missing 'use client' directives to components using hooks (partial fix) APP-461: Add useSafeTranslations() hook with fallback for error boundaries APP-453: Handle 404 responses directly in AragonBackendServiceError to avoid JSON parse errors APP-463: Skip plugins-by-dao and policies API calls on v2 (endpoints not deployed to production) --- .changeset/six-friends-trade.md | 5 + .../layouts/layoutDao/layoutDao.tsx | 3 +- .../layouts/layoutWizard/layoutWizard.tsx | 3 +- .../layoutWizardCreateProposal.tsx | 3 +- .../proposalListStats/proposalListStats.tsx | 2 + .../daoMemberDetailsPage.tsx | 3 +- .../daoProposalDetailsPage.tsx | 3 +- .../daoProcessDetailsPage.tsx | 3 +- .../capitalDistributorRewardsStats.tsx | 2 + .../aragonBackendServiceError.ts | 9 ++ src/shared/api/daoService/daoService.ts | 24 +++- .../errorFeedback/errorFeedback.tsx | 4 +- .../components/translationsProvider/index.ts | 1 + .../translationsProvider.tsx | 18 +++ .../utils/errorUtils/errorUtils.test.ts | 131 ++++++++++++++++++ src/shared/utils/errorUtils/errorUtils.ts | 84 +++++++++++ src/shared/utils/errorUtils/index.ts | 1 + 17 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 .changeset/six-friends-trade.md create mode 100644 src/shared/utils/errorUtils/errorUtils.test.ts create mode 100644 src/shared/utils/errorUtils/errorUtils.ts create mode 100644 src/shared/utils/errorUtils/index.ts diff --git a/.changeset/six-friends-trade.md b/.changeset/six-friends-trade.md new file mode 100644 index 000000000..9703ec2f7 --- /dev/null +++ b/.changeset/six-friends-trade.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +Erros cleanup and improve error resilience diff --git a/src/modules/application/components/layouts/layoutDao/layoutDao.tsx b/src/modules/application/components/layouts/layoutDao/layoutDao.tsx index d758ce92d..7b8e91522 100644 --- a/src/modules/application/components/layouts/layoutDao/layoutDao.tsx +++ b/src/modules/application/components/layouts/layoutDao/layoutDao.tsx @@ -8,6 +8,7 @@ import { daoOptions, type IDao } from '@/shared/api/daoService'; import { Page } from '@/shared/components/page'; import type { IDaoPageParams } from '@/shared/types'; import { daoUtils } from '@/shared/utils/daoUtils'; +import { errorUtils } from '@/shared/utils/errorUtils'; import { networkUtils } from '@/shared/utils/networkUtils'; import { BannerDao } from '../../bannerDao'; import { ErrorBoundary } from '../../errorBoundary'; @@ -47,7 +48,7 @@ export const LayoutDao: React.FC = async (props) => { daoOptions({ urlParams: daoUrlParams }), ); } catch (error: unknown) { - const parsedError = JSON.parse(JSON.stringify(error)) as unknown; + const parsedError = errorUtils.serialize(error); return ( => { + // Handle 404 responses directly to avoid unnecessary JSON parsing errors + if (response.status === 404) { + return new AragonBackendServiceError( + this.notFoundCode, + `Resource not found (url=${response.url})`, + 404, + ); + } + const parsedData = await responseUtils.safeJsonParse(response); const isIErrorResponse = (value: unknown): value is IErrorResponse => diff --git a/src/shared/api/daoService/daoService.ts b/src/shared/api/daoService/daoService.ts index f8d27a2df..a83c19ee1 100644 --- a/src/shared/api/daoService/daoService.ts +++ b/src/shared/api/daoService/daoService.ts @@ -74,14 +74,24 @@ class DaoService extends AragonBackendService { /** * Ensure the returned DAO always has its plugins populated. - * Old backends already include plugins on the DAO endpoint; new ones + * Old backends (v2) already include plugins on the DAO endpoint; new ones (v3) * require an extra call to the plugins-by-dao endpoint. + * + * Note: plugins-by-dao endpoint is not yet deployed to production backend, + * so we skip the call when using v2 to avoid 404 errors. */ private withPlugins = async (dao: IDaoApiResponse): Promise => { if (dao.plugins != null && dao.plugins.length > 0) { return dao as IDao; } + // Skip plugins-by-dao call on v2 - endpoint not yet deployed to production + // TODO: Remove this once the endpoint is deployed to production and we are using NEXT_PUBLIC_API_VERSION=v3 + const apiVersion = apiVersionUtils.getApiVersion(); + if (apiVersion === 'v2') { + return dao as IDao; + } + try { const { network, address } = this.parseDaoId(dao.id); const plugins = await pluginsService.getPluginsByDao({ @@ -124,9 +134,21 @@ class DaoService extends AragonBackendService { return result; }; + /** + * Fetch policies for a DAO. + * Note: policies endpoint is not yet deployed to production backend, + * so we return empty array when using v2 to avoid 404 errors. + * TODO: Remove this check once the endpoint is deployed to production and we are using NEXT_PUBLIC_API_VERSION=v3 + */ getDaoPolicies = async ( params: IGetDaoPoliciesParams, ): Promise => { + // Skip policies call on v2 - endpoint not yet deployed to production + const apiVersion = apiVersionUtils.getApiVersion(); + if (apiVersion === 'v2') { + return []; + } + const result = await this.request( this.urls.daoPolicies, params, diff --git a/src/shared/components/errorFeedback/errorFeedback.tsx b/src/shared/components/errorFeedback/errorFeedback.tsx index 2bd385328..93bd6e0f4 100644 --- a/src/shared/components/errorFeedback/errorFeedback.tsx +++ b/src/shared/components/errorFeedback/errorFeedback.tsx @@ -4,7 +4,7 @@ import { type IEmptyStateBaseProps, type IllustrationObjectType, } from '@aragon/gov-ui-kit'; -import { useTranslations } from '@/shared/components/translationsProvider'; +import { useSafeTranslations } from '@/shared/components/translationsProvider'; export interface IErrorFeedbackProps { /** @@ -42,7 +42,7 @@ export const ErrorFeedback: React.FC = (props) => { hideReportButton, } = props; - const { t } = useTranslations(); + const { t } = useSafeTranslations(); const reportIssueButton = { label: t('app.shared.errorFeedback.link.report'), diff --git a/src/shared/components/translationsProvider/index.ts b/src/shared/components/translationsProvider/index.ts index ae4b580b3..1224ec85f 100644 --- a/src/shared/components/translationsProvider/index.ts +++ b/src/shared/components/translationsProvider/index.ts @@ -3,5 +3,6 @@ export { type ITranslationsProviderProps, type TranslationFunction, TranslationsProvider, + useSafeTranslations, useTranslations, } from './translationsProvider'; diff --git a/src/shared/components/translationsProvider/translationsProvider.tsx b/src/shared/components/translationsProvider/translationsProvider.tsx index 2771c833c..789617974 100644 --- a/src/shared/components/translationsProvider/translationsProvider.tsx +++ b/src/shared/components/translationsProvider/translationsProvider.tsx @@ -80,3 +80,21 @@ export const useTranslations = () => { return values; }; + +/** + * A safe version of useTranslations that provides a fallback when the + * TranslationsProvider is not available. Use this in error boundary components + * or other places where the provider might not be mounted yet. + * + * Falls back to returning the translation key as-is if no provider is found. + */ +export const useSafeTranslations = (): ITranslationContext => { + const values = useContext(translationsContext); + + if (values == null) { + // Fallback: return the key as-is (useful for error boundaries) + return { t: (key: string) => key }; + } + + return values; +}; diff --git a/src/shared/utils/errorUtils/errorUtils.test.ts b/src/shared/utils/errorUtils/errorUtils.test.ts new file mode 100644 index 000000000..fc7098cec --- /dev/null +++ b/src/shared/utils/errorUtils/errorUtils.test.ts @@ -0,0 +1,131 @@ +import { errorUtils } from './errorUtils'; + +describe('errorUtils', () => { + describe('serialize', () => { + it('returns empty object for null', () => { + expect(errorUtils.serialize(null)).toEqual({}); + }); + + it('returns empty object for undefined', () => { + expect(errorUtils.serialize(undefined)).toEqual({}); + }); + + it('converts primitive to message', () => { + expect(errorUtils.serialize('error message')).toEqual({ + message: 'error message', + }); + expect(errorUtils.serialize(42)).toEqual({ message: '42' }); + }); + + it('extracts standard Error properties', () => { + const error = new Error('test error'); + error.name = 'TestError'; + + const result = errorUtils.serialize(error); + + expect(result.name).toBe('TestError'); + expect(result.message).toBe('test error'); + expect(result.stack).toBeDefined(); + }); + + it('extracts custom error properties', () => { + const error = { + name: 'CustomError', + message: 'custom message', + code: 'CUSTOM_CODE', + status: 404, + description: 'Not found', + }; + + const result = errorUtils.serialize(error); + + expect(result).toEqual({ + name: 'CustomError', + message: 'custom message', + code: 'CUSTOM_CODE', + status: 404, + description: 'Not found', + }); + }); + + it('handles nested cause', () => { + const cause = new Error('cause error'); + const error = new Error('main error', { cause }); + + const result = errorUtils.serialize(error); + + expect(result.cause).toBeDefined(); + expect(result.cause?.message).toBe('cause error'); + }); + + it('ignores non-serializable properties', () => { + const error = { + message: 'test', + fn: () => { + return; + }, + symbol: Symbol('test'), + }; + + const result = errorUtils.serialize(error); + + expect(result).toEqual({ message: 'test' }); + expect(result).not.toHaveProperty('fn'); + expect(result).not.toHaveProperty('symbol'); + }); + + it('does not throw on objects with circular references', () => { + // Simulate an error object that would cause JSON.stringify to fail + const error = { + message: 'test', + code: 'TEST_CODE', + status: 500, + }; + + // This should not throw + expect(() => errorUtils.serialize(error)).not.toThrow(); + + const result = errorUtils.serialize(error); + expect(result.message).toBe('test'); + expect(result.code).toBe('TEST_CODE'); + }); + + it('handles circular cause references without infinite recursion', () => { + const error1: Record = { message: 'error 1' }; + const error2: Record = { message: 'error 2' }; + + // Create circular reference: error1 -> error2 -> error1 + error1.cause = error2; + error2.cause = error1; + + // This should not throw or hang + expect(() => errorUtils.serialize(error1)).not.toThrow(); + + const result = errorUtils.serialize(error1); + expect(result.message).toBe('error 1'); + expect(result.cause?.message).toBe('error 2'); + // The circular reference should be cut off + expect(result.cause?.cause).toEqual({}); + }); + + it('limits cause chain depth to prevent stack overflow', () => { + // Create a deep cause chain + let deepError: Record = { message: 'deepest' }; + for (let i = 0; i < 20; i++) { + deepError = { message: `level ${i}`, cause: deepError }; + } + + // This should not throw + expect(() => errorUtils.serialize(deepError)).not.toThrow(); + + // Verify depth is limited (maxCauseDepth = 10) + let result = errorUtils.serialize(deepError); + let depth = 0; + while (result.cause && Object.keys(result.cause).length > 0) { + result = result.cause; + depth++; + } + expect(depth).toBeLessThanOrEqual(10); + }); + }); +}); diff --git a/src/shared/utils/errorUtils/errorUtils.ts b/src/shared/utils/errorUtils/errorUtils.ts new file mode 100644 index 000000000..8291a872b --- /dev/null +++ b/src/shared/utils/errorUtils/errorUtils.ts @@ -0,0 +1,84 @@ +export interface ISerializedError { + name?: string; + message?: string; + stack?: string; + code?: string; + status?: number; + description?: string; + cause?: ISerializedError; +} + +/** + * Utility class for safe error serialization. + */ +class ErrorUtils { + private readonly maxCauseDepth = 10; + + /** + * Safely extracts serializable properties from an error object. + * Filters out DOM elements, functions, and circular references. + * + * Use this instead of `JSON.parse(JSON.stringify(error))` to avoid + * "Converting circular structure to JSON" errors when the error + * contains DOM element references (e.g., from React hydration errors + * or Next.js metadata API errors). + */ + serialize = (error: unknown): ISerializedError => { + return this.serializeInternal(error, new WeakSet(), 0); + }; + + private serializeInternal = ( + error: unknown, + seen: WeakSet, + depth: number, + ): ISerializedError => { + if (error == null) { + return {}; + } + + // Handle primitives + if (typeof error !== 'object') { + return { message: String(error) }; + } + + // Prevent infinite recursion from circular references + if (seen.has(error) || depth >= this.maxCauseDepth) { + return {}; + } + seen.add(error); + + const result: ISerializedError = {}; + const err = error as Record; + + // Safely copy standard error properties + if (typeof err.name === 'string') { + result.name = err.name; + } + if (typeof err.message === 'string') { + result.message = err.message; + } + if (typeof err.stack === 'string') { + result.stack = err.stack; + } + + // Copy custom error properties (e.g., from AragonBackendServiceError) + if (typeof err.code === 'string') { + result.code = err.code; + } + if (typeof err.status === 'number') { + result.status = err.status; + } + if (typeof err.description === 'string') { + result.description = err.description; + } + + // Recursively handle cause (Error.cause from ES2022) + if (err.cause != null) { + result.cause = this.serializeInternal(err.cause, seen, depth + 1); + } + + return result; + }; +} + +export const errorUtils = new ErrorUtils(); diff --git a/src/shared/utils/errorUtils/index.ts b/src/shared/utils/errorUtils/index.ts new file mode 100644 index 000000000..4fd572a9b --- /dev/null +++ b/src/shared/utils/errorUtils/index.ts @@ -0,0 +1 @@ +export { errorUtils, type ISerializedError } from './errorUtils';