Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
123 changes: 113 additions & 10 deletions apps/cowswap-frontend/src/cow-react/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -57,6 +58,108 @@ interface MainProps {
localeMessages: Messages | undefined
}

interface RootCrashBoundaryState {
error: Error | null
eventId: string | null
}

interface RootCrashFallbackProps {
error: Error
eventId: string | null
}

class RootCrashBoundary extends Component<PropsWithChildren, RootCrashBoundaryState> {
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 componentDidMount(): void {
window.addEventListener('error', this.handleWindowError)
window.addEventListener('unhandledrejection', this.handleUnhandledRejection)
}

override componentWillUnmount(): void {
window.removeEventListener('error', this.handleWindowError)
window.removeEventListener('unhandledrejection', this.handleUnhandledRejection)
}

override render(): ReactNode {
const { error, eventId } = this.state

if (error) {
return <RootCrashFallback error={error} eventId={eventId} />
}

return this.props.children
}

private handleWindowError = (event: ErrorEvent): void => {
this.captureGlobalError(event.error, event.message || 'Unhandled window error')
}

private handleUnhandledRejection = (event: PromiseRejectionEvent): void => {
this.captureGlobalError(event.reason, 'Unhandled promise rejection')
}

private captureGlobalError(errorLike: unknown, fallbackMessage: string): void {
const error = normalizeError(errorLike ?? fallbackMessage)
const eventId = Sentry.captureException(error, { tags: { errorBoundary: 'root-global' } })

this.setState({ error, eventId })
}
}

function RootCrashFallback({ error, eventId }: RootCrashFallbackProps): ReactNode {
return (
<main
style={{
minHeight: '100vh',
boxSizing: 'border-box',
padding: 24,
display: 'grid',
placeItems: 'center',
fontFamily: 'sans-serif',
color: '#1f2933',
background: '#f7f8fa',
}}
>
<section style={{ width: '100%', maxWidth: 480 }}>
<h1 style={{ margin: '0 0 12px', fontSize: 28, lineHeight: 1.2 }}>Something went wrong</h1>
<p style={{ margin: '0 0 20px', lineHeight: 1.5 }}>Reload the page. If it keeps failing, contact support.</p>
<button
type="button"
onClick={() => window.location.reload()}
style={{
padding: '10px 14px',
border: 0,
borderRadius: 6,
color: '#fff',
background: '#052b65',
cursor: 'pointer',
fontWeight: 600,
}}
>
Reload
</button>
<p style={{ margin: '20px 0 0', lineHeight: 1.5, color: '#52616f', wordBreak: 'break-word' }}>
{eventId ? `Event ID: ${eventId}` : error.message}
</p>
</section>
</main>
)
}

export function Main({ localeMessages }: MainProps): ReactNode {
return (
<StrictMode>
Expand Down Expand Up @@ -107,16 +210,16 @@ async function initApp(): Promise<void> {
const root = createRoot(container)
try {
const localeMessages = await loadActiveLocaleMessages()
root.render(<Main localeMessages={localeMessages} />)
} catch (err) {
console.error('Failed to init app', err)
const message = err instanceof Error ? err.message : String(err)
root.render(
<div style={{ padding: 24, fontFamily: 'sans-serif' }}>
<h1>Failed to load</h1>
<p>{message}</p>
</div>,
<RootCrashBoundary>
<Main localeMessages={localeMessages} />
</RootCrashBoundary>,
)
} 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(<RootCrashFallback error={error} eventId={eventId} />)
}
}

Expand Down
70 changes: 40 additions & 30 deletions libs/wallet/src/api/hooks/useWalletCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useCapabilities>

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<typeof useCapabilities>)
})

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)
})
})
61 changes: 32 additions & 29 deletions libs/wallet/src/api/hooks/useWalletCapabilities.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,29 @@
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 = {
atomic?: { status: 'supported' | 'ready' | 'unsupported' }
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.
Expand All @@ -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,
Expand All @@ -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
}
36 changes: 0 additions & 36 deletions libs/wallet/src/api/hooks/useWalletCapabilities.utils.ts

This file was deleted.

23 changes: 22 additions & 1 deletion patches/@reown__appkit-adapter-wagmi@1.8.19.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
Loading
Loading