diff --git a/pages/_app.page.tsx b/pages/_app.page.tsx index 14ba54c8b3..a4f5abd691 100644 --- a/pages/_app.page.tsx +++ b/pages/_app.page.tsx @@ -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'; @@ -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'; @@ -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); @@ -154,29 +157,33 @@ export default function MyApp(props: MyAppProps) { - - - - {getLayout()} - - - - - - - - - - - - - - - - - - - + + + + + {getLayout()} + + + + + + + + + + + + + + + + + + + + + + @@ -191,4 +198,4 @@ export default function MyApp(props: MyAppProps) { ); -} +} \ No newline at end of file diff --git a/src/components/ErrorBoundary/TransactionErrorBoundary.tsx b/src/components/ErrorBoundary/TransactionErrorBoundary.tsx new file mode 100644 index 0000000000..9f75f390ab --- /dev/null +++ b/src/components/ErrorBoundary/TransactionErrorBoundary.tsx @@ -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 { + 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 ( + + + + Something went wrong + + + {isWalletError ? ( + + There was an issue with your wallet connection. Please reconnect your wallet and try again. + + ) : ( + + An unexpected error occurred. Please refresh the page and try again. + + )} + + {process.env.NODE_ENV === 'development' && this.state.error && ( + + {this.state.error.toString()} + + )} + + + + {isWalletError ? ( + + ) : ( + + )} + + + + ); + } + + return this.props.children; + } +} \ No newline at end of file diff --git a/src/components/UserAuthenticated.tsx b/src/components/UserAuthenticated.tsx index 067925abee..47192d6632 100644 --- a/src/components/UserAuthenticated.tsx +++ b/src/components/UserAuthenticated.tsx @@ -1,10 +1,11 @@ -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; @@ -12,6 +13,7 @@ interface UserAuthenticatedProps { export const UserAuthenticated = ({ children }: UserAuthenticatedProps) => { const { user, loading } = useAppDataContext(); + if (loading) { return ( @@ -19,6 +21,18 @@ export const UserAuthenticated = ({ children }: UserAuthenticatedProps) => { ); } - invariant(user, 'User data loaded but no user found'); + + // Handle disconnection gracefully instead of crashing + if (!user) { + return ( + + + Please connect your wallet to continue. + + + + ); + } + return <>{children(user)}; }; diff --git a/src/components/transactions/Supply/SupplyModal.tsx b/src/components/transactions/Supply/SupplyModal.tsx index 9f6e8b6a06..d7d66b5018 100644 --- a/src/components/transactions/Supply/SupplyModal.tsx +++ b/src/components/transactions/Supply/SupplyModal.tsx @@ -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'; @@ -11,20 +14,30 @@ export const SupplyModal = () => { const { type, close, args } = useModalContext() as ModalContextType<{ underlyingAsset: string; }>; + const account = useRootStore((store) => store.account); return ( - Supply} - underlyingAsset={args.underlyingAsset} - > - {(params) => ( - - {(user) => } - - )} - + {!account ? ( + + + Please connect your wallet to supply assets. + + close()} /> + + ) : ( + Supply} + underlyingAsset={args.underlyingAsset} + > + {(params) => ( + + {(user) => } + + )} + + )} ); }; diff --git a/src/providers/WalletGuard.tsx b/src/providers/WalletGuard.tsx new file mode 100644 index 0000000000..55371ada44 --- /dev/null +++ b/src/providers/WalletGuard.tsx @@ -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}; +}; \ No newline at end of file