feat(chain-connectivity): warn when the user is not receiving blocks (#2123)
* logic for tracking time since last block * add polling times to chain info * pr feedback * add gui for chain connectivity warning * add arb support * update title of warning to indicate internet connectivity issues may be the problem * pr review * clean up useBlockWarningTimer * softer language on mainnet * only show warning if user has the window visible
This commit is contained in:
parent
c2093ce040
commit
bcf64bcb11
76
src/components/Header/ChainConnectivityWarning.tsx
Normal file
76
src/components/Header/ChainConnectivityWarning.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Trans } from '@lingui/macro'
|
||||||
|
import { CHAIN_INFO, L2ChainInfo, SupportedChainId } from 'constants/chains'
|
||||||
|
import { useActiveWeb3React } from 'hooks/web3'
|
||||||
|
import { AlertOctagon } from 'react-feather'
|
||||||
|
import styled from 'styled-components/macro'
|
||||||
|
import { ExternalLink, MEDIA_WIDTHS } from 'theme'
|
||||||
|
|
||||||
|
const BodyRow = styled.div`
|
||||||
|
color: ${({ theme }) => theme.black};
|
||||||
|
font-size: 12px;
|
||||||
|
`
|
||||||
|
const CautionIcon = styled(AlertOctagon)`
|
||||||
|
color: ${({ theme }) => theme.black};
|
||||||
|
`
|
||||||
|
const Link = styled(ExternalLink)`
|
||||||
|
color: ${({ theme }) => theme.black};
|
||||||
|
text-decoration: underline;
|
||||||
|
`
|
||||||
|
const TitleRow = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`
|
||||||
|
const TitleText = styled.div`
|
||||||
|
color: black;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin: 0px 12px;
|
||||||
|
`
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
background-color: ${({ theme }) => theme.yellow3};
|
||||||
|
border-radius: 12px;
|
||||||
|
bottom: 60px;
|
||||||
|
display: none;
|
||||||
|
max-width: 348px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
@media screen and (min-width: ${MEDIA_WIDTHS.upToMedium}px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export function ChainConnectivityWarning() {
|
||||||
|
const { chainId } = useActiveWeb3React()
|
||||||
|
const info = CHAIN_INFO[chainId ?? SupportedChainId.MAINNET]
|
||||||
|
const label = info?.label
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<TitleRow>
|
||||||
|
<CautionIcon />
|
||||||
|
<TitleText>
|
||||||
|
<Trans>Network Warning</Trans>
|
||||||
|
</TitleText>
|
||||||
|
</TitleRow>
|
||||||
|
<BodyRow>
|
||||||
|
{chainId === SupportedChainId.MAINNET ? (
|
||||||
|
<Trans>You may have lost your network connection.</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>{label} may be down right now, or you may have lost your network connection.</Trans>
|
||||||
|
)}{' '}
|
||||||
|
{(info as L2ChainInfo).statusPage !== undefined && (
|
||||||
|
<span>
|
||||||
|
<Trans>Check network status</Trans>{' '}
|
||||||
|
<Link href={(info as L2ChainInfo).statusPage || ''}>
|
||||||
|
<Trans>here.</Trans>
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</BodyRow>
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
}
|
@ -1,19 +1,21 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useAppSelector } from 'state/hooks'
|
||||||
import styled, { keyframes } from 'styled-components/macro'
|
import styled, { keyframes } from 'styled-components/macro'
|
||||||
import { useActiveWeb3React } from '../../hooks/web3'
|
import { useActiveWeb3React } from '../../hooks/web3'
|
||||||
|
|
||||||
import { useBlockNumber } from '../../state/application/hooks'
|
import { useBlockNumber } from '../../state/application/hooks'
|
||||||
import { ExternalLink, TYPE } from '../../theme'
|
import { ExternalLink, TYPE } from '../../theme'
|
||||||
import { ExplorerDataType, getExplorerLink } from '../../utils/getExplorerLink'
|
import { ExplorerDataType, getExplorerLink } from '../../utils/getExplorerLink'
|
||||||
|
import { ChainConnectivityWarning } from './ChainConnectivityWarning'
|
||||||
|
|
||||||
const StyledPolling = styled.div`
|
const StyledPolling = styled.div<{ warning: boolean }>`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: ${({ theme }) => theme.green1};
|
color: ${({ theme, warning }) => (warning ? theme.yellow3 : theme.green1)};
|
||||||
|
transition: 250ms ease color;
|
||||||
|
|
||||||
${({ theme }) => theme.mediaWidth.upToMedium`
|
${({ theme }) => theme.mediaWidth.upToMedium`
|
||||||
display: none;
|
display: none;
|
||||||
@ -26,14 +28,15 @@ const StyledPollingNumber = styled(TYPE.small)<{ breathe: boolean; hovering: boo
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
const StyledPollingDot = styled.div`
|
const StyledPollingDot = styled.div<{ warning: boolean }>`
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
min-height: 8px;
|
min-height: 8px;
|
||||||
min-width: 8px;
|
min-width: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: ${({ theme }) => theme.green1};
|
background-color: ${({ theme, warning }) => (warning ? theme.yellow3 : theme.green1)};
|
||||||
|
transition: 250ms ease background-color;
|
||||||
`
|
`
|
||||||
|
|
||||||
const rotate360 = keyframes`
|
const rotate360 = keyframes`
|
||||||
@ -45,19 +48,20 @@ const rotate360 = keyframes`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const Spinner = styled.div`
|
const Spinner = styled.div<{ warning: boolean }>`
|
||||||
animation: ${rotate360} 1s cubic-bezier(0.83, 0, 0.17, 1) infinite;
|
animation: ${rotate360} 1s cubic-bezier(0.83, 0, 0.17, 1) infinite;
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
|
|
||||||
border-top: 1px solid transparent;
|
border-top: 1px solid transparent;
|
||||||
border-right: 1px solid transparent;
|
border-right: 1px solid transparent;
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid transparent;
|
||||||
border-left: 2px solid ${({ theme }) => theme.green1};
|
border-left: 2px solid ${({ theme, warning }) => (warning ? theme.yellow3 : theme.green1)};
|
||||||
background: transparent;
|
background: transparent;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: 250ms ease border-color;
|
||||||
|
|
||||||
left: -3px;
|
left: -3px;
|
||||||
top: -3px;
|
top: -3px;
|
||||||
@ -65,11 +69,10 @@ const Spinner = styled.div`
|
|||||||
|
|
||||||
export default function Polling() {
|
export default function Polling() {
|
||||||
const { chainId } = useActiveWeb3React()
|
const { chainId } = useActiveWeb3React()
|
||||||
|
|
||||||
const blockNumber = useBlockNumber()
|
const blockNumber = useBlockNumber()
|
||||||
|
|
||||||
const [isMounting, setIsMounting] = useState(false)
|
const [isMounting, setIsMounting] = useState(false)
|
||||||
const [isHover, setIsHover] = useState(false)
|
const [isHover, setIsHover] = useState(false)
|
||||||
|
const chainConnectivityWarning = useAppSelector((state) => state.application.chainConnectivityWarning)
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
@ -90,15 +93,24 @@ export default function Polling() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExternalLink
|
<>
|
||||||
href={chainId && blockNumber ? getExplorerLink(chainId, blockNumber.toString(), ExplorerDataType.BLOCK) : ''}
|
<ExternalLink
|
||||||
>
|
href={chainId && blockNumber ? getExplorerLink(chainId, blockNumber.toString(), ExplorerDataType.BLOCK) : ''}
|
||||||
<StyledPolling onMouseEnter={() => setIsHover(true)} onMouseLeave={() => setIsHover(false)}>
|
>
|
||||||
<StyledPollingNumber breathe={isMounting} hovering={isHover}>
|
<StyledPolling
|
||||||
{blockNumber} 
|
onMouseEnter={() => setIsHover(true)}
|
||||||
</StyledPollingNumber>
|
onMouseLeave={() => setIsHover(false)}
|
||||||
<StyledPollingDot>{isMounting && <Spinner />}</StyledPollingDot>
|
warning={chainConnectivityWarning}
|
||||||
</StyledPolling>
|
>
|
||||||
</ExternalLink>
|
<StyledPollingNumber breathe={isMounting} hovering={isHover}>
|
||||||
|
{blockNumber} 
|
||||||
|
</StyledPollingNumber>
|
||||||
|
<StyledPollingDot warning={chainConnectivityWarning}>
|
||||||
|
{isMounting && <Spinner warning={chainConnectivityWarning} />}
|
||||||
|
</StyledPollingDot>{' '}
|
||||||
|
</StyledPolling>
|
||||||
|
</ExternalLink>
|
||||||
|
{chainConnectivityWarning && <ChainConnectivityWarning />}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import arbitrumLogoUrl from 'assets/svg/arbitrum_logo.svg'
|
import arbitrumLogoUrl from 'assets/svg/arbitrum_logo.svg'
|
||||||
import optimismLogoUrl from 'assets/svg/optimism_logo.svg'
|
import optimismLogoUrl from 'assets/svg/optimism_logo.svg'
|
||||||
|
import ms from 'ms.macro'
|
||||||
|
|
||||||
export enum SupportedChainId {
|
export enum SupportedChainId {
|
||||||
MAINNET = 1,
|
MAINNET = 1,
|
||||||
@ -47,6 +48,7 @@ export const L2_CHAIN_IDS = [
|
|||||||
export type SupportedL2ChainId = typeof L2_CHAIN_IDS[number]
|
export type SupportedL2ChainId = typeof L2_CHAIN_IDS[number]
|
||||||
|
|
||||||
interface L1ChainInfo {
|
interface L1ChainInfo {
|
||||||
|
readonly blockWaitMsBeforeWarning?: number
|
||||||
readonly docs: string
|
readonly docs: string
|
||||||
readonly explorer: string
|
readonly explorer: string
|
||||||
readonly infoLink: string
|
readonly infoLink: string
|
||||||
@ -55,6 +57,7 @@ interface L1ChainInfo {
|
|||||||
export interface L2ChainInfo extends L1ChainInfo {
|
export interface L2ChainInfo extends L1ChainInfo {
|
||||||
readonly bridge: string
|
readonly bridge: string
|
||||||
readonly logoUrl: string
|
readonly logoUrl: string
|
||||||
|
readonly statusPage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChainInfo = { readonly [chainId: number]: L1ChainInfo | L2ChainInfo } & {
|
type ChainInfo = { readonly [chainId: number]: L1ChainInfo | L2ChainInfo } & {
|
||||||
@ -64,6 +67,7 @@ type ChainInfo = { readonly [chainId: number]: L1ChainInfo | L2ChainInfo } & {
|
|||||||
|
|
||||||
export const CHAIN_INFO: ChainInfo = {
|
export const CHAIN_INFO: ChainInfo = {
|
||||||
[SupportedChainId.ARBITRUM_ONE]: {
|
[SupportedChainId.ARBITRUM_ONE]: {
|
||||||
|
blockWaitMsBeforeWarning: ms`10m`,
|
||||||
bridge: 'https://bridge.arbitrum.io/',
|
bridge: 'https://bridge.arbitrum.io/',
|
||||||
docs: 'https://offchainlabs.com/',
|
docs: 'https://offchainlabs.com/',
|
||||||
explorer: 'https://arbiscan.io/',
|
explorer: 'https://arbiscan.io/',
|
||||||
@ -72,6 +76,7 @@ export const CHAIN_INFO: ChainInfo = {
|
|||||||
logoUrl: arbitrumLogoUrl,
|
logoUrl: arbitrumLogoUrl,
|
||||||
},
|
},
|
||||||
[SupportedChainId.ARBITRUM_RINKEBY]: {
|
[SupportedChainId.ARBITRUM_RINKEBY]: {
|
||||||
|
blockWaitMsBeforeWarning: ms`10m`,
|
||||||
bridge: 'https://bridge.arbitrum.io/',
|
bridge: 'https://bridge.arbitrum.io/',
|
||||||
docs: 'https://offchainlabs.com/',
|
docs: 'https://offchainlabs.com/',
|
||||||
explorer: 'https://rinkeby-explorer.arbitrum.io/',
|
explorer: 'https://rinkeby-explorer.arbitrum.io/',
|
||||||
@ -110,19 +115,23 @@ export const CHAIN_INFO: ChainInfo = {
|
|||||||
label: 'Görli',
|
label: 'Görli',
|
||||||
},
|
},
|
||||||
[SupportedChainId.OPTIMISM]: {
|
[SupportedChainId.OPTIMISM]: {
|
||||||
|
blockWaitMsBeforeWarning: ms`10m`,
|
||||||
bridge: 'https://gateway.optimism.io/',
|
bridge: 'https://gateway.optimism.io/',
|
||||||
docs: 'https://optimism.io/',
|
docs: 'https://optimism.io/',
|
||||||
explorer: 'https://optimistic.etherscan.io/',
|
explorer: 'https://optimistic.etherscan.io/',
|
||||||
infoLink: 'https://info.uniswap.org/#/optimism/',
|
infoLink: 'https://info.uniswap.org/#/optimism/',
|
||||||
label: 'Optimism',
|
label: 'Optimism',
|
||||||
logoUrl: optimismLogoUrl,
|
logoUrl: optimismLogoUrl,
|
||||||
|
statusPage: 'https://optimism.io/status',
|
||||||
},
|
},
|
||||||
[SupportedChainId.OPTIMISTIC_KOVAN]: {
|
[SupportedChainId.OPTIMISTIC_KOVAN]: {
|
||||||
|
blockWaitMsBeforeWarning: ms`10m`,
|
||||||
bridge: 'https://gateway.optimism.io/',
|
bridge: 'https://gateway.optimism.io/',
|
||||||
docs: 'https://optimism.io/',
|
docs: 'https://optimism.io/',
|
||||||
explorer: 'https://optimistic.etherscan.io/',
|
explorer: 'https://optimistic.etherscan.io/',
|
||||||
infoLink: 'https://info.uniswap.org/#/optimism',
|
infoLink: 'https://info.uniswap.org/#/optimism',
|
||||||
label: 'Optimistic Kovan',
|
label: 'Optimistic Kovan',
|
||||||
logoUrl: optimismLogoUrl,
|
logoUrl: optimismLogoUrl,
|
||||||
|
statusPage: 'https://optimism.io/status',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -27,3 +27,4 @@ export const setOpenModal = createAction<ApplicationModal | null>('application/s
|
|||||||
export const addPopup =
|
export const addPopup =
|
||||||
createAction<{ key?: string; removeAfterMs?: number | null; content: PopupContent }>('application/addPopup')
|
createAction<{ key?: string; removeAfterMs?: number | null; content: PopupContent }>('application/addPopup')
|
||||||
export const removePopup = createAction<{ key: string }>('application/removePopup')
|
export const removePopup = createAction<{ key: string }>('application/removePopup')
|
||||||
|
export const setChainConnectivityWarning = createAction<{ warn: boolean }>('application/setChainConnectivityWarning')
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
ApplicationModal,
|
ApplicationModal,
|
||||||
setOpenModal,
|
setOpenModal,
|
||||||
updateChainId,
|
updateChainId,
|
||||||
|
setChainConnectivityWarning,
|
||||||
} from './actions'
|
} from './actions'
|
||||||
|
|
||||||
type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }>
|
type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }>
|
||||||
@ -15,6 +16,7 @@ type PopupList = Array<{ key: string; show: boolean; content: PopupContent; remo
|
|||||||
export interface ApplicationState {
|
export interface ApplicationState {
|
||||||
// used by RTK-Query to build dynamic subgraph urls
|
// used by RTK-Query to build dynamic subgraph urls
|
||||||
readonly chainId: number | null
|
readonly chainId: number | null
|
||||||
|
readonly chainConnectivityWarning: boolean
|
||||||
readonly blockNumber: { readonly [chainId: number]: number }
|
readonly blockNumber: { readonly [chainId: number]: number }
|
||||||
readonly popupList: PopupList
|
readonly popupList: PopupList
|
||||||
readonly openModal: ApplicationModal | null
|
readonly openModal: ApplicationModal | null
|
||||||
@ -22,6 +24,7 @@ export interface ApplicationState {
|
|||||||
|
|
||||||
const initialState: ApplicationState = {
|
const initialState: ApplicationState = {
|
||||||
chainId: null,
|
chainId: null,
|
||||||
|
chainConnectivityWarning: false,
|
||||||
blockNumber: {},
|
blockNumber: {},
|
||||||
popupList: [],
|
popupList: [],
|
||||||
openModal: null,
|
openModal: null,
|
||||||
@ -61,4 +64,7 @@ export default createReducer(initialState, (builder) =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.addCase(setChainConnectivityWarning, (state, { payload: { warn } }) => {
|
||||||
|
state.chainConnectivityWarning = warn
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { CHAIN_INFO } from 'constants/chains'
|
||||||
|
import useDebounce from 'hooks/useDebounce'
|
||||||
|
import useIsWindowVisible from 'hooks/useIsWindowVisible'
|
||||||
|
import { useActiveWeb3React } from 'hooks/web3'
|
||||||
|
import ms from 'ms.macro'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, CHAIN_TAG } from 'state/data/enhanced'
|
import { api, CHAIN_TAG } from 'state/data/enhanced'
|
||||||
import { useAppDispatch, useAppSelector } from 'state/hooks'
|
import { useAppDispatch, useAppSelector } from 'state/hooks'
|
||||||
import { supportedChainId } from 'utils/supportedChainId'
|
import { supportedChainId } from 'utils/supportedChainId'
|
||||||
import useDebounce from '../../hooks/useDebounce'
|
import { setChainConnectivityWarning, updateBlockNumber, updateChainId } from './actions'
|
||||||
import useIsWindowVisible from '../../hooks/useIsWindowVisible'
|
import { useBlockNumber } from './hooks'
|
||||||
import { useActiveWeb3React } from '../../hooks/web3'
|
|
||||||
import { updateBlockNumber, updateChainId } from './actions'
|
|
||||||
|
|
||||||
function useQueryCacheInvalidator() {
|
function useQueryCacheInvalidator() {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@ -20,10 +23,46 @@ function useQueryCacheInvalidator() {
|
|||||||
}, [chainId, dispatch])
|
}, [chainId, dispatch])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NETWORK_HEALTH_CHECK_MS = ms`15s`
|
||||||
|
const DEFAULT_MS_BEFORE_WARNING = ms`10m`
|
||||||
|
|
||||||
|
function useBlockWarningTimer() {
|
||||||
|
const { chainId } = useActiveWeb3React()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const chainConnectivityWarningActive = useAppSelector((state) => state.application.chainConnectivityWarning)
|
||||||
|
const timeout = useRef<NodeJS.Timeout>()
|
||||||
|
const isWindowVisible = useIsWindowVisible()
|
||||||
|
const [msSinceLastBlock, setMsSinceLastBlock] = useState(0)
|
||||||
|
const currentBlock = useBlockNumber()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMsSinceLastBlock(0)
|
||||||
|
}, [currentBlock])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const waitMsBeforeWarning =
|
||||||
|
(chainId ? CHAIN_INFO[chainId]?.blockWaitMsBeforeWarning : DEFAULT_MS_BEFORE_WARNING) ?? DEFAULT_MS_BEFORE_WARNING
|
||||||
|
|
||||||
|
timeout.current = setTimeout(() => {
|
||||||
|
setMsSinceLastBlock(NETWORK_HEALTH_CHECK_MS + msSinceLastBlock)
|
||||||
|
if (msSinceLastBlock > waitMsBeforeWarning && isWindowVisible) {
|
||||||
|
dispatch(setChainConnectivityWarning({ warn: true }))
|
||||||
|
} else if (chainConnectivityWarningActive) {
|
||||||
|
dispatch(setChainConnectivityWarning({ warn: false }))
|
||||||
|
}
|
||||||
|
}, NETWORK_HEALTH_CHECK_MS)
|
||||||
|
|
||||||
|
return function cleanup() {
|
||||||
|
if (timeout.current) {
|
||||||
|
clearTimeout(timeout.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [chainId, chainConnectivityWarningActive, dispatch, isWindowVisible, msSinceLastBlock, setMsSinceLastBlock])
|
||||||
|
}
|
||||||
|
|
||||||
export default function Updater(): null {
|
export default function Updater(): null {
|
||||||
const { library, chainId } = useActiveWeb3React()
|
const { library, chainId } = useActiveWeb3React()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const windowVisible = useIsWindowVisible()
|
const windowVisible = useIsWindowVisible()
|
||||||
|
|
||||||
const [state, setState] = useState<{ chainId: number | undefined; blockNumber: number | null }>({
|
const [state, setState] = useState<{ chainId: number | undefined; blockNumber: number | null }>({
|
||||||
@ -31,6 +70,7 @@ export default function Updater(): null {
|
|||||||
blockNumber: null,
|
blockNumber: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useBlockWarningTimer()
|
||||||
useQueryCacheInvalidator()
|
useQueryCacheInvalidator()
|
||||||
|
|
||||||
const blockNumberCallback = useCallback(
|
const blockNumberCallback = useCallback(
|
||||||
|
Loading…
Reference in New Issue
Block a user