Commit 55ac1eab authored by tom goriunov's avatar tom goriunov Committed by GitHub

navigation doesn't work on error pages (#1519)

* navigation doesn't work on error pages

Fixes #1518

* unify API resource thrown errors and disable Sentry logging for them

* test fix
parent c7dc03bb
......@@ -57,6 +57,7 @@ export default function useApiFetch() {
},
{
resource: resource.path,
omitSentryErrorLog: true, // disable logging of API errors to Sentry
},
);
}, [ fetch, csrfToken ]);
......
export default function throwOnAbsentParamError(param: unknown) {
if (!param) {
throw new Error('Required param not provided', { cause: { status: 404 } });
}
}
import type { ResourceError, ResourceName } from 'lib/api/resources';
type Params = ({
isError: true;
error: ResourceError<unknown>;
} | {
isError: false;
error: null;
}) & {
resource?: ResourceName;
}
export const RESOURCE_LOAD_ERROR_MESSAGE = 'Resource load error';
export default function throwOnResourceLoadError({ isError, error, resource }: Params) {
if (isError) {
throw Error(RESOURCE_LOAD_ERROR_MESSAGE, { cause: { ...error, resource } as unknown as Error });
}
}
......@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
import appConfig from 'configs/app';
import { RESOURCE_LOAD_ERROR_MESSAGE } from 'lib/errors/throwOnResourceLoadError';
const feature = appConfig.features.sentry;
......@@ -59,6 +60,9 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
'The quota has been exceeded',
'Attempt to connect to relay via',
'WebSocket connection failed for URL: wss://relay.walletconnect.com',
// API errors
RESOURCE_LOAD_ERROR_MESSAGE,
],
denyUrls: [
// Facebook flakiness
......
......@@ -7,6 +7,7 @@ import type { Address as TAddress } from 'types/api/address';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_COUNTERS } from 'stubs/address';
import AddressCounterItem from 'ui/address/details/AddressCounterItem';
......@@ -68,7 +69,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
const is422Error = addressQuery.isError && 'status' in addressQuery.error && addressQuery.error.status === 422;
if (addressQuery.isError && is422Error) {
throw Error('Address fetch error', { cause: addressQuery.error as unknown as Error });
throwOnResourceLoadError(addressQuery);
}
if (addressQuery.isError && !is404Error) {
......
......@@ -15,6 +15,7 @@ import type { ResourceError } from 'lib/api/resources';
import getBlockReward from 'lib/block/getBlockReward';
import { GWEI, WEI, WEI_IN_GWEI, ZERO } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import { space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getQueryParamString from 'lib/router/getQueryParamString';
......@@ -68,12 +69,8 @@ const BlockDetails = ({ query }: Props) => {
}, [ data, router ]);
if (isError) {
if (error?.status === 404) {
throw Error('Block not found', { cause: error as unknown as Error });
}
if (error?.status === 422) {
throw Error('Invalid block number', { cause: error as unknown as Error });
if (error?.status === 404 || error?.status === 422) {
throwOnResourceLoadError({ isError, error });
}
return <DataFetchAlert/>;
......
......@@ -8,6 +8,8 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { BLOCK } from 'stubs/block';
......@@ -72,14 +74,6 @@ const BlockPageContent = () => {
},
});
if (!heightOrHash) {
throw new Error('Block not found', { cause: { status: 404 } });
}
if (blockQuery.isError) {
throw new Error(undefined, { cause: blockQuery.error });
}
const tabs: Array<RoutedTab> = React.useMemo(() => ([
{ id: 'index', title: 'Details', component: <BlockDetails query={ blockQuery }/> },
{ id: 'txs', title: 'Transactions', component: <TxsWithFrontendSorting query={ blockTxsQuery } showBlockInfo={ false } showSocketInfo={ false }/> },
......@@ -113,6 +107,9 @@ const BlockPageContent = () => {
};
}, [ appProps.referrer ]);
throwOnAbsentParamError(heightOrHash);
throwOnResourceLoadError(blockQuery);
const title = (() => {
switch (blockQuery.data?.type) {
case 'reorg':
......
......@@ -5,6 +5,7 @@ import type { SmartContractVerificationMethod } from 'types/api/contract';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractVerificationForm from 'ui/contractVerification/ContractVerificationForm';
import useFormConfigQuery from 'ui/contractVerification/useFormConfigQuery';
......@@ -27,9 +28,7 @@ const ContractVerificationForAddress = () => {
},
});
if (contractQuery.isError && contractQuery.error.status === 404) {
throw Error('Not found', { cause: contractQuery.error as unknown as Error });
}
throwOnResourceLoadError(contractQuery);
const configQuery = useFormConfigQuery(Boolean(hash));
......
......@@ -8,11 +8,12 @@ import type { CsvExportParams } from 'types/client/address';
import type { ResourceName } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import CsvExportForm from 'ui/csvExport/CsvExportForm';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -62,7 +63,8 @@ const CsvExport = () => {
const isMobile = useIsMobile();
const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || '';
const exportTypeParam = router.query.type?.toString() || '';
const exportType = isCorrectExportType(exportTypeParam) ? EXPORT_TYPES[exportTypeParam] : null;
const filterTypeFromQuery = router.query.filterType?.toString() || null;
const filterValueFromQuery = router.query.filterValue?.toString();
......@@ -86,17 +88,20 @@ const CsvExport = () => {
};
}, [ appProps.referrer ]);
if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) {
throw Error('Not found', { cause: { status: 404 } });
throwOnAbsentParamError(addressHash);
throwOnAbsentParamError(exportType);
if (!exportType) {
return null;
}
const filterType = filterTypeFromQuery === EXPORT_TYPES[exportType].filterType ? filterTypeFromQuery : null;
const filterType = filterTypeFromQuery === exportType.filterType ? filterTypeFromQuery : null;
const filterValue = (() => {
if (!filterType || !filterValueFromQuery) {
return null;
}
if (EXPORT_TYPES[exportType].filterValues && !EXPORT_TYPES[exportType].filterValues?.includes(filterValueFromQuery)) {
if (exportType.filterValues && !exportType.filterValues?.includes(filterValueFromQuery)) {
return null;
}
......@@ -104,9 +109,7 @@ const CsvExport = () => {
})();
const content = (() => {
if (addressQuery.isError) {
return <DataFetchAlert/>;
}
throwOnResourceLoadError(addressQuery);
if (addressQuery.isPending) {
return <ContentLoader/>;
......@@ -115,10 +118,10 @@ const CsvExport = () => {
return (
<CsvExportForm
hash={ addressHash }
resource={ EXPORT_TYPES[exportType].resource }
resource={ exportType.resource }
filterType={ filterType }
filterValue={ filterValue }
fileNameTemplate={ EXPORT_TYPES[exportType].fileNameTemplate }
fileNameTemplate={ exportType.fileNameTemplate }
/>
);
})();
......@@ -130,7 +133,7 @@ const CsvExport = () => {
backLink={ backLink }
/>
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { EXPORT_TYPES[exportType].text } for address </span>
<span>Export { exportType.text } for address </span>
<AddressEntity
address={{ hash: addressHash, is_contract: true, implementation_name: null }}
truncation={ isMobile ? 'constant' : 'dynamic' }
......@@ -139,7 +142,7 @@ const CsvExport = () => {
<span>{ nbsp }</span>
{ filterType && filterValue && <span>with applied filter by { filterType } ({ filterValue }) </span> }
<span>to CSV file. </span>
<span>Exports are limited to the last 10K { EXPORT_TYPES[exportType].text }.</span>
<span>Exports are limited to the last 10K { exportType.text }.</span>
</Flex>
{ content }
</>
......
......@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceCategoriesMenu from 'ui/marketplace/MarketplaceCategoriesMenu';
import MarketplaceDisclaimerModal from 'ui/marketplace/MarketplaceDisclaimerModal';
......@@ -32,9 +33,7 @@ const Marketplace = () => {
showDisclaimer,
} = useMarketplace();
if (isError) {
throw new Error('Unable to get apps list', { cause: error });
}
throwOnResourceLoadError(isError && error ? { isError, error } : { isError: false, error: null });
if (!feature.isEnabled) {
return null;
......
......@@ -10,6 +10,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useApiFetch from 'lib/hooks/useFetch';
import * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString';
......@@ -100,7 +101,7 @@ const MarketplaceApp = () => {
const router = useRouter();
const id = getQueryParamString(router.query.id);
const { isPending, isError, error, data } = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
const query = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
queryKey: [ 'marketplace-apps', id ],
queryFn: async() => {
const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(configUrl, undefined, { resource: 'marketplace-apps' });
......@@ -116,6 +117,7 @@ const MarketplaceApp = () => {
},
enabled: feature.isEnabled,
});
const { data, isPending } = query;
useEffect(() => {
if (data) {
......@@ -126,9 +128,7 @@ const MarketplaceApp = () => {
}
}, [ data ]);
if (isError) {
throw new Error('Unable to load app', { cause: error });
}
throwOnResourceLoadError(query);
return (
<DappscoutIframeProvider
......
......@@ -8,6 +8,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ENS_DOMAIN } from 'stubs/ENS';
......@@ -42,9 +43,7 @@ const NameDomain = () => {
const tabIndex = useTabIndexFromQuery(tabs);
if (infoQuery.isError) {
throw new Error(undefined, { cause: infoQuery.error });
}
throwOnResourceLoadError(infoQuery);
const isLoading = infoQuery.isPlaceholderData;
......
......@@ -7,6 +7,7 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata';
import * as regexp from 'lib/regexp';
......@@ -129,9 +130,7 @@ const TokenInstanceContent = () => {
) },
].filter(Boolean);
if (tokenInstanceQuery.isError) {
throw Error('Token instance fetch failed', { cause: tokenInstanceQuery.error });
}
throwOnResourceLoadError(tokenInstanceQuery);
const tokenTag = <Tag isLoading={ tokenInstanceQuery.isPlaceholderData }>{ tokenQuery.data?.type }</Tag>;
......
......@@ -5,6 +5,8 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TX_ZKEVM_L2 } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
......@@ -41,13 +43,8 @@ const ZkEvmL2TxnBatch = () => {
},
});
if (!number) {
throw new Error('Tx batch not found', { cause: { status: 404 } });
}
if (batchQuery.isError) {
throw new Error(undefined, { cause: batchQuery.error });
}
throwOnAbsentParamError(number);
throwOnResourceLoadError(batchQuery);
const tabs: Array<RoutedTab> = React.useMemo(() => ([
{ id: 'index', title: 'Details', component: <ZkEvmL2TxnBatchDetails query={ batchQuery }/> },
......
......@@ -37,7 +37,7 @@ test('status code 500', async({ mount }) => {
});
test('invalid tx hash', async({ mount }) => {
const error = { message: 'Invalid tx hash', cause: { status: 404 } } as Error;
const error = { message: 'Invalid tx hash', cause: { status: 422, resource: 'tx' } } as Error;
const component = await mount(
<TestApp>
<AppError error={ error }/>
......
......@@ -3,6 +3,7 @@ import React from 'react';
import { route } from 'nextjs-routes';
import getErrorCause from 'lib/errors/getErrorCause';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
......@@ -36,6 +37,7 @@ const ERROR_TEXTS: Record<string, { title: string; text: string }> = {
const AppError = ({ error, className }: Props) => {
const content = (() => {
const resourceErrorPayload = getResourceErrorPayload(error);
const cause = getErrorCause(error);
const messageInPayload =
resourceErrorPayload &&
typeof resourceErrorPayload === 'object' &&
......@@ -43,8 +45,9 @@ const AppError = ({ error, className }: Props) => {
typeof resourceErrorPayload.message === 'string' ?
resourceErrorPayload.message :
undefined;
const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error);
const isInvalidTxHash = error?.message?.includes('Invalid tx hash');
const isInvalidTxHash = cause && 'resource' in cause && cause.resource === 'tx' && statusCode === 422;
const isBlockConsensus = messageInPayload?.includes('Block lost consensus');
if (isInvalidTxHash) {
......@@ -62,8 +65,6 @@ const AppError = ({ error, className }: Props) => {
return <AppErrorBlockConsensus hash={ hash }/>;
}
const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error);
switch (statusCode) {
case 429: {
return <AppErrorTooManyRequests/>;
......
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import React from 'react';
interface Props {
......@@ -6,21 +8,33 @@ interface Props {
onError?: (error: Error) => void;
}
interface PropsWithRouter extends Props {
router: NextRouter;
}
interface State {
hasError: boolean;
error?: Error;
errorPathname?: string;
}
class ErrorBoundary extends React.PureComponent<Props, State> {
class ErrorBoundary extends React.PureComponent<PropsWithRouter, State> {
state: State = {
hasError: false,
};
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
static getDerivedStateFromProps(props: PropsWithRouter, state: State) {
if (state.hasError && state.errorPathname) {
if (props.router.pathname !== state.errorPathname) {
return { hasError: false, error: undefined, errorPathname: undefined };
}
}
return null;
}
componentDidCatch(error: Error) {
this.setState({ hasError: true, error, errorPathname: this.props.router.pathname });
this.props.onError?.(error);
}
......@@ -33,4 +47,9 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
}
}
export default ErrorBoundary;
const WrappedErrorBoundary = (props: Props) => {
const router = useRouter();
return <ErrorBoundary { ...props } router={ router }/>;
};
export default React.memo(WrappedErrorBoundary);
......@@ -7,15 +7,15 @@ import { NETWORK_GROUPS } from 'types/networks';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch';
export default function useNetworkMenu() {
const { isOpen, onClose, onOpen, onToggle } = useDisclosure();
const apiFetch = useApiFetch();
const fetch = useFetch();
const { isPending, data } = useQuery<unknown, ResourceError<unknown>, Array<FeaturedNetwork>>({
queryKey: [ 'featured-network' ],
queryFn: async() => apiFetch(config.UI.sidebar.featuredNetworks || '', undefined, { resource: 'featured-network' }),
queryFn: async() => fetch(config.UI.sidebar.featuredNetworks || '', undefined, { resource: 'featured-network' }),
enabled: Boolean(config.UI.sidebar.featuredNetworks) && isOpen,
staleTime: Infinity,
});
......
......@@ -5,6 +5,8 @@ import type { SmartContract } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import ContentLoader from 'ui/shared/ContentLoader';
interface Props {
......@@ -59,23 +61,15 @@ const Sol2UmlDiagram = ({ addressHash }: Props) => {
newWindow?.document.write(image.outerHTML);
}, [ imgUrl ]);
if (!addressHash) {
throw Error('Contract address is not provided', { cause: { status: 404 } as unknown as Error });
}
if (contractQuery.isError) {
throw Error('Contract fetch error', { cause: contractQuery.error as unknown as Error });
}
if (umlQuery.isError) {
throw Error('Uml diagram fetch error', { cause: contractQuery.error as unknown as Error });
}
throwOnAbsentParamError(addressHash);
throwOnResourceLoadError(contractQuery);
throwOnResourceLoadError(umlQuery);
if (contractQuery.isPending || umlQuery.isPending) {
return <ContentLoader/>;
}
if (!umlQuery.data.svg) {
if (!umlQuery.data?.svg || !contractQuery.data) {
return <span>No data for visualization</span>;
}
......
......@@ -9,6 +9,7 @@ import type { TokenInfo } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getCurrencyValue from 'lib/getCurrencyValue';
import { TOKEN_COUNTERS } from 'stubs/token';
import type { TokenTabs } from 'ui/pages/Token';
......@@ -63,9 +64,7 @@ const TokenDetails = ({ tokenQuery }: Props) => {
);
}, [ tokenCountersQuery.data, tokenCountersQuery.isPlaceholderData, changeUrlAndScroll ]);
if (tokenQuery.isError) {
throw Error('Token fetch error', { cause: tokenQuery.error as unknown as Error });
}
throwOnResourceLoadError(tokenQuery);
const {
exchange_rate: exchangeRate,
......
......@@ -23,6 +23,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
import { currencyUnits } from 'lib/units';
......@@ -71,12 +72,8 @@ const TxDetails = () => {
const executionSuccessIconColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
if (isError) {
if (error?.status === 422) {
throw Error('Invalid tx hash', { cause: error as unknown as Error });
}
if (error?.status === 404) {
throw Error('Tx not found', { cause: error as unknown as Error });
if (error?.status === 422 || error?.status === 404) {
throwOnResourceLoadError({ isError, error, resource: 'tx' });
}
return <DataFetchAlert/>;
......
......@@ -10,6 +10,7 @@ import { route } from 'nextjs-routes';
import type { ResourceError } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -43,12 +44,8 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
}, [ data, router ]);
if (isError) {
if (error?.status === 404) {
throw Error('Tx Batch not found', { cause: error as unknown as Error });
}
if (error?.status === 422) {
throw Error('Invalid tx batch number', { cause: error as unknown as Error });
if (error?.status === 404 || error?.status === 422) {
throwOnResourceLoadError({ isError, error });
}
return <DataFetchAlert/>;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment