diff --git a/cypress/utils/user-state.ts b/cypress/utils/user-state.ts index 99e3c7212b..0475797c11 100644 --- a/cypress/utils/user-state.ts +++ b/cypress/utils/user-state.ts @@ -1,3 +1,4 @@ +import { connectionMetaKey } from '../../src/connection/meta' import { ConnectionType } from '../../src/connection/types' import { UserState } from '../../src/state/user/reducer' @@ -13,7 +14,12 @@ export const DISCONNECTED_WALLET_USER_STATE: Partial = { selectedWall export function setInitialUserState(win: Cypress.AUTWindow, state: UserState) { // Selected wallet should also be reflected in localStorage, so that eager connections work. if (state.selectedWallet) { - win.localStorage.setItem('selected_wallet', state.selectedWallet) + win.localStorage.setItem( + connectionMetaKey, + JSON.stringify({ + type: state.selectedWallet, + }) + ) } win.indexedDB.deleteDatabase('redux') diff --git a/src/components/Web3Status/index.tsx b/src/components/Web3Status/index.tsx index efcf1c3a7b..278429ff6c 100644 --- a/src/components/Web3Status/index.tsx +++ b/src/components/Web3Status/index.tsx @@ -4,17 +4,19 @@ import { useWeb3React } from '@web3-react/core' import { sendAnalyticsEvent, TraceEvent } from 'analytics' import PortfolioDrawer, { useAccountDrawer } from 'components/AccountDrawer' import { usePendingActivity } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' -import Loader from 'components/Icons/LoadingSpinner' +import Loader, { LoaderV3 } from 'components/Icons/LoadingSpinner' import { IconWrapper } from 'components/Identicon/StatusIcon' import PrefetchBalancesWrapper from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' import { getConnection } from 'connection' +import { useConnectionReady } from 'connection/eagerlyConnect' +import { ConnectionMeta, getPersistedConnectionMeta, setPersistedConnectionMeta } from 'connection/meta' import useENSName from 'hooks/useENSName' import useLast from 'hooks/useLast' import { navSearchInputVisibleSize } from 'hooks/useScreenSize' import { Portal } from 'nft/components/common/Portal' import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable' import { darken } from 'polished' -import { useCallback } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useAppSelector } from 'state/hooks' import styled from 'styled-components' import { colors } from 'theme/colors' @@ -94,8 +96,15 @@ const Web3StatusConnected = styled(Web3StatusGeneric)<{ } ` -const AddressAndChevronContainer = styled.div` +const Web3StatusConnecting = styled(Web3StatusConnected)` + &:disabled { + opacity: 1; + } +` + +const AddressAndChevronContainer = styled.div<{ loading?: boolean }>` display: flex; + opacity: ${({ loading, theme }) => loading && theme.opacity.disabled}; @media only screen and (max-width: ${navSearchInputVisibleSize}px) { display: none; @@ -128,8 +137,11 @@ const StyledConnectButton = styled.button` function Web3StatusInner() { const switchingChain = useAppSelector((state) => state.wallets.switchingChain) const ignoreWhileSwitchingChain = useCallback(() => !switchingChain, [switchingChain]) - const { account, connector } = useLast(useWeb3React(), ignoreWhileSwitchingChain) - const { ENSName } = useENSName(account) + const connectionReady = useConnectionReady() + const activeWeb3 = useWeb3React() + const lastWeb3 = useLast(useWeb3React(), ignoreWhileSwitchingChain) + const { account, connector } = useMemo(() => (activeWeb3.account ? activeWeb3 : lastWeb3), [activeWeb3, lastWeb3]) + const { ENSName, loading: ENSLoading } = useENSName(account) const connection = getConnection(connector) const [, toggleAccountDrawer] = useAccountDrawer() @@ -141,6 +153,48 @@ function Web3StatusInner() { const { hasPendingActivity, pendingActivityCount } = usePendingActivity() + // Display a loading state while initializing the connection, based on the last session's persisted connection. + // The connection will go through three states: + // - startup: connection is not ready + // - initializing: account is available, but ENS (if preset on the persisted initialMeta) is still loading + // - initialized: account and ENS are available + // Subsequent connections are always considered initialized, and will not display startup/initializing states. + const initialConnection = useRef(getPersistedConnectionMeta()) + const isConnectionInitializing = Boolean( + initialConnection.current?.address === account && initialConnection.current?.ENSName && ENSLoading + ) + const isConnectionInitialized = connectionReady && !isConnectionInitializing + // Clear the initial connection once initialized so it does not interfere with subsequent connections. + useEffect(() => { + if (isConnectionInitialized) { + initialConnection.current = undefined + } + }, [isConnectionInitialized]) + // Persist the connection if it changes, so it can be used to initialize the next session's connection. + useEffect(() => { + if (account || ENSName) { + const meta: ConnectionMeta = { + type: connection.type, + address: account, + ENSName: ENSName ?? undefined, + } + setPersistedConnectionMeta(meta) + } + }, [ENSName, account, connection.type]) + + if (!isConnectionInitialized) { + return ( + + + + + + {initialConnection.current?.ENSName ?? shortenAddress(initialConnection.current?.address)} + + + ) + } + if (account) { return ( ) : ( - {ENSName || shortenAddress(account)} + {ENSName ?? shortenAddress(account)} )} diff --git a/src/connection/eagerlyConnect.ts b/src/connection/eagerlyConnect.ts index 390845180b..0a7602f080 100644 --- a/src/connection/eagerlyConnect.ts +++ b/src/connection/eagerlyConnect.ts @@ -1,7 +1,25 @@ import { Connector } from '@web3-react/types' +import { useSyncExternalStore } from 'react' import { getConnection, gnosisSafeConnection, networkConnection } from './index' -import { ConnectionType, selectedWalletKey, toConnectionType } from './types' +import { deletePersistedConnectionMeta, getPersistedConnectionMeta } from './meta' +import { ConnectionType } from './types' + +class FailedToConnect extends Error {} + +let connectionReady: Promise | true = true + +export function useConnectionReady() { + return useSyncExternalStore( + (onStoreChange) => { + if (connectionReady instanceof Promise) { + connectionReady.finally(onStoreChange) + } + return () => undefined + }, + () => connectionReady === true + ) +} async function connect(connector: Connector, type: ConnectionType) { performance.mark(`web3:connect:${type}:start`) @@ -28,15 +46,25 @@ if (window !== window.parent) { connect(networkConnection.connector, ConnectionType.NETWORK) -const selectedWallet = toConnectionType(localStorage.getItem(selectedWalletKey) ?? undefined) -if (selectedWallet) { - const selectedConnection = getConnection(selectedWallet) +// Get the persisted wallet type from the last session. +const meta = getPersistedConnectionMeta() +if (meta?.type) { + const selectedConnection = getConnection(meta.type) if (selectedConnection) { - connect(selectedConnection.connector, selectedWallet).then((connected) => { - if (!connected) { - // only clear the persisted wallet type if it failed to connect. - localStorage.removeItem(selectedWalletKey) - } - }) + connectionReady = connect(selectedConnection.connector, meta.type) + .then((connected) => { + if (!connected) throw new FailedToConnect() + }) + .catch((error) => { + // Clear the persisted wallet type if it failed to connect. + deletePersistedConnectionMeta() + // Log it if it threw an unknown error. + if (!(error instanceof FailedToConnect)) { + console.error(error) + } + }) + .finally(() => { + connectionReady = true + }) } } diff --git a/src/connection/meta.ts b/src/connection/meta.ts new file mode 100644 index 0000000000..f7722f8842 --- /dev/null +++ b/src/connection/meta.ts @@ -0,0 +1,37 @@ +import { ConnectionType, toConnectionType } from './types' + +export interface ConnectionMeta { + type: ConnectionType + address?: string + ENSName?: string +} + +export const connectionMetaKey = 'connection_meta' + +export function getPersistedConnectionMeta(): ConnectionMeta | undefined { + try { + const value = localStorage.getItem(connectionMetaKey) + if (value) { + const raw = JSON.parse(value) as ConnectionMeta + const connectionType = toConnectionType(raw.type) + if (connectionType) { + return { + type: connectionType, + address: raw.address, + ENSName: raw.ENSName, + } + } + } + } catch (e) { + console.warn(e) + } + return +} + +export function setPersistedConnectionMeta(meta: ConnectionMeta) { + localStorage.setItem(connectionMetaKey, JSON.stringify(meta)) +} + +export function deletePersistedConnectionMeta() { + localStorage.removeItem(connectionMetaKey) +} diff --git a/src/connection/types.ts b/src/connection/types.ts index ee98a9875e..c34415eeea 100644 --- a/src/connection/types.ts +++ b/src/connection/types.ts @@ -2,8 +2,6 @@ import { ChainId } from '@uniswap/sdk-core' import { Web3ReactHooks } from '@web3-react/core' import { Connector } from '@web3-react/types' -export const selectedWalletKey = 'selected_wallet' - export enum ConnectionType { UNISWAP_WALLET_V2 = 'UNISWAP_WALLET_V2', INJECTED = 'INJECTED', diff --git a/src/hooks/useENSName.ts b/src/hooks/useENSName.ts index 1d13cea119..c17ef1451e 100644 --- a/src/hooks/useENSName.ts +++ b/src/hooks/useENSName.ts @@ -34,11 +34,12 @@ export default function useENSName(address?: string): { ENSName: string | null; const checkedName = address === fwdAddr?.address ? name : null const changed = debouncedAddress !== address + const loading = changed || resolverAddress.loading || nameCallRes.loading || fwdAddr.loading return useMemo( () => ({ ENSName: changed ? null : checkedName, - loading: changed || resolverAddress.loading || nameCallRes.loading, + loading, }), - [changed, nameCallRes.loading, checkedName, resolverAddress.loading] + [changed, checkedName, loading] ) } diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 6e408a9a04..e342d135ea 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -29,6 +29,7 @@ import SwapDetailsDropdown from 'components/swap/SwapDetailsDropdown' import SwapHeader from 'components/swap/SwapHeader' import { SwitchLocaleLink } from 'components/SwitchLocaleLink' import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' +import { useConnectionReady } from 'connection/eagerlyConnect' import { getChainInfo } from 'constants/chainInfo' import { asSupportedChain, isSupportedChain } from 'constants/chains' import { getSwapCurrencyId, TOKEN_SHORTHANDS } from 'constants/tokens' @@ -183,6 +184,7 @@ export function Swap({ onCurrencyChange?: (selected: Pick) => void disableTokenInputs?: boolean }) { + const connectionReady = useConnectionReady() const { account, chainId: connectedChainId, connector } = useWeb3React() const trace = useTrace() @@ -723,7 +725,7 @@ export function Swap({ Connecting to {getChainInfo(switchingChain)?.label} - ) : !account ? ( + ) : connectionReady && !account ? ( { } }) +jest.mock('connection/eagerlyConnect', () => { + return { + useConnectionReady: () => true, + } +}) + jest.mock('state/routing/slice', () => { const routingSlice = jest.requireActual('state/routing/slice') return { diff --git a/src/state/swap/hooks.tsx b/src/state/swap/hooks.tsx index f8dcff0ce2..7ca5c89987 100644 --- a/src/state/swap/hooks.tsx +++ b/src/state/swap/hooks.tsx @@ -1,6 +1,7 @@ import { Trans } from '@lingui/macro' import { ChainId, Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core' import { useWeb3React } from '@web3-react/core' +import { useConnectionReady } from 'connection/eagerlyConnect' import { useFotAdjustmentsEnabled } from 'featureFlags/flags/fotAdjustments' import useAutoSlippageTolerance from 'hooks/useAutoSlippageTolerance' import { useDebouncedTrade } from 'hooks/useDebouncedTrade' @@ -168,11 +169,12 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine // slippage amount used to submit the trade const allowedSlippage = uniswapXAutoSlippage ?? classicAllowedSlippage + const connectionReady = useConnectionReady() const inputError = useMemo(() => { let inputError: ReactNode | undefined if (!account) { - inputError = Connect Wallet + inputError = connectionReady ? Connect Wallet : Connecting Wallet... } if (!currencies[Field.INPUT] || !currencies[Field.OUTPUT]) { @@ -200,7 +202,7 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine } return inputError - }, [account, currencies, parsedAmount, to, currencyBalances, trade.trade, allowedSlippage]) + }, [account, currencies, parsedAmount, to, currencyBalances, trade?.trade, allowedSlippage, connectionReady]) return useMemo( () => ({ diff --git a/src/state/user/reducer.ts b/src/state/user/reducer.ts index cb20a22fe7..ca0c34c9e8 100644 --- a/src/state/user/reducer.ts +++ b/src/state/user/reducer.ts @@ -1,12 +1,13 @@ import { createSlice } from '@reduxjs/toolkit' +import { deletePersistedConnectionMeta, getPersistedConnectionMeta } from 'connection/meta' -import { ConnectionType, selectedWalletKey, toConnectionType } from '../../connection/types' +import { ConnectionType } from '../../connection/types' import { SupportedLocale } from '../../constants/locales' import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc' import { RouterPreference } from '../../state/routing/types' import { SerializedPair, SerializedToken, SlippageTolerance } from './types' -const selectedWallet = toConnectionType(localStorage.getItem(selectedWalletKey) ?? undefined) +const selectedWallet = getPersistedConnectionMeta()?.type const currentTimestamp = () => new Date().getTime() export interface UserState { @@ -79,7 +80,9 @@ const userSlice = createSlice({ initialState, reducers: { updateSelectedWallet(state, { payload: { wallet } }) { - localStorage.setItem(selectedWalletKey, wallet) + if (!wallet) { + deletePersistedConnectionMeta() + } state.selectedWallet = wallet }, updateUserLocale(state, action) {