diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 251a8dc950..6250274365 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -1263,6 +1263,11 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==" }, + "@react-hook/debounce": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-2.0.5.tgz", + "integrity": "sha512-WrwQ1e4vx5lxxxEpGPPliMcs6oUJ8cyEb/GL8OEUPhBW4WL8YRSDW5oPnsOqIPqhHDyQMHgMipXWgj7QVmRMKA==" + }, "@sheerun/mutationobserver-shim": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz", diff --git a/explorer/package.json b/explorer/package.json index 452ff7974a..b094f57e60 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@react-hook/debounce": "^2.0.5", "@solana/web3.js": "^0.56.0", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", diff --git a/explorer/src/components/ClusterModal.tsx b/explorer/src/components/ClusterModal.tsx index ccfcf23635..af693d0579 100644 --- a/explorer/src/components/ClusterModal.tsx +++ b/explorer/src/components/ClusterModal.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Link, useLocation, useHistory } from "react-router-dom"; +import { Link, useHistory, useLocation } from "react-router-dom"; +import { useDebounceCallback } from "@react-hook/debounce"; import { Location } from "history"; import { useCluster, @@ -9,10 +10,12 @@ import { clusterSlug, CLUSTERS, Cluster, - useClusterModal + useClusterModal, + useUpdateCustomUrl } from "../providers/cluster"; import { assertUnreachable } from "../utils"; import Overlay from "./Overlay"; +import { useQuery } from "utils/url"; function ClusterModal() { const [show, setShow] = useClusterModal(); @@ -46,34 +49,35 @@ function ClusterModal() { type InputProps = { activeSuffix: string; active: boolean }; function CustomClusterInput({ activeSuffix, active }: InputProps) { const { customUrl } = useCluster(); + const updateCustomUrl = useUpdateCustomUrl(); const [editing, setEditing] = React.useState(false); + const query = useQuery(); const history = useHistory(); const location = useLocation(); const customClass = (prefix: string) => active ? `${prefix}-${activeSuffix}` : ""; - const clusterLocation = (location: Location, url: string) => { - const params = new URLSearchParams(location.search); - params.set("clusterUrl", url); - params.delete("cluster"); + const clusterLocation = (location: Location) => { + if (customUrl.length > 0) query.set("cluster", "custom"); return { ...location, - search: params.toString() + search: query.toString() }; }; - const updateCustomUrl = React.useCallback( - (url: string) => { - history.push(clusterLocation(location, url)); - }, - [history, location] - ); + const onUrlInput = useDebounceCallback((url: string) => { + updateCustomUrl(url); + if (url.length > 0) { + query.set("cluster", "custom"); + history.push({ ...location, search: query.toString() }); + } + }, 500); const inputTextClass = editing ? "" : "text-muted"; return ( clusterLocation(location, customUrl)} + to={location => clusterLocation(location)} className="btn input-group input-group-merge p-0" > setEditing(true)} onBlur={() => setEditing(false)} - onInput={e => updateCustomUrl(e.currentTarget.value)} + onInput={e => onUrlInput(e.currentTarget.value)} />
@@ -133,14 +137,10 @@ function ClusterToggle() { const clusterLocation = (location: Location) => { const params = new URLSearchParams(location.search); const slug = clusterSlug(net); - if (slug && slug !== "mainnet-beta") { + if (slug !== "mainnet-beta") { params.set("cluster", slug); - params.delete("clusterUrl"); } else { params.delete("cluster"); - if (slug === "mainnet-beta") { - params.delete("clusterUrl"); - } } return { ...location, diff --git a/explorer/src/components/TransactionDetails.tsx b/explorer/src/components/TransactionDetails.tsx index 3daf01679a..380dea50aa 100644 --- a/explorer/src/components/TransactionDetails.tsx +++ b/explorer/src/components/TransactionDetails.tsx @@ -3,10 +3,9 @@ import { useFetchTransactionStatus, useTransactionStatus, useTransactionDetails, - useDetailsDispatch, FetchStatus } from "../providers/transactions"; -import { fetchDetails } from "providers/transactions/details"; +import { useFetchTransactionDetails } from "providers/transactions/details"; import { useCluster, useClusterModal } from "providers/cluster"; import { TransactionSignature, @@ -206,12 +205,11 @@ function StatusCard({ signature }: Props) { function AccountsCard({ signature }: Props) { const details = useTransactionDetails(signature); - const dispatch = useDetailsDispatch(); - const { url } = useCluster(); const fetchStatus = useFetchTransactionStatus(); + const fetchDetails = useFetchTransactionDetails(); const refreshStatus = () => fetchStatus(signature); - const refreshDetails = () => fetchDetails(dispatch, signature, url); + const refreshDetails = () => fetchDetails(signature); const transaction = details?.transaction?.transaction; const message = React.useMemo(() => { return transaction?.compileMessage(); @@ -308,9 +306,8 @@ function AccountsCard({ signature }: Props) { function InstructionsSection({ signature }: Props) { const status = useTransactionStatus(signature); const details = useTransactionDetails(signature); - const dispatch = useDetailsDispatch(); - const { url } = useCluster(); - const refreshDetails = () => fetchDetails(dispatch, signature, url); + const fetchDetails = useFetchTransactionDetails(); + const refreshDetails = () => fetchDetails(signature); if (!status || !status.info || !details || !details.transaction) return null; diff --git a/explorer/src/providers/cluster.tsx b/explorer/src/providers/cluster.tsx index 3ea32f0e2b..392af5a89c 100644 --- a/explorer/src/providers/cluster.tsx +++ b/explorer/src/providers/cluster.tsx @@ -1,6 +1,7 @@ import React from "react"; import { clusterApiUrl, Connection } from "@solana/web3.js"; import { useQuery } from "../utils/url"; +import { useHistory, useLocation } from "react-router-dom"; export enum ClusterStatus { Connected, @@ -22,7 +23,7 @@ export const CLUSTERS = [ Cluster.Custom ]; -export function clusterSlug(cluster: Cluster): string | undefined { +export function clusterSlug(cluster: Cluster): string { switch (cluster) { case Cluster.MainnetBeta: return "mainnet-beta"; @@ -31,7 +32,7 @@ export function clusterSlug(cluster: Cluster): string | undefined { case Cluster.Devnet: return "devnet"; case Cluster.Custom: - return undefined; + return "custom"; } } @@ -52,8 +53,20 @@ export const MAINNET_BETA_URL = clusterApiUrl("mainnet-beta"); export const TESTNET_URL = clusterApiUrl("testnet"); export const DEVNET_URL = clusterApiUrl("devnet"); +export function clusterUrl(cluster: Cluster, customUrl: string): string { + switch (cluster) { + case Cluster.Devnet: + return DEVNET_URL; + case Cluster.MainnetBeta: + return MAINNET_BETA_URL; + case Cluster.Testnet: + return TESTNET_URL; + case Cluster.Custom: + return customUrl; + } +} + export const DEFAULT_CLUSTER = Cluster.MainnetBeta; -export const DEFAULT_CUSTOM_URL = "http://localhost:8899"; interface State { cluster: Cluster; @@ -88,51 +101,19 @@ function clusterReducer(state: State, action: Action): State { } } -function parseQuery( - query: URLSearchParams -): { cluster: Cluster; customUrl: string } { +function parseQuery(query: URLSearchParams): Cluster { const clusterParam = query.get("cluster"); - const clusterUrlParam = query.get("clusterUrl"); - - let cluster; - let customUrl = DEFAULT_CUSTOM_URL; - switch (clusterUrlParam) { - case MAINNET_BETA_URL: - cluster = Cluster.MainnetBeta; - break; - case DEVNET_URL: - cluster = Cluster.Devnet; - break; - case TESTNET_URL: - cluster = Cluster.Testnet; - break; - } - switch (clusterParam) { - case "mainnet-beta": - cluster = Cluster.MainnetBeta; - break; + case "custom": + return Cluster.Custom; case "devnet": - cluster = Cluster.Devnet; - break; + return Cluster.Devnet; case "testnet": - cluster = Cluster.Testnet; - break; + return Cluster.Testnet; + case "mainnet-beta": + default: + return Cluster.MainnetBeta; } - - if (!cluster) { - if (!clusterUrlParam) { - cluster = DEFAULT_CLUSTER; - } else { - cluster = Cluster.Custom; - customUrl = clusterUrlParam; - } - } - - return { - cluster, - customUrl - }; } type SetShowModal = React.Dispatch>; @@ -146,16 +127,28 @@ type ClusterProviderProps = { children: React.ReactNode }; export function ClusterProvider({ children }: ClusterProviderProps) { const [state, dispatch] = React.useReducer(clusterReducer, { cluster: DEFAULT_CLUSTER, - customUrl: DEFAULT_CUSTOM_URL, + customUrl: "", status: ClusterStatus.Connecting }); const [showModal, setShowModal] = React.useState(false); - const { cluster, customUrl } = parseQuery(useQuery()); + const query = useQuery(); + const cluster = parseQuery(query); + const history = useHistory(); + const location = useLocation(); - // Reconnect to cluster when it changes + // Reconnect to cluster when params change React.useEffect(() => { - updateCluster(dispatch, cluster, customUrl); - }, [cluster, customUrl]); + if (cluster === Cluster.Custom) { + // Remove cluster param if custom url has not been set + if (state.customUrl.length === 0) { + query.delete("cluster"); + history.push({ ...location, search: query.toString() }); + return; + } + } + + updateCluster(dispatch, cluster, state.customUrl); + }, [cluster, state.customUrl]); // eslint-disable-line react-hooks/exhaustive-deps return ( @@ -168,19 +161,6 @@ export function ClusterProvider({ children }: ClusterProviderProps) { ); } -export function clusterUrl(cluster: Cluster, customUrl: string): string { - switch (cluster) { - case Cluster.Devnet: - return DEVNET_URL; - case Cluster.MainnetBeta: - return MAINNET_BETA_URL; - case Cluster.Testnet: - return TESTNET_URL; - case Cluster.Custom: - return customUrl; - } -} - async function updateCluster( dispatch: Dispatch, cluster: Cluster, @@ -207,6 +187,17 @@ async function updateCluster( } } +export function useUpdateCustomUrl() { + const dispatch = React.useContext(DispatchContext); + if (!dispatch) { + throw new Error(`useUpdateCustomUrl must be used within a ClusterProvider`); + } + + return (customUrl: string) => { + updateCluster(dispatch, Cluster.Custom, customUrl); + }; +} + export function useCluster() { const context = React.useContext(StateContext); if (!context) { diff --git a/explorer/src/providers/transactions/details.tsx b/explorer/src/providers/transactions/details.tsx index fa218ca319..106c1467fe 100644 --- a/explorer/src/providers/transactions/details.tsx +++ b/explorer/src/providers/transactions/details.tsx @@ -128,7 +128,7 @@ export function DetailsProvider({ children }: DetailsProviderProps) { ); } -export async function fetchDetails( +async function fetchDetails( dispatch: Dispatch, signature: TransactionSignature, url: string @@ -151,3 +151,17 @@ export async function fetchDetails( } dispatch({ type: ActionType.Update, fetchStatus, signature, transaction }); } + +export function useFetchTransactionDetails() { + const dispatch = React.useContext(DispatchContext); + if (!dispatch) { + throw new Error( + `useFetchTransactionDetails must be used within a TransactionsProvider` + ); + } + + const { url } = useCluster(); + return (signature: TransactionSignature) => { + url && fetchDetails(dispatch, signature, url); + }; +} diff --git a/explorer/src/providers/transactions/index.tsx b/explorer/src/providers/transactions/index.tsx index a982a65db9..ad7808ec0e 100644 --- a/explorer/src/providers/transactions/index.tsx +++ b/explorer/src/providers/transactions/index.tsx @@ -12,8 +12,7 @@ import { useQuery } from "../../utils/url"; import { useCluster, Cluster, ClusterStatus } from "../cluster"; import { DetailsProvider, - StateContext as DetailsStateContext, - DispatchContext as DetailsDispatchContext + StateContext as DetailsStateContext } from "./details"; import base58 from "bs58"; import { useFetchAccountInfo } from "../accounts"; @@ -308,16 +307,6 @@ export function useTransactionDetails(signature: TransactionSignature) { return context[signature]; } -export function useDetailsDispatch() { - const context = React.useContext(DetailsDispatchContext); - if (!context) { - throw new Error( - `useDetailsDispatch must be used within a TransactionsProvider` - ); - } - return context; -} - export function useFetchTransactionStatus() { const dispatch = React.useContext(DispatchContext); if (!dispatch) {