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
55 changes: 31 additions & 24 deletions pages/_app.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import dynamic from 'next/dynamic';
import Head from 'next/head';
import { ReactNode, useEffect, useState } from 'react';
import { AddressBlocked } from 'src/components/AddressBlocked';
import { TransactionErrorBoundary } from 'src/components/ErrorBoundary/TransactionErrorBoundary';
import { Meta } from 'src/components/Meta';
import { TransactionEventHandler } from 'src/components/TransactionEventHandler';
import { GasStationProvider } from 'src/components/transactions/GasStation/GasStationProvider';
Expand All @@ -21,6 +22,7 @@ import { AppDataProvider } from 'src/hooks/app-data-provider/useAppDataProvider'
import { CowOrderToastProvider } from 'src/hooks/useCowOrderToast';
import { ModalContextProvider } from 'src/hooks/useModal';
import { Web3ContextProvider } from 'src/libs/web3-data-provider/Web3Provider';
import { WalletGuard } from 'src/providers/WalletGuard';
import { useRootStore } from 'src/store/root';
import { SharedDependenciesProvider } from 'src/ui-config/SharedDependenciesProvider';
import { wagmiConfig } from 'src/ui-config/wagmiConfig';
Expand Down Expand Up @@ -99,6 +101,7 @@ interface MyAppProps extends AppProps {
emotionCache?: EmotionCache;
Component: NextPageWithLayout;
}

export default function MyApp(props: MyAppProps) {
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
const getLayout = Component.getLayout ?? ((page: ReactNode) => page);
Expand Down Expand Up @@ -154,29 +157,33 @@ export default function MyApp(props: MyAppProps) {
<AddressBlocked>
<CowOrderToastProvider>
<ModalContextProvider>
<SharedDependenciesProvider>
<AppDataProvider>
<GasStationProvider>
{getLayout(<Component {...pageProps} />)}
<SupplyModal />
<WithdrawModal />
<BorrowModal />
<RepayModal />
<CollateralChangeModal />
<DebtSwitchModal />
<ClaimRewardsModal />
<EmodeModal />
<SwapModal />
<FaucetModal />
<TransactionEventHandler />
<SwitchModal />
<StakingMigrateModal />
<BridgeModal />
<ReadOnlyModal />
<CowOrderToast />
</GasStationProvider>
</AppDataProvider>
</SharedDependenciesProvider>
<WalletGuard>
<SharedDependenciesProvider>
<AppDataProvider>
<GasStationProvider>
{getLayout(<Component {...pageProps} />)}
<TransactionErrorBoundary>
<SupplyModal />
<WithdrawModal />
<BorrowModal />
<RepayModal />
<CollateralChangeModal />
<DebtSwitchModal />
<ClaimRewardsModal />
<EmodeModal />
<SwapModal />
<FaucetModal />
<SwitchModal />
<StakingMigrateModal />
<BridgeModal />
</TransactionErrorBoundary>
<TransactionEventHandler />
<ReadOnlyModal />
<CowOrderToast />
</GasStationProvider>
</AppDataProvider>
</SharedDependenciesProvider>
</WalletGuard>
</ModalContextProvider>
</CowOrderToastProvider>
</AddressBlocked>
Expand All @@ -191,4 +198,4 @@ export default function MyApp(props: MyAppProps) {
</NoSsr>
</CacheProvider>
);
}
}
121 changes: 121 additions & 0 deletions src/components/ErrorBoundary/TransactionErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Trans } from '@lingui/macro';
import { Alert, Box, Button, Typography } from '@mui/material';
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton';

interface Props {
children: ReactNode;
fallback?: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}

/**
* TransactionErrorBoundary
*
* Catches errors that occur within transaction modals and provides
* a graceful recovery mechanism. This prevents the entire app from
* crashing when wallet-related errors occur.
*
* Specifically handles:
* - Wallet disconnection errors
* - Transaction failures
* - Unexpected modal state errors
*/
export class TransactionErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}

static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI
return { hasError: true, error, errorInfo: null };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error details for debugging
console.error('[TransactionErrorBoundary] Caught error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}

handleReset = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
};

render() {
if (this.state.hasError) {
// Check if the error is wallet-related
const isWalletError =
this.state.error?.message?.toLowerCase().includes('wallet') ||
this.state.error?.message?.toLowerCase().includes('user') ||
this.state.error?.message?.toLowerCase().includes('account');

// Use custom fallback if provided
if (this.props.fallback) {
return <>{this.props.fallback}</>;
}

// Default error UI
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
minHeight: 200,
}}
>
<Alert severity="error" sx={{ mb: 3, width: '100%' }}>
<Typography variant="h6" gutterBottom>
<Trans>Something went wrong</Trans>
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{isWalletError ? (
<Trans>
There was an issue with your wallet connection. Please reconnect your wallet and try again.
</Trans>
) : (
<Trans>
An unexpected error occurred. Please refresh the page and try again.
</Trans>
)}
</Typography>
{process.env.NODE_ENV === 'development' && this.state.error && (
<Typography variant="caption" sx={{ fontFamily: 'monospace', display: 'block', mt: 1 }}>
{this.state.error.toString()}
</Typography>
)}
</Alert>

