Przeglądaj źródła

Use common provider for explorer cached data (#11582)

Justin Starry 5 lat temu
rodzic
commit
a992bb5f94

+ 3 - 2
explorer/src/components/account/OwnedTokensCard.tsx

@@ -1,6 +1,6 @@
 import React from "react";
 import { PublicKey } from "@solana/web3.js";
-import { FetchStatus } from "providers/accounts";
+import { FetchStatus } from "providers/cache";
 import {
   useFetchAccountOwnedTokens,
   useAccountOwnedTokens,
@@ -24,7 +24,8 @@ export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
     return null;
   }
 
-  const { status, tokens } = ownedTokens;
+  const { status } = ownedTokens;
+  const tokens = ownedTokens.data?.tokens;
   const fetching = status === FetchStatus.Fetching;
   if (fetching && (tokens === undefined || tokens.length === 0)) {
     return <LoadingCard message="Loading owned tokens" />;

+ 12 - 10
explorer/src/components/account/TokenHistoryCard.tsx

@@ -4,7 +4,7 @@ import {
   ConfirmedSignatureInfo,
   ParsedInstruction,
 } from "@solana/web3.js";
-import { FetchStatus } from "providers/accounts";
+import { FetchStatus } from "providers/cache";
 import {
   useAccountHistories,
   useFetchAccountHistory,
@@ -34,7 +34,7 @@ export function TokenHistoryCard({ pubkey }: { pubkey: PublicKey }) {
     return null;
   }
 
-  const { tokens } = ownedTokens;
+  const tokens = ownedTokens.data?.tokens;
   if (tokens === undefined || tokens.length === 0) return null;
 
   return <TokenHistoryTable tokens={tokens} />;
@@ -62,17 +62,17 @@ function TokenHistoryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
 
   const fetchedFullHistory = tokens.every((token) => {
     const history = accountHistories[token.pubkey.toBase58()];
-    return history && history.foundOldest === true;
+    return history?.data?.foundOldest === true;
   });
 
   const fetching = tokens.some((token) => {
     const history = accountHistories[token.pubkey.toBase58()];
-    return history && history.status === FetchStatus.Fetching;
+    return history?.status === FetchStatus.Fetching;
   });
 
   const failed = tokens.some((token) => {
     const history = accountHistories[token.pubkey.toBase58()];
-    return history && history.status === FetchStatus.FetchFailed;
+    return history?.status === FetchStatus.FetchFailed;
   });
 
   const mintAndTxs = tokens
@@ -81,12 +81,13 @@ function TokenHistoryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
       history: accountHistories[token.pubkey.toBase58()],
     }))
     .filter(({ history }) => {
-      return (
-        history !== undefined && history.fetched && history.fetched.length > 0
-      );
+      return history?.data?.fetched && history.data.fetched.length > 0;
     })
     .flatMap(({ mint, history }) =>
-      (history.fetched as ConfirmedSignatureInfo[]).map((tx) => ({ mint, tx }))
+      (history?.data?.fetched as ConfirmedSignatureInfo[]).map((tx) => ({
+        mint,
+        tx,
+      }))
     );
 
   if (mintAndTxs.length === 0) {
@@ -196,7 +197,8 @@ function TokenTransactionRow({
     if (!details) fetchDetails(tx.signature);
   }, []); // eslint-disable-line react-hooks/exhaustive-deps
 
-  const instructions = details?.transaction?.transaction.message.instructions;
+  const instructions =
+    details?.data?.transaction?.transaction.message.instructions;
   if (instructions) {
     const tokenInstructions = instructions.filter(
       (ix) => "parsed" in ix && ix.program === "spl-token"

+ 9 - 11
explorer/src/components/account/TransactionHistoryCard.tsx

@@ -1,10 +1,7 @@
 import React from "react";
 import { PublicKey } from "@solana/web3.js";
-import {
-  FetchStatus,
-  useAccountInfo,
-  useAccountHistory,
-} from "providers/accounts";
+import { FetchStatus } from "providers/cache";
+import { useAccountInfo, useAccountHistory } from "providers/accounts";
 import { useFetchAccountHistory } from "providers/accounts/history";
 import { Signature } from "components/common/Signature";
 import { ErrorCard } from "components/common/ErrorCard";
@@ -22,9 +19,11 @@ export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
     if (!history) refresh();
   }, [address]); // eslint-disable-line react-hooks/exhaustive-deps
 
-  if (!info || !history || info.lamports === undefined) {
+  if (!history || info?.data === undefined) {
     return null;
-  } else if (history.fetched === undefined) {
+  }
+
+  if (history?.data === undefined) {
     if (history.status === FetchStatus.Fetching) {
       return <LoadingCard message="Loading history" />;
     }
@@ -34,7 +33,8 @@ export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
     );
   }
 
-  if (history.fetched.length === 0) {
+  const transactions = history.data.fetched;
+  if (transactions.length === 0) {
     if (history.status === FetchStatus.Fetching) {
       return <LoadingCard message="Loading history" />;
     }
@@ -48,8 +48,6 @@ export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
   }
 
   const detailsList: React.ReactNode[] = [];
-  const transactions = history.fetched;
-
   for (var i = 0; i < transactions.length; i++) {
     const slot = transactions[i].slot;
     const slotTransactions = [transactions[i]];
@@ -126,7 +124,7 @@ export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
       </div>
 
       <div className="card-footer">
-        {history.foundOldest ? (
+        {history.data.foundOldest ? (
           <div className="text-muted text-center">Fetched full history</div>
         ) : (
           <button

+ 9 - 11
explorer/src/pages/AccountDetailsPage.tsx

@@ -1,10 +1,7 @@
 import React from "react";
 import { PublicKey } from "@solana/web3.js";
-import {
-  FetchStatus,
-  useFetchAccountInfo,
-  useAccountInfo,
-} from "providers/accounts";
+import { FetchStatus } from "providers/cache";
+import { useFetchAccountInfo, useAccountInfo } from "providers/accounts";
 import { StakeAccountSection } from "components/account/StakeAccountSection";
 import { TokenAccountSection } from "components/account/TokenAccountSection";
 import { ErrorCard } from "components/common/ErrorCard";
@@ -62,12 +59,13 @@ function InfoSection({ pubkey }: { pubkey: PublicKey }) {
     return <LoadingCard />;
   } else if (
     info.status === FetchStatus.FetchFailed ||
-    info.lamports === undefined
+    info.data?.lamports === undefined
   ) {
     return <ErrorCard retry={() => refresh(pubkey)} text="Fetch Failed" />;
   }
 
-  const data = info.details?.data;
+  const account = info.data;
+  const data = account?.details?.data;
   if (data && data.name === "stake") {
     let stakeAccountType, stakeAccount;
     if ("accountType" in data.parsed) {
@@ -80,15 +78,15 @@ function InfoSection({ pubkey }: { pubkey: PublicKey }) {
 
     return (
       <StakeAccountSection
-        account={info}
+        account={account}
         stakeAccount={stakeAccount}
         stakeAccountType={stakeAccountType}
       />
     );
   } else if (data && data.name === "spl-token") {
-    return <TokenAccountSection account={info} tokenAccount={data.parsed} />;
+    return <TokenAccountSection account={account} tokenAccount={data.parsed} />;
   } else {
-    return <UnknownAccountCard account={info} />;
+    return <UnknownAccountCard account={account} />;
   }
 }
 
@@ -96,7 +94,7 @@ type MoreTabs = "history" | "tokens";
 function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) {
   const address = pubkey.toBase58();
   const info = useAccountInfo(address);
-  if (!info || info.lamports === undefined) return null;
+  if (info?.data === undefined) return null;
 
   return (
     <>

+ 17 - 17
explorer/src/pages/TransactionDetailsPage.tsx

@@ -3,7 +3,6 @@ import {
   useFetchTransactionStatus,
   useTransactionStatus,
   useTransactionDetails,
-  FetchStatus,
 } from "providers/transactions";
 import { useFetchTransactionDetails } from "providers/transactions/details";
 import { useCluster, ClusterStatus } from "providers/cluster";
@@ -27,6 +26,7 @@ import { Address } from "components/common/Address";
 import { Signature } from "components/common/Signature";
 import { intoTransactionInstruction } from "utils/tx";
 import { TokenDetailsCard } from "components/instruction/token/TokenDetailsCard";
+import { FetchStatus } from "providers/cache";
 
 type Props = { signature: TransactionSignature };
 export function TransactionDetailsPage({ signature }: Props) {
@@ -67,13 +67,13 @@ function StatusCard({ signature }: Props) {
     }
   }, [signature, clusterStatus]); // eslint-disable-line react-hooks/exhaustive-deps
 
-  if (!status || status.fetchStatus === FetchStatus.Fetching) {
+  if (!status || status.status === FetchStatus.Fetching) {
     return <LoadingCard />;
-  } else if (status?.fetchStatus === FetchStatus.FetchFailed) {
+  } else if (status.status === FetchStatus.FetchFailed) {
     return (
       <ErrorCard retry={() => fetchStatus(signature)} text="Fetch Failed" />
     );
-  } else if (!status.info) {
+  } else if (!status.data?.info) {
     if (firstAvailableBlock !== undefined) {
       return (
         <ErrorCard
@@ -86,7 +86,7 @@ function StatusCard({ signature }: Props) {
     return <ErrorCard retry={() => fetchStatus(signature)} text="Not Found" />;
   }
 
-  const { info } = status;
+  const { info } = status.data;
   const renderResult = () => {
     let statusClass = "success";
     let statusText = "Success";
@@ -102,8 +102,8 @@ function StatusCard({ signature }: Props) {
     );
   };
 
-  const fee = details?.transaction?.meta?.fee;
-  const transaction = details?.transaction?.transaction;
+  const fee = details?.data?.transaction?.meta?.fee;
+  const transaction = details?.data?.transaction?.transaction;
   const blockhash = transaction?.message.recentBlockhash;
   const isNonce = (() => {
     if (!transaction) return false;
@@ -203,18 +203,18 @@ function AccountsCard({ signature }: Props) {
   const fetchDetails = useFetchTransactionDetails();
   const refreshStatus = () => fetchStatus(signature);
   const refreshDetails = () => fetchDetails(signature);
-  const transaction = details?.transaction?.transaction;
+  const transaction = details?.data?.transaction?.transaction;
   const message = transaction?.message;
   const status = useTransactionStatus(signature);
 
   // Fetch details on load
   React.useEffect(() => {
-    if (status?.info?.confirmations === "max" && !details) {
+    if (status?.data?.info?.confirmations === "max" && !details) {
       fetchDetails(signature);
     }
   }, [signature, details, status, fetchDetails]);
 
-  if (!status || !status.info) {
+  if (!status?.data?.info) {
     return null;
   } else if (!details) {
     return (
@@ -223,15 +223,15 @@ function AccountsCard({ signature }: Props) {
         text="Details are not available until the transaction reaches MAX confirmations"
       />
     );
-  } else if (details.fetchStatus === FetchStatus.Fetching) {
+  } else if (details.status === FetchStatus.Fetching) {
     return <LoadingCard />;
-  } else if (details?.fetchStatus === FetchStatus.FetchFailed) {
+  } else if (details.status === FetchStatus.FetchFailed) {
     return <ErrorCard retry={refreshDetails} text="Fetch Failed" />;
-  } else if (!details.transaction || !message) {
+  } else if (!details.data?.transaction || !message) {
     return <ErrorCard retry={refreshDetails} text="Not Found" />;
   }
 
-  const { meta } = details.transaction;
+  const { meta } = details.data.transaction;
   if (!meta) {
     if (isCached(url, signature)) {
       return null;
@@ -308,14 +308,14 @@ function InstructionsSection({ signature }: Props) {
   const fetchDetails = useFetchTransactionDetails();
   const refreshDetails = () => fetchDetails(signature);
 
-  if (!status || !status.info || !details || !details.transaction) return null;
+  if (!status?.data?.info || !details?.data?.transaction) return null;
 
-  const { transaction } = details.transaction;
+  const { transaction } = details.data.transaction;
   if (transaction.message.instructions.length === 0) {
     return <ErrorCard retry={refreshDetails} text="No instructions found" />;
   }
 
-  const result = status.info.result;
+  const result = status.data.info.result;
   const instructionDetails = transaction.message.instructions.map(
     (next, index) => {
       if ("parsed" in next) {

+ 48 - 91
explorer/src/providers/accounts/history.tsx

@@ -5,51 +5,29 @@ import {
   TransactionSignature,
   Connection,
 } from "@solana/web3.js";
-import { FetchStatus } from "./index";
 import { useCluster } from "../cluster";
+import * as Cache from "providers/cache";
+import { ActionType, FetchStatus } from "providers/cache";
 
-interface AccountHistory {
-  status: FetchStatus;
-  fetched?: ConfirmedSignatureInfo[];
+type AccountHistory = {
+  fetched: ConfirmedSignatureInfo[];
   foundOldest: boolean;
-}
-
-type State = {
-  url: string;
-  map: { [address: string]: AccountHistory };
 };
 
-export enum ActionType {
-  Update,
-  Clear,
-}
-
-interface Update {
-  type: ActionType.Update;
-  url: string;
-  pubkey: PublicKey;
-  status: FetchStatus;
-  fetched?: ConfirmedSignatureInfo[];
+type HistoryUpdate = {
+  history?: AccountHistory;
   before?: TransactionSignature;
-  foundOldest?: boolean;
-}
-
-interface Clear {
-  type: ActionType.Clear;
-  url: string;
-}
+};
 
-type Action = Update | Clear;
-type Dispatch = (action: Action) => void;
+type State = Cache.State<AccountHistory>;
+type Dispatch = Cache.Dispatch<HistoryUpdate>;
 
 function combineFetched(
-  fetched: ConfirmedSignatureInfo[] | undefined,
+  fetched: ConfirmedSignatureInfo[],
   current: ConfirmedSignatureInfo[] | undefined,
   before: TransactionSignature | undefined
 ) {
-  if (fetched === undefined) {
-    return current;
-  } else if (current === undefined) {
+  if (current === undefined) {
     return fetched;
   }
 
@@ -60,46 +38,19 @@ function combineFetched(
   }
 }
 
-function reducer(state: State, action: Action): State {
-  switch (action.type) {
-    case ActionType.Update: {
-      if (action.url !== state.url) return state;
-      const address = action.pubkey.toBase58();
-      if (state.map[address]) {
-        return {
-          ...state,
-          map: {
-            ...state.map,
-            [address]: {
-              status: action.status,
-              fetched: combineFetched(
-                action.fetched,
-                state.map[address].fetched,
-                action.before
-              ),
-              foundOldest: action.foundOldest || state.map[address].foundOldest,
-            },
-          },
-        };
-      } else {
-        return {
-          ...state,
-          map: {
-            ...state.map,
-            [address]: {
-              status: action.status,
-              fetched: action.fetched,
-              foundOldest: action.foundOldest || false,
-            },
-          },
-        };
-      }
-    }
-
-    case ActionType.Clear: {
-      return { url: action.url, map: {} };
-    }
-  }
+function reconcile(
+  history: AccountHistory | undefined,
+  update: HistoryUpdate | undefined
+) {
+  if (update?.history === undefined) return;
+  return {
+    fetched: combineFetched(
+      update.history.fetched,
+      history?.fetched,
+      update?.before
+    ),
+    foundOldest: update?.history?.foundOldest || history?.foundOldest || false,
+  };
 }
 
 const StateContext = React.createContext<State | undefined>(undefined);
@@ -108,11 +59,11 @@ const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
 type HistoryProviderProps = { children: React.ReactNode };
 export function HistoryProvider({ children }: HistoryProviderProps) {
   const { url } = useCluster();
-  const [state, dispatch] = React.useReducer(reducer, { url, map: {} });
+  const [state, dispatch] = Cache.useCustomReducer(url, reconcile);
 
   React.useEffect(() => {
     dispatch({ type: ActionType.Clear, url });
-  }, [url]);
+  }, [dispatch, url]);
 
   return (
     <StateContext.Provider value={state}>
@@ -132,20 +83,22 @@ async function fetchAccountHistory(
   dispatch({
     type: ActionType.Update,
     status: FetchStatus.Fetching,
-    pubkey,
+    key: pubkey.toBase58(),
     url,
   });
 
   let status;
-  let fetched;
-  let foundOldest;
+  let history;
   try {
     const connection = new Connection(url);
-    fetched = await connection.getConfirmedSignaturesForAddress2(
+    const fetched = await connection.getConfirmedSignaturesForAddress2(
       pubkey,
       options
     );
-    foundOldest = fetched.length < options.limit;
+    history = {
+      fetched,
+      foundOldest: fetched.length < options.limit,
+    };
     status = FetchStatus.Fetched;
   } catch (error) {
     console.error("Failed to fetch account history", error);
@@ -154,11 +107,12 @@ async function fetchAccountHistory(
   dispatch({
     type: ActionType.Update,
     url,
+    key: pubkey.toBase58(),
     status,
-    fetched,
-    before: options?.before,
-    pubkey,
-    foundOldest,
+    data: {
+      history,
+      before: options?.before,
+    },
   });
 }
 
@@ -171,17 +125,19 @@ export function useAccountHistories() {
     );
   }
 
-  return context.map;
+  return context.entries;
 }
 
-export function useAccountHistory(address: string) {
+export function useAccountHistory(
+  address: string
+): Cache.CacheEntry<AccountHistory> | undefined {
   const context = React.useContext(StateContext);
 
   if (!context) {
     throw new Error(`useAccountHistory must be used within a AccountsProvider`);
   }
 
-  return context.map[address];
+  return context.entries[address];
 }
 
 export function useFetchAccountHistory() {
@@ -195,10 +151,11 @@ export function useFetchAccountHistory() {
   }
 
   return (pubkey: PublicKey, refresh?: boolean) => {
-    const before = state.map[pubkey.toBase58()];
-    if (!refresh && before && before.fetched && before.fetched.length > 0) {
-      if (before.foundOldest) return;
-      const oldest = before.fetched[before.fetched.length - 1].signature;
+    const before = state.entries[pubkey.toBase58()];
+    if (!refresh && before?.data?.fetched && before.data.fetched.length > 0) {
+      if (before.data.foundOldest) return;
+      const oldest =
+        before.data.fetched[before.data.fetched.length - 1].signature;
       fetchAccountHistory(dispatch, pubkey, url, { before: oldest, limit: 25 });
     } else {
       fetchAccountHistory(dispatch, pubkey, url, { limit: 25 });

+ 27 - 110
explorer/src/providers/accounts/index.tsx

@@ -8,14 +8,10 @@ import { coerce } from "superstruct";
 import { ParsedInfo } from "validators";
 import { StakeAccount } from "validators/accounts/stake";
 import { TokenAccount } from "validators/accounts/token";
+import * as Cache from "providers/cache";
+import { ActionType, FetchStatus } from "providers/cache";
 export { useAccountHistory } from "./history";
 
-export enum FetchStatus {
-  Fetching,
-  FetchFailed,
-  Fetched,
-}
-
 export type StakeProgramData = {
   name: "stake";
   parsed: StakeAccount | StakeAccountWasm;
@@ -37,98 +33,12 @@ export interface Details {
 
 export interface Account {
   pubkey: PublicKey;
-  status: FetchStatus;
-  lamports?: number;
+  lamports: number;
   details?: Details;
 }
 
-type Accounts = { [address: string]: Account };
-interface State {
-  accounts: Accounts;
-  url: string;
-}
-
-export enum ActionType {
-  Update,
-  Fetch,
-  Clear,
-}
-
-interface Update {
-  type: ActionType.Update;
-  url: string;
-  pubkey: PublicKey;
-  data: {
-    status: FetchStatus;
-    lamports?: number;
-    details?: Details;
-  };
-}
-
-interface Fetch {
-  type: ActionType.Fetch;
-  url: string;
-  pubkey: PublicKey;
-}
-
-interface Clear {
-  type: ActionType.Clear;
-  url: string;
-}
-
-type Action = Update | Fetch | Clear;
-type Dispatch = (action: Action) => void;
-
-function reducer(state: State, action: Action): State {
-  if (action.type === ActionType.Clear) {
-    return { url: action.url, accounts: {} };
-  } else if (action.url !== state.url) {
-    return state;
-  }
-
-  switch (action.type) {
-    case ActionType.Fetch: {
-      const address = action.pubkey.toBase58();
-      const account = state.accounts[address];
-      if (account) {
-        const accounts = {
-          ...state.accounts,
-          [address]: {
-            pubkey: account.pubkey,
-            status: FetchStatus.Fetching,
-          },
-        };
-        return { ...state, accounts };
-      } else {
-        const accounts = {
-          ...state.accounts,
-          [address]: {
-            status: FetchStatus.Fetching,
-            pubkey: action.pubkey,
-          },
-        };
-        return { ...state, accounts };
-      }
-    }
-
-    case ActionType.Update: {
-      const address = action.pubkey.toBase58();
-      const account = state.accounts[address];
-      if (account) {
-        const accounts = {
-          ...state.accounts,
-          [address]: {
-            ...account,
-            ...action.data,
-          },
-        };
-        return { ...state, accounts };
-      }
-      break;
-    }
-  }
-  return state;
-}
+type State = Cache.State<Account>;
+type Dispatch = Cache.Dispatch<Account>;
 
 const StateContext = React.createContext<State | undefined>(undefined);
 const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
@@ -136,15 +46,12 @@ const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
 type AccountsProviderProps = { children: React.ReactNode };
 export function AccountsProvider({ children }: AccountsProviderProps) {
   const { url } = useCluster();
-  const [state, dispatch] = React.useReducer(reducer, {
-    url,
-    accounts: {},
-  });
+  const [state, dispatch] = Cache.useReducer<Account>(url);
 
-  // Clear account statuses whenever cluster is changed
+  // Clear accounts cache whenever cluster is changed
   React.useEffect(() => {
     dispatch({ type: ActionType.Clear, url });
-  }, [url]);
+  }, [dispatch, url]);
 
   return (
     <StateContext.Provider value={state}>
@@ -163,18 +70,20 @@ async function fetchAccountInfo(
   url: string
 ) {
   dispatch({
-    type: ActionType.Fetch,
-    pubkey,
+    type: ActionType.Update,
+    key: pubkey.toBase58(),
+    status: Cache.FetchStatus.Fetching,
     url,
   });
 
+  let data;
   let fetchStatus;
-  let details;
-  let lamports;
   try {
     const result = (
       await new Connection(url, "single").getParsedAccountInfo(pubkey)
     ).value;
+
+    let lamports, details;
     if (result === null) {
       lamports = 0;
     } else {
@@ -227,13 +136,19 @@ async function fetchAccountInfo(
         data,
       };
     }
+    data = { pubkey, lamports, details };
     fetchStatus = FetchStatus.Fetched;
   } catch (error) {
     console.error("Failed to fetch account info", error);
     fetchStatus = FetchStatus.FetchFailed;
   }
-  const data = { status: fetchStatus, lamports, details };
-  dispatch({ type: ActionType.Update, data, pubkey, url });
+  dispatch({
+    type: ActionType.Update,
+    status: fetchStatus,
+    data,
+    key: pubkey.toBase58(),
+    url,
+  });
 }
 
 export function useAccounts() {
@@ -241,17 +156,19 @@ export function useAccounts() {
   if (!context) {
     throw new Error(`useAccounts must be used within a AccountsProvider`);
   }
-  return context.accounts;
+  return context.entries;
 }
 
-export function useAccountInfo(address: string) {
+export function useAccountInfo(
+  address: string
+): Cache.CacheEntry<Account> | undefined {
   const context = React.useContext(StateContext);
 
   if (!context) {
     throw new Error(`useAccountInfo must be used within a AccountsProvider`);
   }
 
-  return context.accounts[address];
+  return context.entries[address];
 }
 
 export function useFetchAccountInfo() {

+ 23 - 69
explorer/src/providers/accounts/tokens.tsx

@@ -1,6 +1,7 @@
 import React from "react";
 import { Connection, PublicKey } from "@solana/web3.js";
-import { FetchStatus } from "./index";
+import * as Cache from "providers/cache";
+import { ActionType, FetchStatus } from "providers/cache";
 import { TokenAccountInfo } from "validators/accounts/token";
 import { useCluster } from "../cluster";
 import { coerce } from "superstruct";
@@ -11,63 +12,11 @@ export type TokenInfoWithPubkey = {
 };
 
 interface AccountTokens {
-  status: FetchStatus;
   tokens?: TokenInfoWithPubkey[];
 }
 
-interface Update {
-  type: "update";
-  url: string;
-  pubkey: PublicKey;
-  status: FetchStatus;
-  tokens?: TokenInfoWithPubkey[];
-}
-
-interface Clear {
-  type: "clear";
-  url: string;
-}
-
-type Action = Update | Clear;
-type State = {
-  url: string;
-  map: { [address: string]: AccountTokens };
-};
-
-type Dispatch = (action: Action) => void;
-
-function reducer(state: State, action: Action): State {
-  if (action.type === "clear") {
-    return {
-      url: action.url,
-      map: {},
-    };
-  } else if (action.url !== state.url) {
-    return state;
-  }
-
-  const address = action.pubkey.toBase58();
-  let addressEntry = state.map[address];
-  if (addressEntry && action.status === FetchStatus.Fetching) {
-    addressEntry = {
-      ...addressEntry,
-      status: FetchStatus.Fetching,
-    };
-  } else {
-    addressEntry = {
-      tokens: action.tokens,
-      status: action.status,
-    };
-  }
-
-  return {
-    ...state,
-    map: {
-      ...state.map,
-      [address]: addressEntry,
-    },
-  };
-}
+type State = Cache.State<AccountTokens>;
+type Dispatch = Cache.Dispatch<AccountTokens>;
 
 const StateContext = React.createContext<State | undefined>(undefined);
 const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
@@ -75,11 +24,11 @@ const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
 type ProviderProps = { children: React.ReactNode };
 export function TokensProvider({ children }: ProviderProps) {
   const { url } = useCluster();
-  const [state, dispatch] = React.useReducer(reducer, { url, map: {} });
+  const [state, dispatch] = Cache.useReducer<AccountTokens>(url);
 
   React.useEffect(() => {
-    dispatch({ url, type: "clear" });
-  }, [url]);
+    dispatch({ url, type: ActionType.Clear });
+  }, [dispatch, url]);
 
   return (
     <StateContext.Provider value={state}>
@@ -99,33 +48,38 @@ async function fetchAccountTokens(
   pubkey: PublicKey,
   url: string
 ) {
+  const key = pubkey.toBase58();
   dispatch({
-    type: "update",
+    type: ActionType.Update,
+    key,
     status: FetchStatus.Fetching,
-    pubkey,
     url,
   });
 
   let status;
-  let tokens;
+  let data;
   try {
     const { value } = await new Connection(
       url,
       "recent"
     ).getParsedTokenAccountsByOwner(pubkey, { programId: TOKEN_PROGRAM_ID });
-    tokens = value.map((accountInfo) => {
-      const parsedInfo = accountInfo.account.data.parsed.info;
-      const info = coerce(parsedInfo, TokenAccountInfo);
-      return { info, pubkey: accountInfo.pubkey };
-    });
+    data = {
+      tokens: value.map((accountInfo) => {
+        const parsedInfo = accountInfo.account.data.parsed.info;
+        const info = coerce(parsedInfo, TokenAccountInfo);
+        return { info, pubkey: accountInfo.pubkey };
+      }),
+    };
     status = FetchStatus.Fetched;
   } catch (error) {
     status = FetchStatus.FetchFailed;
   }
-  dispatch({ type: "update", url, status, tokens, pubkey });
+  dispatch({ type: ActionType.Update, url, status, data, key });
 }
 
-export function useAccountOwnedTokens(address: string) {
+export function useAccountOwnedTokens(
+  address: string
+): Cache.CacheEntry<AccountTokens> | undefined {
   const context = React.useContext(StateContext);
 
   if (!context) {
@@ -134,7 +88,7 @@ export function useAccountOwnedTokens(address: string) {
     );
   }
 
-  return context.map[address];
+  return context.entries[address];
 }
 
 export function useFetchAccountOwnedTokens() {

+ 108 - 0
explorer/src/providers/cache.tsx

@@ -0,0 +1,108 @@
+import React from "react";
+
+export enum FetchStatus {
+  Fetching,
+  FetchFailed,
+  Fetched,
+}
+
+export type CacheEntry<T> = {
+  status: FetchStatus;
+  data?: T;
+};
+
+export type State<T> = {
+  entries: {
+    [key: string]: CacheEntry<T>;
+  };
+  url: string;
+};
+
+export enum ActionType {
+  Update,
+  Clear,
+}
+
+export type Update<T> = {
+  type: ActionType.Update;
+  url: string;
+  key: string;
+  status: FetchStatus;
+  data?: T;
+};
+
+export type Clear = {
+  type: ActionType.Clear;
+  url: string;
+};
+
+export type Action<T> = Update<T> | Clear;
+export type Dispatch<T> = (action: Action<T>) => void;
+type Reducer<T, U> = (state: State<T>, action: Action<U>) => State<T>;
+type Reconciler<T, U> = (
+  entry: T | undefined,
+  update: U | undefined
+) => T | undefined;
+
+function defaultReconciler<T>(entry: T | undefined, update: T | undefined) {
+  if (entry) {
+    if (update) {
+      return {
+        ...entry,
+        ...update,
+      };
+    } else {
+      return entry;
+    }
+  } else {
+    return update;
+  }
+}
+
+function defaultReducer<T>(state: State<T>, action: Action<T>) {
+  return reducer(state, action, defaultReconciler);
+}
+
+export function useReducer<T>(url: string) {
+  return React.useReducer<Reducer<T, T>>(defaultReducer, { url, entries: {} });
+}
+
+export function useCustomReducer<T, U>(
+  url: string,
+  reconciler: Reconciler<T, U>
+) {
+  const customReducer = React.useMemo(() => {
+    return (state: State<T>, action: Action<U>) => {
+      return reducer(state, action, reconciler);
+    };
+  }, [reconciler]);
+  return React.useReducer<Reducer<T, U>>(customReducer, { url, entries: {} });
+}
+
+export function reducer<T, U>(
+  state: State<T>,
+  action: Action<U>,
+  reconciler: Reconciler<T, U>
+): State<T> {
+  if (action.type === ActionType.Clear) {
+    return { url: action.url, entries: {} };
+  } else if (action.url !== state.url) {
+    return state;
+  }
+
+  switch (action.type) {
+    case ActionType.Update: {
+      const key = action.key;
+      const entry = state.entries[key];
+      const entries = {
+        ...state.entries,
+        [key]: {
+          ...entry,
+          status: action.status,
+          data: reconciler(entry?.data, action.data),
+        },
+      };
+      return { ...state, entries };
+    }
+  }
+}

+ 27 - 76
explorer/src/providers/transactions/details.tsx

@@ -5,78 +5,16 @@ import {
   ParsedConfirmedTransaction,
 } from "@solana/web3.js";
 import { useCluster } from "../cluster";
-import { FetchStatus } from "./index";
 import { CACHED_DETAILS, isCached } from "./cached";
+import * as Cache from "providers/cache";
+import { ActionType, FetchStatus } from "providers/cache";
 
 export interface Details {
-  fetchStatus: FetchStatus;
-  transaction: ParsedConfirmedTransaction | null;
+  transaction?: ParsedConfirmedTransaction | null;
 }
 
-type State = {
-  entries: { [signature: string]: Details };
-  url: string;
-};
-
-export enum ActionType {
-  Update,
-  Clear,
-}
-
-interface Update {
-  type: ActionType.Update;
-  url: string;
-  signature: string;
-  fetchStatus: FetchStatus;
-  transaction: ParsedConfirmedTransaction | null;
-}
-
-interface Clear {
-  type: ActionType.Clear;
-  url: string;
-}
-
-type Action = Update | Clear;
-type Dispatch = (action: Action) => void;
-
-function reducer(state: State, action: Action): State {
-  if (action.type === ActionType.Clear) {
-    return { url: action.url, entries: {} };
-  } else if (action.url !== state.url) {
-    return state;
-  }
-
-  switch (action.type) {
-    case ActionType.Update: {
-      const signature = action.signature;
-      const details = state.entries[signature];
-      if (details) {
-        return {
-          ...state,
-          entries: {
-            ...state.entries,
-            [signature]: {
-              ...details,
-              fetchStatus: action.fetchStatus,
-              transaction: action.transaction,
-            },
-          },
-        };
-      } else {
-        return {
-          ...state,
-          entries: {
-            ...state.entries,
-            [signature]: {
-              fetchStatus: FetchStatus.Fetching,
-              transaction: null,
-            },
-          },
-        };
-      }
-    }
-  }
-}
+type State = Cache.State<Details>;
+type Dispatch = Cache.Dispatch<Details>;
 
 export const StateContext = React.createContext<State | undefined>(undefined);
 export const DispatchContext = React.createContext<Dispatch | undefined>(
@@ -86,11 +24,11 @@ export const DispatchContext = React.createContext<Dispatch | undefined>(
 type DetailsProviderProps = { children: React.ReactNode };
 export function DetailsProvider({ children }: DetailsProviderProps) {
   const { url } = useCluster();
-  const [state, dispatch] = React.useReducer(reducer, { url, entries: {} });
+  const [state, dispatch] = Cache.useReducer<Details>(url);
 
   React.useEffect(() => {
     dispatch({ type: ActionType.Clear, url });
-  }, [url]);
+  }, [dispatch, url]);
 
   return (
     <StateContext.Provider value={state}>
@@ -108,14 +46,13 @@ async function fetchDetails(
 ) {
   dispatch({
     type: ActionType.Update,
-    fetchStatus: FetchStatus.Fetching,
-    transaction: null,
-    signature,
+    status: FetchStatus.Fetching,
+    key: signature,
     url,
   });
 
   let fetchStatus;
-  let transaction = null;
+  let transaction;
   if (isCached(url, signature)) {
     transaction = CACHED_DETAILS[signature];
     fetchStatus = FetchStatus.Fetched;
@@ -132,9 +69,9 @@ async function fetchDetails(
   }
   dispatch({
     type: ActionType.Update,
-    fetchStatus,
-    signature,
-    transaction,
+    status: fetchStatus,
+    key: signature,
+    data: { transaction },
     url,
   });
 }
@@ -152,3 +89,17 @@ export function useFetchTransactionDetails() {
     url && fetchDetails(dispatch, signature, url);
   };
 }
+
+export function useTransactionDetails(
+  signature: TransactionSignature
+): Cache.CacheEntry<Details> | undefined {
+  const context = React.useContext(StateContext);
+
+  if (!context) {
+    throw new Error(
+      `useTransactionDetails must be used within a TransactionsProvider`
+    );
+  }
+
+  return context.entries[signature];
+}

+ 29 - 189
explorer/src/providers/transactions/index.tsx

@@ -2,27 +2,14 @@ import React from "react";
 import {
   TransactionSignature,
   Connection,
-  SystemProgram,
-  Account,
   SignatureResult,
-  PublicKey,
-  sendAndConfirmTransaction,
 } from "@solana/web3.js";
-import { useQuery } from "utils/url";
-import { useCluster, Cluster, ClusterStatus } from "../cluster";
-import {
-  DetailsProvider,
-  StateContext as DetailsStateContext,
-} from "./details";
-import base58 from "bs58";
-import { useFetchAccountInfo } from "../accounts";
+import { useCluster } from "../cluster";
+import { DetailsProvider } from "./details";
+import * as Cache from "providers/cache";
+import { ActionType, FetchStatus } from "providers/cache";
 import { CACHED_STATUSES, isCached } from "./cached";
-
-export enum FetchStatus {
-  Fetching,
-  FetchFailed,
-  Fetched,
-}
+export { useTransactionDetails } from "./details";
 
 export type Confirmations = number | "max";
 
@@ -36,125 +23,27 @@ export interface TransactionStatusInfo {
 }
 
 export interface TransactionStatus {
-  fetchStatus: FetchStatus;
   signature: TransactionSignature;
-  info?: TransactionStatusInfo;
-}
-
-type Transactions = { [signature: string]: TransactionStatus };
-interface State {
-  transactions: Transactions;
-  url: string;
-}
-
-export enum ActionType {
-  UpdateStatus,
-  FetchSignature,
-  Clear,
-}
-
-interface UpdateStatus {
-  type: ActionType.UpdateStatus;
-  url: string;
-  signature: TransactionSignature;
-  fetchStatus: FetchStatus;
-  info?: TransactionStatusInfo;
-}
-
-interface FetchSignature {
-  type: ActionType.FetchSignature;
-  url: string;
-  signature: TransactionSignature;
-}
-
-interface Clear {
-  type: ActionType.Clear;
-  url: string;
-}
-
-type Action = UpdateStatus | FetchSignature | Clear;
-type Dispatch = (action: Action) => void;
-
-function reducer(state: State, action: Action): State {
-  if (action.type === ActionType.Clear) {
-    return { url: action.url, transactions: {} };
-  } else if (action.url !== state.url) {
-    return state;
-  }
-
-  switch (action.type) {
-    case ActionType.FetchSignature: {
-      const signature = action.signature;
-      const transaction = state.transactions[signature];
-      if (transaction) {
-        const transactions = {
-          ...state.transactions,
-          [action.signature]: {
-            ...transaction,
-            fetchStatus: FetchStatus.Fetching,
-            info: undefined,
-          },
-        };
-        return { ...state, transactions };
-      } else {
-        const transactions = {
-          ...state.transactions,
-          [action.signature]: {
-            signature: action.signature,
-            fetchStatus: FetchStatus.Fetching,
-          },
-        };
-        return { ...state, transactions };
-      }
-    }
-
-    case ActionType.UpdateStatus: {
-      const transaction = state.transactions[action.signature];
-      if (transaction) {
-        const transactions = {
-          ...state.transactions,
-          [action.signature]: {
-            ...transaction,
-            fetchStatus: action.fetchStatus,
-            info: action.info,
-          },
-        };
-        return { ...state, transactions };
-      }
-      break;
-    }
-  }
-  return state;
+  info: TransactionStatusInfo | null;
 }
 
 export const TX_ALIASES = ["tx", "txn", "transaction"];
 
+type State = Cache.State<TransactionStatus>;
+type Dispatch = Cache.Dispatch<TransactionStatus>;
+
 const StateContext = React.createContext<State | undefined>(undefined);
 const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
 
 type TransactionsProviderProps = { children: React.ReactNode };
 export function TransactionsProvider({ children }: TransactionsProviderProps) {
-  const { cluster, status: clusterStatus, url } = useCluster();
-  const [state, dispatch] = React.useReducer(reducer, {
-    transactions: {},
-    url,
-  });
-
-  const fetchAccount = useFetchAccountInfo();
-  const query = useQuery();
-  const testFlag = query.get("test");
+  const { url } = useCluster();
+  const [state, dispatch] = Cache.useReducer<TransactionStatus>(url);
 
-  // Check transaction statuses whenever cluster updates
+  // Clear accounts cache whenever cluster is changed
   React.useEffect(() => {
-    if (clusterStatus === ClusterStatus.Connecting) {
-      dispatch({ type: ActionType.Clear, url });
-    }
-
-    // Create a test transaction
-    if (cluster === Cluster.Devnet && testFlag !== null) {
-      createTestTransaction(dispatch, fetchAccount, url, clusterStatus);
-    }
-  }, [testFlag, cluster, clusterStatus, url]); // eslint-disable-line react-hooks/exhaustive-deps
+    dispatch({ type: ActionType.Clear, url });
+  }, [dispatch, url]);
 
   return (
     <StateContext.Provider value={state}>
@@ -165,64 +54,23 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
   );
 }
 
-async function createTestTransaction(
-  dispatch: Dispatch,
-  fetchAccount: (pubkey: PublicKey) => void,
-  url: string,
-  clusterStatus: ClusterStatus
-) {
-  const testKey = process.env.REACT_APP_TEST_KEY;
-  let testAccount = new Account();
-  if (testKey) {
-    testAccount = new Account(base58.decode(testKey));
-  }
-
-  try {
-    const connection = new Connection(url, "recent");
-    const signature = await connection.requestAirdrop(
-      testAccount.publicKey,
-      100000
-    );
-    fetchTransactionStatus(dispatch, signature, url);
-    fetchAccount(testAccount.publicKey);
-  } catch (error) {
-    console.error("Failed to create test success transaction", error);
-  }
-
-  try {
-    const connection = new Connection(url, "recent");
-    const tx = SystemProgram.transfer({
-      fromPubkey: testAccount.publicKey,
-      toPubkey: testAccount.publicKey,
-      lamports: 1,
-    });
-    const signature = await sendAndConfirmTransaction(
-      connection,
-      tx,
-      [testAccount],
-      { confirmations: 1, skipPreflight: false }
-    );
-    fetchTransactionStatus(dispatch, signature, url);
-  } catch (error) {
-    console.error("Failed to create test failure transaction", error);
-  }
-}
-
 export async function fetchTransactionStatus(
   dispatch: Dispatch,
   signature: TransactionSignature,
   url: string
 ) {
   dispatch({
-    type: ActionType.FetchSignature,
-    signature,
+    type: ActionType.Update,
+    key: signature,
+    status: FetchStatus.Fetching,
     url,
   });
 
   let fetchStatus;
-  let info: TransactionStatusInfo | undefined;
+  let data;
   if (isCached(url, signature)) {
-    info = CACHED_STATUSES[signature];
+    const info = CACHED_STATUSES[signature];
+    data = { signature, info };
     fetchStatus = FetchStatus.Fetched;
   } else {
     try {
@@ -231,6 +79,7 @@ export async function fetchTransactionStatus(
         searchTransactionHistory: true,
       });
 
+      let info = null;
       if (value !== null) {
         let blockTime = null;
         try {
@@ -260,6 +109,7 @@ export async function fetchTransactionStatus(
           result: { err: value.err },
         };
       }
+      data = { signature, info };
       fetchStatus = FetchStatus.Fetched;
     } catch (error) {
       console.error("Failed to fetch transaction status", error);
@@ -268,10 +118,10 @@ export async function fetchTransactionStatus(
   }
 
   dispatch({
-    type: ActionType.UpdateStatus,
-    signature,
-    fetchStatus,
-    info,
+    type: ActionType.Update,
+    key: signature,
+    status: fetchStatus,
+    data,
     url,
   });
 }
@@ -286,7 +136,9 @@ export function useTransactions() {
   return context;
 }
 
-export function useTransactionStatus(signature: TransactionSignature) {
+export function useTransactionStatus(
+  signature: TransactionSignature
+): Cache.CacheEntry<TransactionStatus> | undefined {
   const context = React.useContext(StateContext);
 
   if (!context) {
@@ -295,18 +147,6 @@ export function useTransactionStatus(signature: TransactionSignature) {
     );
   }
 
-  return context.transactions[signature];
-}
-
-export function useTransactionDetails(signature: TransactionSignature) {
-  const context = React.useContext(DetailsStateContext);
-
-  if (!context) {
-    throw new Error(
-      `useTransactionDetails must be used within a TransactionsProvider`
-    );
-  }
-
   return context.entries[signature];
 }