Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/six-friends-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aragon/app": patch
---

Erros cleanup and improve error resilience
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,7 +48,7 @@ export const LayoutDao: React.FC<ILayoutDaoProps> = async (props) => {
daoOptions({ urlParams: daoUrlParams }),
);
} catch (error: unknown) {
const parsedError = JSON.parse(JSON.stringify(error)) as unknown;
const parsedError = errorUtils.serialize(error);
return (
<Page.Error
error={parsedError}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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';

export interface ILayoutWizardProps<
IPageParams extends IDaoPageParams = IDaoPageParams,
Expand Down Expand Up @@ -54,7 +55,7 @@ export const LayoutWizard = async <
);
}
} catch (error: unknown) {
const parsedError = JSON.parse(JSON.stringify(error)) as unknown;
const parsedError = errorUtils.serialize(error);
return (
<Page.Error
error={parsedError}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { daoOptions, type IDao } from '@/shared/api/daoService';
import { Page } from '@/shared/components/page';
import { PluginType } from '@/shared/types';
import { daoUtils } from '@/shared/utils/daoUtils';
import { errorUtils } from '@/shared/utils/errorUtils';
import type { ICreateProposalPageParams } from '../../types';

export interface ILayoutWizardCreateProposalProps {
Expand Down Expand Up @@ -57,7 +58,7 @@ export const LayoutWizardCreateProposal: React.FC<
);
wizardName = getWizardName(dao, pluginAddress);
} catch (error: unknown) {
const parsedError = JSON.parse(JSON.stringify(error)) as unknown;
const parsedError = errorUtils.serialize(error);
const errorNamespace =
'app.governance.layoutWizardCreateProposal.error';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import {
Button,
DateFormat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { QueryClient } from '@tanstack/react-query';
import { daoService } from '@/shared/api/daoService';
import { Page } from '@/shared/components/page';
import { daoUtils } from '@/shared/utils/daoUtils';
import { errorUtils } from '@/shared/utils/errorUtils';
import { memberOptions } from '../../api/governanceService';
import type { IDaoMemberPageParams } from '../../types';
import { DaoMemberDetailsPageClient } from './daoMemberDetailsPageClient';
Expand Down Expand Up @@ -33,7 +34,7 @@ export const DaoMemberDetailsPage: React.FC<
try {
await queryClient.fetchQuery(memberOptions(memberParams));
} catch (error: unknown) {
const parsedError = JSON.parse(JSON.stringify(error)) as unknown;
const parsedError = errorUtils.serialize(error);
const errorNamespace = 'app.governance.daoMemberDetailsPage.error';
const actionLink = `/dao/${network}/${addressOrEns}/members`;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { QueryClient } from '@tanstack/react-query';
import { Page } from '@/shared/components/page';
import { daoUtils } from '@/shared/utils/daoUtils';
import { errorUtils } from '@/shared/utils/errorUtils';
import {
proposalActionsOptions,
proposalBySlugOptions,
Expand Down Expand Up @@ -37,7 +38,7 @@ export const DaoProposalDetailsPage: React.FC<
proposalActionsOptions({ urlParams: { id: proposal.id } }),
);
} catch (error: unknown) {
const parsedError = JSON.parse(JSON.stringify(error)) as unknown;
const parsedError = errorUtils.serialize(error);
const errorNamespace = 'app.governance.daoProposalDetailsPage.error';
const actionLink = `/dao/${network}/${addressOrEns}/proposals`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { daoService } from '@/shared/api/daoService';
import { Page } from '@/shared/components/page';
import { PluginType } from '@/shared/types';
import { daoUtils } from '@/shared/utils/daoUtils';
import { errorUtils } from '@/shared/utils/errorUtils';
import type { IDaoProcessDetailsPageParams } from '../../types';
import { DaoProcessDetailsPageClient } from './daoProcessDetailsPageClient';

Expand Down Expand Up @@ -34,7 +35,7 @@ export const DaoProcessDetailsPage: React.FC<
'Process not found',
404,
);
const parsedError = JSON.parse(JSON.stringify(error)) as unknown;
const parsedError = errorUtils.serialize(error);
const errorNamespace = 'app.settings.daoProcessDetailsPage.error';
const actionLink = `/dao/${network}/${addressOrEns}/settings`;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import { DateFormat, formatterUtils } from '@aragon/gov-ui-kit';
import { useAccount } from 'wagmi';
import { StatCard } from '@/shared/components/statCard';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export class AragonBackendServiceError extends Error {
static fromResponse = async (
response: Response,
): Promise<AragonBackendServiceError> => {
// 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 =>
Expand Down
24 changes: 23 additions & 1 deletion src/shared/api/daoService/daoService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDao> => {
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({
Expand Down Expand Up @@ -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<IDaoPolicy[]> => {
// Skip policies call on v2 - endpoint not yet deployed to production
const apiVersion = apiVersionUtils.getApiVersion();
if (apiVersion === 'v2') {
return [];
}

const result = await this.request<IDaoPolicy[]>(
this.urls.daoPolicies,
params,
Expand Down
4 changes: 2 additions & 2 deletions src/shared/components/errorFeedback/errorFeedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -42,7 +42,7 @@ export const ErrorFeedback: React.FC<IErrorFeedbackProps> = (props) => {
hideReportButton,
} = props;

const { t } = useTranslations();
const { t } = useSafeTranslations();

const reportIssueButton = {
label: t('app.shared.errorFeedback.link.report'),
Expand Down
1 change: 1 addition & 0 deletions src/shared/components/translationsProvider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export {
type ITranslationsProviderProps,
type TranslationFunction,
TranslationsProvider,
useSafeTranslations,
useTranslations,
} from './translationsProvider';
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
131 changes: 131 additions & 0 deletions src/shared/utils/errorUtils/errorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { message: 'error 1' };
const error2: Record<string, unknown> = { 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<string, unknown> = { 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);
});
});
});
Loading