<Box sx={{ display: 'flex', gap: 2 }}>
{isWalletError ? (
<ConnectWalletButton />
) : (
<Button variant="contained" onClick={this.handleReset}>
<Trans>Try Again</Trans>
</Button>
)}
<Button
variant="outlined"
onClick={() => window.location.reload()}
>
<Trans>Refresh Page</Trans>
</Button>
</Box>
</Box>
);
}

return this.props.children;
}
}
20 changes: 17 additions & 3 deletions src/components/UserAuthenticated.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
import { Box, CircularProgress } from '@mui/material';
import { Trans } from '@lingui/macro';
import { Box, CircularProgress, Typography } from '@mui/material';
import React, { ReactNode } from 'react';
import {
ExtendedFormattedUser,
useAppDataContext,
} from 'src/hooks/app-data-provider/useAppDataProvider';
import invariant from 'tiny-invariant';
import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton';

interface UserAuthenticatedProps {
children: (user: ExtendedFormattedUser) => ReactNode;
}

export const UserAuthenticated = ({ children }: UserAuthenticatedProps) => {
const { user, loading } = useAppDataContext();

if (loading) {
return (
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
invariant(user, 'User data loaded but no user found');

// Handle disconnection gracefully instead of crashing
if (!user) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', mt: 4, alignItems: 'center' }}>
<Typography sx={{ mb: 6, textAlign: 'center' }} color="text.secondary">
<Trans>Please connect your wallet to continue.</Trans>
</Typography>
<ConnectWalletButton />
</Box>
);
}

return <>{children(user)}</>;
};
35 changes: 24 additions & 11 deletions src/components/transactions/Supply/SupplyModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Trans } from '@lingui/macro';
import { Box, Typography } from '@mui/material';
import React from 'react';
import { UserAuthenticated } from 'src/components/UserAuthenticated';
import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton';
import { ModalContextType, ModalType, useModalContext } from 'src/hooks/useModal';
import { useRootStore } from 'src/store/root';

import { BasicModal } from '../../primitives/BasicModal';
import { ModalWrapper } from '../FlowCommons/ModalWrapper';
Expand All @@ -11,20 +14,30 @@ export const SupplyModal = () => {
const { type, close, args } = useModalContext() as ModalContextType<{
underlyingAsset: string;
}>;
const account = useRootStore((store) => store.account);

return (
<BasicModal open={type === ModalType.Supply} setOpen={close}>
<ModalWrapper
action="supply"
title={<Trans>Supply</Trans>}
underlyingAsset={args.underlyingAsset}
>
{(params) => (
<UserAuthenticated>
{(user) => <SupplyModalContentWrapper {...params} user={user} />}
</UserAuthenticated>
)}
</ModalWrapper>
{!account ? (
<Box sx={{ display: 'flex', flexDirection: 'column', mt: 4, alignItems: 'center' }}>
<Typography sx={{ mb: 6, textAlign: 'center' }} color="text.secondary">
<Trans>Please connect your wallet to supply assets.</Trans>
</Typography>
<ConnectWalletButton onClick={() => close()} />
</Box>
) : (
<ModalWrapper
action="supply"
title={<Trans>Supply</Trans>}
underlyingAsset={args.underlyingAsset}
>
{(params) => (
<UserAuthenticated>
{(user) => <SupplyModalContentWrapper {...params} user={user} />}
</UserAuthenticated>
)}
</ModalWrapper>
)}
</BasicModal>
);
};
37 changes: 37 additions & 0 deletions src/providers/WalletGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useModalContext } from 'src/hooks/useModal';
import { useAccount } from 'wagmi';

/**
* WalletGuard Provider
*
* Monitors wallet connection state and automatically closes modals
* when the wallet disconnects to prevent crashes and stale state.
*
* This prevents the app from crashing when users lock their wallet
* extensions (MetaMask, Rabby, Ambire, etc.) while modals are open.
*/
export const WalletGuard = ({ children }: PropsWithChildren) => {
const { isConnected } = useAccount();
const { close } = useModalContext();
const wasConnectedRef = useRef(false);

useEffect(() => {
// Track if wallet was previously connected
if (isConnected) {
wasConnectedRef.current = true;
return;
}

// Only close modals if wallet was connected and now disconnected
// This prevents closing modals on initial load
if (wasConnectedRef.current && !isConnected) {
console.debug('[WalletGuard] Wallet disconnected, closing modals');
close();
// Reset transaction states are handled by the close function
wasConnectedRef.current = false;
}
}, [isConnected, close]);

return <>{children}</>;
};