diff --git a/package.json b/package.json index 93ac62264..a45cb926d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "format": "prettier --loglevel warn --write \"**/*.{js,jsx,ts,tsx,css,md,yml,json}\"", "dev": "lerna run dev --parallel", + "dev:ui": "lerna run dev:ui --parallel", "clean": "rm -rf packages/extension/dist packages/get-starket/dist", "build": "lerna run build --stream", "build:sourcemaps": "GEN_SOURCE_MAPS=true lerna run build", diff --git a/packages/extension/package.json b/packages/extension/package.json index 4074ff879..e398a4191 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -47,6 +47,7 @@ "build:sourcemaps": "NODE_ENV=production GEN_SOURCE_MAPS=true webpack", "start": "webpack", "dev": "webpack --color --watch", + "dev:ui": "SHOW_DEV_UI=true webpack --color --watch", "lint": "eslint . --cache --ext .ts,.tsx", "test": "vitest run", "pretest:e2e": "playwright install chromium && ([ -d dist ] || yarn build)", diff --git a/packages/extension/src/ui/App.tsx b/packages/extension/src/ui/App.tsx index a75eb345e..646c89ea5 100644 --- a/packages/extension/src/ui/App.tsx +++ b/packages/extension/src/ui/App.tsx @@ -7,6 +7,7 @@ import { AppRoutes } from "./AppRoutes" import { ErrorBoundary } from "./components/ErrorBoundary" import { LoadingScreen } from "./features/actions/LoadingScreen" import { useExtensionIsInTab } from "./features/browser/tabs" +import DevUI from "./features/dev/DevUI" import { useTracking } from "./services/analytics" import { swrCacheProvider } from "./services/swr" import { GlobalStyle, theme } from "./theme" @@ -24,6 +25,7 @@ export const App: FC = () => { rel="stylesheet" /> + {process.env.SHOW_DEV_UI && } }> }> diff --git a/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx b/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx index aa4b645d2..668c9ad1b 100644 --- a/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx +++ b/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx @@ -1,4 +1,4 @@ -import { FC, Suspense, useEffect, useRef } from "react" +import { FC, useEffect, useRef } from "react" import { Link, useNavigate } from "react-router-dom" import styled from "styled-components" import useSWR from "swr" @@ -8,7 +8,6 @@ import { isDeprecated, } from "../../../shared/wallet.service" import { useAppState } from "../../app.state" -import { ErrorBoundary } from "../../components/ErrorBoundary" import ErrorBoundaryFallbackWithCopyError from "../../components/ErrorBoundaryFallbackWithCopyError" import { IconButton } from "../../components/IconButton" import { AddIcon } from "../../components/Icons/MuiIcons" @@ -75,7 +74,8 @@ export const AccountTokens: FC = ({ account }) => { { suspense: false }, ) - const { isValidating, tokenDetails } = useTokensWithBalance(account) + const { isValidating, error, tokenDetails, tokenDetailsIsInitialising } = + useTokensWithBalance(account) const { data: needsUpgrade = false, mutate } = useSWR( [ @@ -131,30 +131,31 @@ export const AccountTokens: FC = ({ account }) => { )} {showNoBalanceForUpgrade && } - + ) : ( + <> + - } - > - }> - <> - + {tokenDetailsIsInitialising ? ( + + ) : ( navigate(routes.newToken()))}> Add token - - - + )} + + )} ) } diff --git a/packages/extension/src/ui/features/accountTokens/TokenList.tsx b/packages/extension/src/ui/features/accountTokens/TokenList.tsx index 39ad8ff1a..e8086a783 100644 --- a/packages/extension/src/ui/features/accountTokens/TokenList.tsx +++ b/packages/extension/src/ui/features/accountTokens/TokenList.tsx @@ -24,7 +24,7 @@ export const TokenList: FC = ({ navigateToSend = false, }) => { if (!tokenList) { - return <> + return null } return ( diff --git a/packages/extension/src/ui/features/accountTokens/tokens.state.ts b/packages/extension/src/ui/features/accountTokens/tokens.state.ts index a9a63595e..a925f8291 100644 --- a/packages/extension/src/ui/features/accountTokens/tokens.state.ts +++ b/packages/extension/src/ui/features/accountTokens/tokens.state.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo } from "react" import { number } from "starknet" import useSWR from "swr" import create from "zustand" +import shallow from "zustand/shallow" import { messageStream } from "../../../shared/messages" import { Token, equalToken } from "../../../shared/token" @@ -106,6 +107,7 @@ export const selectTokensByNetwork = (networkId: string) => (state: State) => interface UseTokens { tokenDetails: TokenDetailsWithBalance[] + tokenDetailsIsInitialising: boolean isValidating: boolean error?: any } @@ -114,15 +116,28 @@ export const useTokensWithBalance = ( account?: BaseWalletAccount, ): UseTokens => { const selectedAccount = useAccount(account) - const tokensInNetwork = useTokens( - selectTokensByNetwork(selectedAccount?.networkId ?? ""), - ) - const tokenAddresses = useMemo( - () => tokensInNetwork.map((t) => t.address), - [tokensInNetwork], - ) - const { data, isValidating, error, mutate } = useSWR( + const tokensInNetworkSelector = useMemo(() => { + return selectTokensByNetwork(selectedAccount?.networkId ?? "") + }, [selectedAccount?.networkId]) + + const tokenAddressesSelector = useMemo(() => { + return (state: State) => { + const tokensInNetwork = tokensInNetworkSelector(state) + return tokensInNetwork.map((t) => t.address) + } + }, [tokensInNetworkSelector]) + + // shallow compare objects and arrays + const tokensInNetwork = useTokens(tokensInNetworkSelector, shallow) + const tokenAddresses = useTokens(tokenAddressesSelector, shallow) + + const { + data, + isValidating, + error: rawError, + mutate, + } = useSWR( // skip if no account selected selectedAccount && [ getAccountIdentifier(selectedAccount), @@ -130,7 +145,7 @@ export const useTokensWithBalance = ( ], async () => { if (!selectedAccount) { - return {} + return } const balances = await fetchAllTokensBalance( @@ -138,17 +153,45 @@ export const useTokensWithBalance = ( selectedAccount, ) - return balances ?? {} + return balances }, { - suspense: true, refreshInterval: 30000, }, ) - // refetch balances on transaction success or token edit (token was added or removed) + /** + * FIXME: + * Investigate what causes the SWR hook above to cache an empty object `error: {}`, usually observed after reloading the extension + * + * This is subsequently retreived by SWR from the cache and causes an immediately defined error if left unchecked + * + * You can verify this by debugging in the `set` method of `swrCacheProvider`, and + * checking for SWR setting a value containing a key of `error` with an empty object {} + * + * As a workaround we check for empty object here and treat as undefined while the hook revalidates properly + * + */ + + const error: any = useMemo(() => { + if (!rawError) { + return + } + try { + if (JSON.stringify(rawError) === "{}") { + console.warn("FIXME: Ignoring empty object {} error") + return + } + } catch (e) { + // ignore any stringify errors + } + return rawError + }, [rawError]) + + const tokenDetailsIsInitialising = !error && !data && isValidating + + // refetch balances on transaction success useEffect(() => { - mutate() const subscription = messageStream.subscribe(([msg]) => { if (msg.type === "TRANSACTION_SUCCESS") { mutate() // refetch balances @@ -159,7 +202,12 @@ export const useTokensWithBalance = ( subscription.unsubscribe() } } - }, [tokenAddresses.join(":")]) + }, [mutate]) + + // refetch balances on token edit (token was added or removed) + useEffect(() => { + mutate() + }, [mutate, tokenAddresses]) const tokenDetails = useMemo(() => { return tokensInNetwork @@ -170,7 +218,12 @@ export const useTokensWithBalance = ( .filter( (token) => token.showAlways || (token.balance && token.balance.gt(0)), ) - }, [tokenAddresses, data]) - - return { tokenDetails, isValidating, error } + }, [tokensInNetwork, data]) + + return { + tokenDetails, + tokenDetailsIsInitialising, + isValidating, + error, + } } diff --git a/packages/extension/src/ui/features/dev/DevUI.tsx b/packages/extension/src/ui/features/dev/DevUI.tsx new file mode 100644 index 000000000..8582b19e3 --- /dev/null +++ b/packages/extension/src/ui/features/dev/DevUI.tsx @@ -0,0 +1,46 @@ +import { FC, useCallback } from "react" +import styled from "styled-components" +import browser from "webextension-polyfill" + +import { RowCentered } from "../../components/Row" +import { useBackupRequired } from "../recovery/backupDownload.state" + +const Container = styled(RowCentered)` + position: fixed; + z-index: 123; +` + +const DevButton = styled.div` + background-color: rgba(255, 255, 255, 0.15); + padding: 4px 8px; + cursor: pointer; + border-radius: 500px; + margin-top: 4px; + &:hover { + background-color: rgba(255, 255, 255, 0.25); + } +` + +const DevUI: FC = () => { + const reset = useCallback(() => { + // reset cache + const backupState = useBackupRequired.getState() + localStorage.clear() + useBackupRequired.setState(backupState) + }, []) + const onReload = useCallback(() => { + const url = browser.runtime.getURL("index.html") + setTimeout(() => { + // ensure state got persisted before reloading + window.location.href = url + }, 100) + }, []) + return ( + + Reset cache + Reload + + ) +} + +export default DevUI