feat: account suspense (#7337)

* feat: eagerly connect outside of react lifecycle

* test: reflect selected wallet in localStorage

* test: spy only on portfolio balances

* feat: connectionReady

* feat: connecting state

* feat: leave space for address

* fix tests

* better meta

* fix

* fix wallet change

* add interactivity earlier

* add validation

* update localstorage key in cypress setup

* even less thrash

* load per account

* simplify, hopefully

* explanatory

* inf render

* whoopsie

* ordering
This commit is contained in:
Zach Pomerantz 2023-09-22 09:57:35 -07:00 committed by GitHub
parent 622c72d4a8
commit ed87df6269
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 164 additions and 27 deletions

@ -1,3 +1,4 @@
import { connectionMetaKey } from '../../src/connection/meta'
import { ConnectionType } from '../../src/connection/types' import { ConnectionType } from '../../src/connection/types'
import { UserState } from '../../src/state/user/reducer' import { UserState } from '../../src/state/user/reducer'
@ -13,7 +14,12 @@ export const DISCONNECTED_WALLET_USER_STATE: Partial<UserState> = { selectedWall
export function setInitialUserState(win: Cypress.AUTWindow, state: UserState) { export function setInitialUserState(win: Cypress.AUTWindow, state: UserState) {
// Selected wallet should also be reflected in localStorage, so that eager connections work. // Selected wallet should also be reflected in localStorage, so that eager connections work.
if (state.selectedWallet) { if (state.selectedWallet) {
win.localStorage.setItem('selected_wallet', state.selectedWallet) win.localStorage.setItem(
connectionMetaKey,
JSON.stringify({
type: state.selectedWallet,
})
)
} }
win.indexedDB.deleteDatabase('redux') win.indexedDB.deleteDatabase('redux')

@ -4,17 +4,19 @@ import { useWeb3React } from '@web3-react/core'
import { sendAnalyticsEvent, TraceEvent } from 'analytics' import { sendAnalyticsEvent, TraceEvent } from 'analytics'
import PortfolioDrawer, { useAccountDrawer } from 'components/AccountDrawer' import PortfolioDrawer, { useAccountDrawer } from 'components/AccountDrawer'
import { usePendingActivity } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' 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 { IconWrapper } from 'components/Identicon/StatusIcon'
import PrefetchBalancesWrapper from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' import PrefetchBalancesWrapper from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'
import { getConnection } from 'connection' import { getConnection } from 'connection'
import { useConnectionReady } from 'connection/eagerlyConnect'
import { ConnectionMeta, getPersistedConnectionMeta, setPersistedConnectionMeta } from 'connection/meta'
import useENSName from 'hooks/useENSName' import useENSName from 'hooks/useENSName'
import useLast from 'hooks/useLast' import useLast from 'hooks/useLast'
import { navSearchInputVisibleSize } from 'hooks/useScreenSize' import { navSearchInputVisibleSize } from 'hooks/useScreenSize'
import { Portal } from 'nft/components/common/Portal' import { Portal } from 'nft/components/common/Portal'
import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable' import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable'
import { darken } from 'polished' import { darken } from 'polished'
import { useCallback } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useAppSelector } from 'state/hooks' import { useAppSelector } from 'state/hooks'
import styled from 'styled-components' import styled from 'styled-components'
import { colors } from 'theme/colors' 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; display: flex;
opacity: ${({ loading, theme }) => loading && theme.opacity.disabled};
@media only screen and (max-width: ${navSearchInputVisibleSize}px) { @media only screen and (max-width: ${navSearchInputVisibleSize}px) {
display: none; display: none;
@ -128,8 +137,11 @@ const StyledConnectButton = styled.button`
function Web3StatusInner() { function Web3StatusInner() {
const switchingChain = useAppSelector((state) => state.wallets.switchingChain) const switchingChain = useAppSelector((state) => state.wallets.switchingChain)
const ignoreWhileSwitchingChain = useCallback(() => !switchingChain, [switchingChain]) const ignoreWhileSwitchingChain = useCallback(() => !switchingChain, [switchingChain])
const { account, connector } = useLast(useWeb3React(), ignoreWhileSwitchingChain) const connectionReady = useConnectionReady()
const { ENSName } = useENSName(account) 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 connection = getConnection(connector)
const [, toggleAccountDrawer] = useAccountDrawer() const [, toggleAccountDrawer] = useAccountDrawer()
@ -141,6 +153,48 @@ function Web3StatusInner() {
const { hasPendingActivity, pendingActivityCount } = usePendingActivity() 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 (
<Web3StatusConnecting disabled={!isConnectionInitializing} onClick={handleWalletDropdownClick}>
<IconWrapper size={24}>
<LoaderV3 size="24px" />
</IconWrapper>
<AddressAndChevronContainer loading={true}>
<Text>{initialConnection.current?.ENSName ?? shortenAddress(initialConnection.current?.address)}</Text>
</AddressAndChevronContainer>
</Web3StatusConnecting>
)
}
if (account) { if (account) {
return ( return (
<TraceEvent <TraceEvent
@ -167,7 +221,7 @@ function Web3StatusInner() {
</RowBetween> </RowBetween>
) : ( ) : (
<AddressAndChevronContainer> <AddressAndChevronContainer>
<Text>{ENSName || shortenAddress(account)}</Text> <Text>{ENSName ?? shortenAddress(account)}</Text>
</AddressAndChevronContainer> </AddressAndChevronContainer>
)} )}
</Web3StatusConnected> </Web3StatusConnected>

@ -1,7 +1,25 @@
import { Connector } from '@web3-react/types' import { Connector } from '@web3-react/types'
import { useSyncExternalStore } from 'react'
import { getConnection, gnosisSafeConnection, networkConnection } from './index' 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<void> | 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) { async function connect(connector: Connector, type: ConnectionType) {
performance.mark(`web3:connect:${type}:start`) performance.mark(`web3:connect:${type}:start`)
@ -28,15 +46,25 @@ if (window !== window.parent) {
connect(networkConnection.connector, ConnectionType.NETWORK) connect(networkConnection.connector, ConnectionType.NETWORK)
const selectedWallet = toConnectionType(localStorage.getItem(selectedWalletKey) ?? undefined) // Get the persisted wallet type from the last session.
if (selectedWallet) { const meta = getPersistedConnectionMeta()
const selectedConnection = getConnection(selectedWallet) if (meta?.type) {
const selectedConnection = getConnection(meta.type)
if (selectedConnection) { if (selectedConnection) {
connect(selectedConnection.connector, selectedWallet).then((connected) => { connectionReady = connect(selectedConnection.connector, meta.type)
if (!connected) { .then((connected) => {
// only clear the persisted wallet type if it failed to connect. if (!connected) throw new FailedToConnect()
localStorage.removeItem(selectedWalletKey) })
} .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
})
} }
} }

37
src/connection/meta.ts Normal file

@ -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)
}

@ -2,8 +2,6 @@ import { ChainId } from '@uniswap/sdk-core'
import { Web3ReactHooks } from '@web3-react/core' import { Web3ReactHooks } from '@web3-react/core'
import { Connector } from '@web3-react/types' import { Connector } from '@web3-react/types'
export const selectedWalletKey = 'selected_wallet'
export enum ConnectionType { export enum ConnectionType {
UNISWAP_WALLET_V2 = 'UNISWAP_WALLET_V2', UNISWAP_WALLET_V2 = 'UNISWAP_WALLET_V2',
INJECTED = 'INJECTED', INJECTED = 'INJECTED',

@ -34,11 +34,12 @@ export default function useENSName(address?: string): { ENSName: string | null;
const checkedName = address === fwdAddr?.address ? name : null const checkedName = address === fwdAddr?.address ? name : null
const changed = debouncedAddress !== address const changed = debouncedAddress !== address
const loading = changed || resolverAddress.loading || nameCallRes.loading || fwdAddr.loading
return useMemo( return useMemo(
() => ({ () => ({
ENSName: changed ? null : checkedName, ENSName: changed ? null : checkedName,
loading: changed || resolverAddress.loading || nameCallRes.loading, loading,
}), }),
[changed, nameCallRes.loading, checkedName, resolverAddress.loading] [changed, checkedName, loading]
) )
} }

@ -29,6 +29,7 @@ import SwapDetailsDropdown from 'components/swap/SwapDetailsDropdown'
import SwapHeader from 'components/swap/SwapHeader' import SwapHeader from 'components/swap/SwapHeader'
import { SwitchLocaleLink } from 'components/SwitchLocaleLink' import { SwitchLocaleLink } from 'components/SwitchLocaleLink'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import { useConnectionReady } from 'connection/eagerlyConnect'
import { getChainInfo } from 'constants/chainInfo' import { getChainInfo } from 'constants/chainInfo'
import { asSupportedChain, isSupportedChain } from 'constants/chains' import { asSupportedChain, isSupportedChain } from 'constants/chains'
import { getSwapCurrencyId, TOKEN_SHORTHANDS } from 'constants/tokens' import { getSwapCurrencyId, TOKEN_SHORTHANDS } from 'constants/tokens'
@ -183,6 +184,7 @@ export function Swap({
onCurrencyChange?: (selected: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => void onCurrencyChange?: (selected: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => void
disableTokenInputs?: boolean disableTokenInputs?: boolean
}) { }) {
const connectionReady = useConnectionReady()
const { account, chainId: connectedChainId, connector } = useWeb3React() const { account, chainId: connectedChainId, connector } = useWeb3React()
const trace = useTrace() const trace = useTrace()
@ -723,7 +725,7 @@ export function Swap({
<ButtonPrimary $borderRadius="16px" disabled={true}> <ButtonPrimary $borderRadius="16px" disabled={true}>
<Trans>Connecting to {getChainInfo(switchingChain)?.label}</Trans> <Trans>Connecting to {getChainInfo(switchingChain)?.label}</Trans>
</ButtonPrimary> </ButtonPrimary>
) : !account ? ( ) : connectionReady && !account ? (
<TraceEvent <TraceEvent
events={[BrowserEvent.onClick]} events={[BrowserEvent.onClick]}
name={InterfaceEventName.CONNECT_WALLET_BUTTON_CLICKED} name={InterfaceEventName.CONNECT_WALLET_BUTTON_CLICKED}

@ -74,6 +74,12 @@ jest.mock('@web3-react/core', () => {
} }
}) })
jest.mock('connection/eagerlyConnect', () => {
return {
useConnectionReady: () => true,
}
})
jest.mock('state/routing/slice', () => { jest.mock('state/routing/slice', () => {
const routingSlice = jest.requireActual('state/routing/slice') const routingSlice = jest.requireActual('state/routing/slice')
return { return {

@ -1,6 +1,7 @@
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { ChainId, Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core' import { ChainId, Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { useConnectionReady } from 'connection/eagerlyConnect'
import { useFotAdjustmentsEnabled } from 'featureFlags/flags/fotAdjustments' import { useFotAdjustmentsEnabled } from 'featureFlags/flags/fotAdjustments'
import useAutoSlippageTolerance from 'hooks/useAutoSlippageTolerance' import useAutoSlippageTolerance from 'hooks/useAutoSlippageTolerance'
import { useDebouncedTrade } from 'hooks/useDebouncedTrade' import { useDebouncedTrade } from 'hooks/useDebouncedTrade'
@ -168,11 +169,12 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine
// slippage amount used to submit the trade // slippage amount used to submit the trade
const allowedSlippage = uniswapXAutoSlippage ?? classicAllowedSlippage const allowedSlippage = uniswapXAutoSlippage ?? classicAllowedSlippage
const connectionReady = useConnectionReady()
const inputError = useMemo(() => { const inputError = useMemo(() => {
let inputError: ReactNode | undefined let inputError: ReactNode | undefined
if (!account) { if (!account) {
inputError = <Trans>Connect Wallet</Trans> inputError = connectionReady ? <Trans>Connect Wallet</Trans> : <Trans>Connecting Wallet...</Trans>
} }
if (!currencies[Field.INPUT] || !currencies[Field.OUTPUT]) { if (!currencies[Field.INPUT] || !currencies[Field.OUTPUT]) {
@ -200,7 +202,7 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine
} }
return inputError return inputError
}, [account, currencies, parsedAmount, to, currencyBalances, trade.trade, allowedSlippage]) }, [account, currencies, parsedAmount, to, currencyBalances, trade?.trade, allowedSlippage, connectionReady])
return useMemo( return useMemo(
() => ({ () => ({

@ -1,12 +1,13 @@
import { createSlice } from '@reduxjs/toolkit' 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 { SupportedLocale } from '../../constants/locales'
import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc' import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc'
import { RouterPreference } from '../../state/routing/types' import { RouterPreference } from '../../state/routing/types'
import { SerializedPair, SerializedToken, SlippageTolerance } from './types' import { SerializedPair, SerializedToken, SlippageTolerance } from './types'
const selectedWallet = toConnectionType(localStorage.getItem(selectedWalletKey) ?? undefined) const selectedWallet = getPersistedConnectionMeta()?.type
const currentTimestamp = () => new Date().getTime() const currentTimestamp = () => new Date().getTime()
export interface UserState { export interface UserState {
@ -79,7 +80,9 @@ const userSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
updateSelectedWallet(state, { payload: { wallet } }) { updateSelectedWallet(state, { payload: { wallet } }) {
localStorage.setItem(selectedWalletKey, wallet) if (!wallet) {
deletePersistedConnectionMeta()
}
state.selectedWallet = wallet state.selectedWallet = wallet
}, },
updateUserLocale(state, action) { updateUserLocale(state, action) {