diff --git a/apps/cowswap-frontend/src/cow-react/index.tsx b/apps/cowswap-frontend/src/cow-react/index.tsx index 9edd93b728c..56c856379ae 100644 --- a/apps/cowswap-frontend/src/cow-react/index.tsx +++ b/apps/cowswap-frontend/src/cow-react/index.tsx @@ -1,15 +1,16 @@ import '@reach/dialog/styles.css' import { Provider as AtomProvider } from 'jotai' -import { type ReactNode, StrictMode } from 'react' +import { Component, type ErrorInfo, type PropsWithChildren, type ReactNode, StrictMode } from 'react' import './sentry' import { CowAnalyticsProvider, createNoopCowAnalytics, initGtm } from '@cowprotocol/analytics' -import { isInjectedWidget, nodeRemoveChildFix } from '@cowprotocol/common-utils' +import { isInjectedWidget, nodeRemoveChildFix, normalizeError } from '@cowprotocol/common-utils' import { jotaiStore } from '@cowprotocol/core' import { SnackbarsWidget } from '@cowprotocol/snackbars' import { WalletProvider, Web3Provider } from '@cowprotocol/wallet' import { Messages } from '@lingui/core' +import * as Sentry from '@sentry/react' import { useInjectedWidgetParams } from 'entities/injectedWidget' import { LanguageProvider } from 'i18n' import { createRoot } from 'react-dom/client' @@ -57,6 +58,83 @@ interface MainProps { localeMessages: Messages | undefined } +interface RootCrashBoundaryState { + error: Error | null + eventId: string | null +} + +interface RootCrashFallbackProps { + error: Error + eventId: string | null +} + +class RootCrashBoundary extends Component { + override state: RootCrashBoundaryState = { error: null, eventId: null } + + static getDerivedStateFromError(error: Error): RootCrashBoundaryState { + return { error, eventId: null } + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + const eventId = Sentry.captureException(error, { + tags: { errorBoundary: 'root' }, + contexts: { react: { componentStack: errorInfo.componentStack ?? undefined } }, + }) + + this.setState({ error, eventId }) + } + + override render(): ReactNode { + const { error, eventId } = this.state + + if (error) { + return + } + + return this.props.children + } +} + +function RootCrashFallback({ error, eventId }: RootCrashFallbackProps): ReactNode { + return ( +
+
+

Something went wrong

+

Reload the page. If it keeps failing, contact support.

+ +

+ {eventId ? `Event ID: ${eventId}` : error.message} +

+
+
+ ) +} + export function Main({ localeMessages }: MainProps): ReactNode { return ( @@ -107,16 +185,16 @@ async function initApp(): Promise { const root = createRoot(container) try { const localeMessages = await loadActiveLocaleMessages() - root.render(
) - } catch (err) { - console.error('Failed to init app', err) - const message = err instanceof Error ? err.message : String(err) root.render( -
-

Failed to load

-

{message}

-
, + +
+ , ) + } catch (err: unknown) { + const error = normalizeError(err) + console.error('Failed to init app', error) + const eventId = Sentry.captureException(error, { tags: { errorBoundary: 'root-init' } }) + root.render() } } diff --git a/apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx b/apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx index cbd2f8525db..44330a61512 100644 --- a/apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx +++ b/apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx @@ -2,7 +2,14 @@ import { Fragment, ReactNode } from 'react' import { CHAIN_INFO } from '@cowprotocol/common-const' import { styled } from '@cowprotocol/common-hooks' -import { getEtherscanLink, getExplorerAddressLink, getExplorerLabel, shortenAddress } from '@cowprotocol/common-utils' +import { + captureError, + getEtherscanLink, + getExplorerAddressLink, + getExplorerLabel, + normalizeError, + shortenAddress, +} from '@cowprotocol/common-utils' import { Command } from '@cowprotocol/types' import { ExternalLink } from '@cowprotocol/ui' import { @@ -104,7 +111,20 @@ export function AccountDetails({ const walletConnectSuffix = isWalletConnect && walletDetails?.walletName ? ` ` + t`(via WalletConnect)` : '' const handleDisconnectClick = async (): Promise => { - await disconnectWallet() + try { + await disconnectWallet() + } catch (err: unknown) { + const error = normalizeError(err) + + captureError( + error, + undefined, + { source: 'AccountDetails.handleDisconnectClick' }, + { errorType: 'walletDisconnect' }, + ) + console.debug('[AccountDetails] wallet disconnect failed', error) + } + handleCloseOrdersPanel() dispatch(updateSelectedWallet({ wallet: undefined })) } diff --git a/libs/wallet/src/api/hooks/useWalletCapabilities.test.ts b/libs/wallet/src/api/hooks/useWalletCapabilities.test.ts index 50334be38b9..723f6f7b8d8 100644 --- a/libs/wallet/src/api/hooks/useWalletCapabilities.test.ts +++ b/libs/wallet/src/api/hooks/useWalletCapabilities.test.ts @@ -1,38 +1,48 @@ -import { shouldCheckCapabilities } from './useWalletCapabilities.utils' - -const desktopEnvironment = { - isInjectedMobileBrowser: false, - isInjectedWidget: false, - isMobile: false, -} - -const mobileEnvironment = { - ...desktopEnvironment, - isMobile: true, -} - -describe('shouldCheckCapabilities', () => { - it('checks capabilities for desktop injected wallets', () => { - expect(shouldCheckCapabilities(false, { isLoading: false }, desktopEnvironment)).toBe(true) - }) +import { act, renderHook } from '@testing-library/react' +import { useCapabilities } from 'wagmi' - it('skips capabilities for injected mobile browsers', () => { - expect( - shouldCheckCapabilities(false, { isLoading: false }, { ...mobileEnvironment, isInjectedMobileBrowser: true }), - ).toBe(false) - }) +import { useWalletCapabilities } from './useWalletCapabilities' + +jest.mock('wagmi', () => ({ + useCapabilities: jest.fn(), +})) + +jest.mock('../../wagmi/hooks/useWalletMetadata', () => ({ + useIsSafeViaWc: jest.fn(() => false), +})) + +jest.mock('../hooks', () => ({ + useWalletInfo: jest.fn(() => ({ account: '0x0000000000000000000000000000000000000001', chainId: 1 })), +})) + +describe('useWalletCapabilities', () => { + const mockUseCapabilities = useCapabilities as jest.MockedFunction - it('skips capabilities for mobile WalletConnect sessions', () => { - expect(shouldCheckCapabilities(true, { isLoading: false }, mobileEnvironment)).toBe(false) + beforeEach(() => { + jest.useFakeTimers() + mockUseCapabilities.mockReturnValue({ data: undefined, isLoading: true } as ReturnType) }) - it('treats null widget metadata as regular injected wallet metadata', () => { - expect(shouldCheckCapabilities(false, { data: null, isLoading: false }, desktopEnvironment)).toBe(true) + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() }) - it('waits for widget provider metadata before deciding on mobile', () => { - expect(shouldCheckCapabilities(false, { isLoading: true }, { ...mobileEnvironment, isInjectedWidget: true })).toBe( - false, - ) + it('stops reporting loading when capabilities do not settle before timeout', () => { + const { result } = renderHook(() => useWalletCapabilities()) + + expect(result.current.isLoading).toBe(true) + + act(() => { + jest.advanceTimersByTime(4_999) + }) + + expect(result.current.isLoading).toBe(true) + + act(() => { + jest.advanceTimersByTime(1) + }) + + expect(result.current.isLoading).toBe(false) }) }) diff --git a/libs/wallet/src/api/hooks/useWalletCapabilities.ts b/libs/wallet/src/api/hooks/useWalletCapabilities.ts index 9a66c868403..5f2f5c91531 100644 --- a/libs/wallet/src/api/hooks/useWalletCapabilities.ts +++ b/libs/wallet/src/api/hooks/useWalletCapabilities.ts @@ -1,18 +1,11 @@ -import { useCallback, useMemo } from 'react' - -import { isInjectedWidget, isMobile } from '@cowprotocol/common-utils' +import { useCallback, useEffect, useMemo, useState } from 'react' +import ms from 'ms.macro' import { useCapabilities } from 'wagmi' -import { shouldCheckCapabilities } from './useWalletCapabilities.utils' -import { useWidgetProviderMetaInfo } from './useWidgetProviderMetaInfo' - -import { useIsWalletConnect } from '../../wagmi/hooks/useIsWalletConnect' import { useIsSafeViaWc } from '../../wagmi/hooks/useWalletMetadata' import { useWalletInfo } from '../hooks' -import { getIsInjectedMobileBrowser } from '../utils/connection' -import type { WalletCapabilitiesEnvironment } from './useWalletCapabilities.utils' import type { GetCapabilitiesData } from '@wagmi/core/query' export type WalletCapabilities = { @@ -20,33 +13,17 @@ export type WalletCapabilities = { atomicBatch?: { supported: boolean } } -function getWalletCapabilitiesEnvironment(): WalletCapabilitiesEnvironment { - return { - isInjectedMobileBrowser: getIsInjectedMobileBrowser(), - isInjectedWidget: isInjectedWidget(), - isMobile, - } -} +const WALLET_CAPABILITIES_LOADING_TIMEOUT = ms`5s` export function useWalletCapabilities(): { data: WalletCapabilities | undefined; isLoading: boolean } { - const isWalletConnect = useIsWalletConnect() - const widgetProviderMetaInfo = useWidgetProviderMetaInfo() const { chainId, account } = useWalletInfo() const isSafeViaWc = useIsSafeViaWc() - const shouldFetchCapabilities = useMemo( - () => - Boolean( - shouldCheckCapabilities(isWalletConnect, widgetProviderMetaInfo, getWalletCapabilitiesEnvironment()) && - account && - chainId, - ), - [isWalletConnect, widgetProviderMetaInfo, account, chainId], - ) + const shouldFetchCapabilities = useMemo(() => Boolean(account && chainId), [account, chainId]) const select = useCallback( (capabilities: GetCapabilitiesData) => { - if (!capabilities || !chainId) return undefined as WalletCapabilities | undefined + if (!capabilities || !chainId) return undefined // Only apply the Safe wallet fallback (first-entry) when connected via Safe WalletConnect, // since Safe's wallet_getCapabilities response may omit the chain ID key. @@ -62,7 +39,7 @@ export function useWalletCapabilities(): { data: WalletCapabilities | undefined; // Fetch capabilities for all chains (no chainId filter) so we can apply // the Safe wallet fallback: if the exact chain is missing, use the first entry. // See https://github.com/safe-global/safe-wallet-monorepo/issues/6906 - return useCapabilities({ + const capabilitiesState = useCapabilities({ account, query: { enabled: shouldFetchCapabilities, @@ -74,4 +51,30 @@ export function useWalletCapabilities(): { data: WalletCapabilities | undefined; select, }, }) + + const [hasLoadingTimedOut, setHasLoadingTimedOut] = useState(false) + + useEffect(() => { + if (!shouldFetchCapabilities || !capabilitiesState.isLoading) { + console.debug('[COW][WalletCapabilities]', 'Wallet capabilities timeout reset') + setHasLoadingTimedOut(false) + return + } + + const timeoutId = setTimeout(() => { + setHasLoadingTimedOut(true) + console.warn( + '[COW][WalletCapabilities]', + `Wallet capabilities loading timed out after ${WALLET_CAPABILITIES_LOADING_TIMEOUT / 1000}s`, + ) + }, WALLET_CAPABILITIES_LOADING_TIMEOUT) + + return () => clearTimeout(timeoutId) + }, [shouldFetchCapabilities, capabilitiesState.isLoading]) + + if (hasLoadingTimedOut && capabilitiesState.isLoading) { + return { data: capabilitiesState.data, isLoading: false } + } + + return capabilitiesState } diff --git a/libs/wallet/src/api/hooks/useWalletCapabilities.utils.ts b/libs/wallet/src/api/hooks/useWalletCapabilities.utils.ts deleted file mode 100644 index 9a7ac7c1277..00000000000 --- a/libs/wallet/src/api/hooks/useWalletCapabilities.utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface WidgetProviderMetaInfoState { - data?: { providerWcMetadata?: unknown } | null - isLoading: boolean -} - -export interface WalletCapabilitiesEnvironment { - isInjectedMobileBrowser: boolean - isInjectedWidget: boolean - isMobile: boolean -} - -export function shouldCheckCapabilities( - isWalletConnect: boolean, - { data, isLoading }: WidgetProviderMetaInfoState, - environment: WalletCapabilitiesEnvironment, -): boolean { - // When widget in the mobile device, wait till providerWcMetadata is loaded - // In order to detect if is connected to WalletConnect - if (environment.isInjectedWidget && environment.isMobile && isLoading) { - return false - } - - const isWalletConnectViaWidget = Boolean(data?.providerWcMetadata) - - if ((isWalletConnect || isWalletConnectViaWidget) && environment.isMobile) { - return false - } - - // Some injected mobile browsers expose wallet_getCapabilities but never settle the request. - // Treat them as capability-unknown so regular trading is not blocked by WalletCapabilitiesLoading. - if (environment.isInjectedMobileBrowser) { - return false - } - - return true -} diff --git a/patches/@reown__appkit-adapter-wagmi@1.8.19.patch b/patches/@reown__appkit-adapter-wagmi@1.8.19.patch index 09e044de55a..5e38c51e26a 100644 --- a/patches/@reown__appkit-adapter-wagmi@1.8.19.patch +++ b/patches/@reown__appkit-adapter-wagmi@1.8.19.patch @@ -2,7 +2,28 @@ diff --git a/dist/esm/src/client.js b/dist/esm/src/client.js index 79697e342068039131b11f6f47d97702e28c9ff2..0fe9085611f5d0d446fe7803462226f6a13c64fe 100644 --- a/dist/esm/src/client.js +++ b/dist/esm/src/client.js -@@ -520,13 +524,14 @@ export class WagmiAdapter extends AdapterBlueprint { +@@ -150,15 +150,18 @@ export class WagmiAdapter extends AdapterBlueprint { + }); + } + if (accountData.status === 'connected') { ++ const connector = accountData.connector; ++ if (!connector) ++ return; + const hasAccountChanged = accountData.address !== prevAccountData?.address; +- const hasConnectorChanged = accountData.connector.id !== prevAccountData.connector?.id; ++ const hasConnectorChanged = connector.id !== prevAccountData.connector?.id; + const hasConnectionStatusChanged = prevAccountData.status !== 'connected'; + if (hasAccountChanged || hasConnectorChanged || hasConnectionStatusChanged) { + this.setupWatchPendingTransactions(); + this.handleAccountChanged({ + address: accountData.address, + chainId: accountData.chainId, +- connector: accountData.connector ++ connector + }); + } + } +@@ -520,13 +523,14 @@ export class WagmiAdapter extends AdapterBlueprint { chainId, token: params.tokens?.[caipNetwork.caipNetworkId]?.address }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ba5b291906..20f04c409ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ packageExtensionsChecksum: sha256-q7R3iA9KyuwX1I24FPBePzkCHJmQXkID7pwH/yC+Cgw= patchedDependencies: '@reown/appkit-adapter-wagmi@1.8.19': - hash: b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6 + hash: e1f6a7396c89aa33c016c902cf5094ec1f92d1a06258ce01542f8139760e5c40 path: patches/@reown__appkit-adapter-wagmi@1.8.19.patch importers: @@ -659,7 +659,7 @@ importers: version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(use-sync-external-store@1.5.0(react@19.1.2))(utf-8-validate@6.0.6)(zod@3.25.76) '@reown/appkit-adapter-wagmi': specifier: 1.8.19 - version: 1.8.19(patch_hash=b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6)(725f042c52896b22a29c385487b96d7a) + version: 1.8.19(patch_hash=e1f6a7396c89aa33c016c902cf5094ec1f92d1a06258ce01542f8139760e5c40)(725f042c52896b22a29c385487b96d7a) '@reown/appkit-controllers': specifier: 1.8.19 version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.1.2)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) @@ -1269,7 +1269,7 @@ importers: version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(use-sync-external-store@1.5.0(react@19.1.2))(utf-8-validate@6.0.6)(zod@3.25.76) '@reown/appkit-adapter-wagmi': specifier: 1.8.19 - version: 1.8.19(patch_hash=b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6)(725f042c52896b22a29c385487b96d7a) + version: 1.8.19(patch_hash=e1f6a7396c89aa33c016c902cf5094ec1f92d1a06258ce01542f8139760e5c40)(725f042c52896b22a29c385487b96d7a) '@reown/appkit-controllers': specifier: 1.8.19 version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.1.2)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) @@ -2109,7 +2109,7 @@ importers: version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(use-sync-external-store@1.5.0(react@19.1.2))(utf-8-validate@6.0.6)(zod@3.25.76) '@reown/appkit-adapter-wagmi': specifier: 1.8.19 - version: 1.8.19(patch_hash=b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6)(725f042c52896b22a29c385487b96d7a) + version: 1.8.19(patch_hash=e1f6a7396c89aa33c016c902cf5094ec1f92d1a06258ce01542f8139760e5c40)(725f042c52896b22a29c385487b96d7a) '@reown/appkit-common': specifier: 1.8.19 version: 1.8.19(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) @@ -22815,7 +22815,7 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-adapter-wagmi@1.8.19(patch_hash=b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6)(725f042c52896b22a29c385487b96d7a)': + '@reown/appkit-adapter-wagmi@1.8.19(patch_hash=e1f6a7396c89aa33c016c902cf5094ec1f92d1a06258ce01542f8139760e5c40)(725f042c52896b22a29c385487b96d7a)': dependencies: '@reown/appkit': 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(use-sync-external-store@1.5.0(react@19.1.2))(utf-8-validate@6.0.6)(zod@3.25.76) '@reown/appkit-common': 1.8.19(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76)