diff --git a/src/__tests__/walletRefresh.test.ts b/src/__tests__/walletRefresh.test.ts new file mode 100644 index 00000000..c44363d9 --- /dev/null +++ b/src/__tests__/walletRefresh.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "@jest/globals"; +import { + createWalletRefreshCoordinator, + type RefreshContext, +} from "@/lib/wallet-refresh"; + +/** A task the test settles by hand, recording the context it was given. */ +function deferredTask() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const done = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const calls: RefreshContext[] = []; + const task = (context: RefreshContext) => { + calls.push(context); + return done; + }; + return { task, calls, resolve, reject }; +} + +/** Let queued microtasks (the deferred task start) run. */ +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe("walletRefreshCoordinator.showWallet", () => { + it("asks for a load on the first wallet and on every switch", () => { + const coordinator = createWalletRefreshCoordinator(); + expect(coordinator.showWallet("A")).toBe(true); + expect(coordinator.showWallet("B")).toBe(true); + expect(coordinator.showWallet("A")).toBe(true); + }); + + it("does not ask again when a control remounts on the same wallet", () => { + // The mobile menu's refresh item mounts on every open; each mount reports + // the wallet the app is already showing. + const coordinator = createWalletRefreshCoordinator(); + expect(coordinator.showWallet("A")).toBe(true); + expect(coordinator.showWallet("A")).toBe(false); + expect(coordinator.showWallet("A")).toBe(false); + }); +}); + +describe("walletRefreshCoordinator.run", () => { + it("joins a refresh already in flight for the same wallet", async () => { + const coordinator = createWalletRefreshCoordinator(); + coordinator.showWallet("A"); + const first = deferredTask(); + const second = deferredTask(); + + const p1 = coordinator.run("A", first.task); + const p2 = coordinator.run("A", second.task); + expect(p2).toBe(p1); + expect(coordinator.isRunning("A")).toBe(true); + + first.resolve(); + await p1; + expect(first.calls).toHaveLength(1); + expect(second.calls).toHaveLength(0); + expect(coordinator.isRunning("A")).toBe(false); + }); + + it("starts a new refresh once the previous one has finished", async () => { + const coordinator = createWalletRefreshCoordinator(); + coordinator.showWallet("A"); + const first = deferredTask(); + first.resolve(); + await coordinator.run("A", first.task); + + const second = deferredTask(); + const p = coordinator.run("A", second.task); + await flush(); + expect(second.calls).toHaveLength(1); + second.resolve(); + await p; + }); + + it("frees the slot when a refresh fails, and passes the failure on", async () => { + const coordinator = createWalletRefreshCoordinator(); + coordinator.showWallet("A"); + const failing = deferredTask(); + const p = coordinator.run("A", failing.task); + failing.reject(new Error("boom")); + await expect(p).rejects.toThrow("boom"); + expect(coordinator.isRunning("A")).toBe(false); + }); + + it("frees the slot when the task throws before returning a promise", async () => { + const coordinator = createWalletRefreshCoordinator(); + coordinator.showWallet("A"); + const p = coordinator.run("A", () => { + throw new Error("sync"); + }); + await expect(p).rejects.toThrow("sync"); + expect(coordinator.isRunning("A")).toBe(false); + }); +}); + +describe("staleness", () => { + it("marks a refresh stale when the user switches to another wallet", async () => { + const coordinator = createWalletRefreshCoordinator(); + coordinator.showWallet("A"); + const a = deferredTask(); + const p = coordinator.run("A", a.task); + await flush(); + const context = a.calls[0]!; + + expect(context.isStale()).toBe(false); + coordinator.showWallet("B"); + expect(context.isStale()).toBe(true); + // Not reported as running: a control mounting on B must not join it. + expect(coordinator.isRunning("A")).toBe(false); + + a.resolve(); + await p; + }); + + it("keeps a refresh stale after the user switches back", async () => { + // A -> B -> A while A's first refresh winds down: that run may already + // have skipped lookups, so it must not write. A fresh run loads A instead. + const coordinator = createWalletRefreshCoordinator(); + coordinator.showWallet("A"); + const first = deferredTask(); + const p1 = coordinator.run("A", first.task); + await flush(); + + coordinator.showWallet("B"); + coordinator.showWallet("A"); + expect(first.calls[0]!.isStale()).toBe(true); + + const second = deferredTask(); + const p2 = coordinator.run("A", second.task); + expect(p2).not.toBe(p1); + await flush(); + expect(second.calls).toHaveLength(1); + expect(second.calls[0]!.isStale()).toBe(false); + expect(coordinator.isRunning("A")).toBe(true); + + // The old run finishing must not free the new run's slot. + first.resolve(); + await p1; + expect(coordinator.isRunning("A")).toBe(true); + const third = deferredTask(); + expect(coordinator.run("A", third.task)).toBe(p2); + + second.resolve(); + await p2; + expect(coordinator.isRunning("A")).toBe(false); + }); + + it("treats a refresh of a wallet that is not shown as stale from the start", async () => { + const coordinator = createWalletRefreshCoordinator(); + coordinator.showWallet("A"); + const b = deferredTask(); + const p = coordinator.run("B", b.task); + await flush(); + expect(b.calls[0]!.isStale()).toBe(true); + b.resolve(); + await p; + }); + + it("does not hold a new wallet's refresh behind the old one's", async () => { + const coordinator = createWalletRefreshCoordinator(); + coordinator.showWallet("A"); + const a = deferredTask(); + const pa = coordinator.run("A", a.task); + + coordinator.showWallet("B"); + const b = deferredTask(); + const pb = coordinator.run("B", b.task); + await flush(); + expect(b.calls).toHaveLength(1); + expect(b.calls[0]!.isStale()).toBe(false); + + b.resolve(); + await pb; + a.resolve(); + await pa; + }); +}); diff --git a/src/components/common/overall-layout/mobile-wrappers/wallet-data-loader-wrapper.tsx b/src/components/common/overall-layout/mobile-wrappers/wallet-data-loader-wrapper.tsx index 8e6da89e..93e55e62 100644 --- a/src/components/common/overall-layout/mobile-wrappers/wallet-data-loader-wrapper.tsx +++ b/src/components/common/overall-layout/mobile-wrappers/wallet-data-loader-wrapper.tsx @@ -1,7 +1,7 @@ import { RefreshCw } from "lucide-react"; import useAppWallet from "@/hooks/useAppWallet"; import useMultisigWallet from "@/hooks/useMultisigWallet"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { getProvider } from "@/utils/get-provider"; import { useWalletsStore } from "@/lib/zustand/wallets"; import { api } from "@/utils/api"; @@ -16,6 +16,10 @@ import { useProxyActions } from "@/lib/zustand/proxy"; import { useUserStore } from "@/lib/zustand/user"; import { mapWithConcurrency } from "@/utils/concurrency"; import { createBlockfrostThrottle } from "@/utils/blockfrost-throttle"; +import { + type RefreshContext, + walletRefreshCoordinator, +} from "@/lib/wallet-refresh"; type BlockfrostThrottle = ReturnType; @@ -42,7 +46,6 @@ export default function WalletDataLoaderWrapper({ const { multisigWallet } = useMultisigWallet(); const [loading, setLoading] = useState(false); - const prevWalletIdRef = useRef(null); const ctx = api.useUtils(); const network = useSiteStore((state) => state.network); const setRandomState = useSiteStore((state) => state.setRandomState); @@ -53,7 +56,6 @@ export default function WalletDataLoaderWrapper({ const setWalletTransactions = useWalletsStore( (state) => state.setWalletTransactions, ); - const fetchingTransactions = useRef(false); const walletLastUpdated = useWalletsStore((state) => state.walletLastUpdated); const setWalletLastUpdated = useWalletsStore( @@ -84,7 +86,10 @@ export default function WalletDataLoaderWrapper({ } } - async function getTransactionsOnChain(throttle: BlockfrostThrottle) { + async function getTransactionsOnChain( + throttle: BlockfrostThrottle, + refresh: RefreshContext, + ) { try { if (appWallet) { const maxPage = 4; @@ -94,6 +99,7 @@ export default function WalletDataLoaderWrapper({ // List pages first (newest first); stop at the first short page. const listed: TxInfo[] = []; for (let i = 1; i <= maxPage; i++) { + if (refresh.isStale()) return; const transactions: TxInfo[] = await blockchainProvider.get( `/addresses/${appWallet.address}/transactions?page=${i}&order=desc`, ); @@ -111,10 +117,18 @@ export default function WalletDataLoaderWrapper({ listed, BLOCKFROST_CONCURRENCY, (tx) => - throttle.get(blockchainProvider, `/txs/${tx.tx_hash}/utxos`).catch( - () => null, - ), + // The user moved to another wallet: stop spending rate budget on + // this one. What was fetched is discarded below. + refresh.isStale() + ? Promise.resolve(null) + : throttle + .get(blockchainProvider, `/txs/${tx.tx_hash}/utxos`) + .catch(() => null), ); + // A history cut short by a wallet switch would replace the cached one + // with transactions missing their inputs and outputs. Keep the cache; + // switching back to this wallet loads it again. + if (refresh.isStale()) return; listed.forEach((tx, index) => { const txData = details[index]; _transactions.push({ @@ -133,14 +147,17 @@ export default function WalletDataLoaderWrapper({ setWalletLastUpdated(appWallet?.id, Date.now()); } } catch (error) { - if (appWallet) { + if (appWallet && !refresh.isStale()) { setWalletTransactions(appWallet?.id, []); setWalletLastUpdated(appWallet?.id, Date.now()); } } } - async function getWalletAssets(throttle: BlockfrostThrottle) { + async function getWalletAssets( + throttle: BlockfrostThrottle, + refresh: RefreshContext, + ) { try { const blockchainProvider = getProvider(network); const assets = await blockchainProvider.get( @@ -164,10 +181,17 @@ export default function WalletDataLoaderWrapper({ tokens, BLOCKFROST_CONCURRENCY, (asset) => - throttle.get(blockchainProvider, `/assets/${asset.unit}`).catch( - () => null, - ), + refresh.isStale() + ? Promise.resolve(null) + : throttle + .get(blockchainProvider, `/assets/${asset.unit}`) + .catch(() => null), ); + // `walletAssets` is not keyed by wallet: written now, it would show + // the previous wallet's assets on the one the user switched to. The + // metadata is keyed by unit, but lookups skipped above would replace + // known names with the bare unit. + if (refresh.isStale()) return; for (const [index, asset] of tokens.entries()) { const assetInfo = infos[index]; setWalletAssetMetadata( @@ -193,6 +217,7 @@ export default function WalletDataLoaderWrapper({ setWalletAssets([]); } } catch (error) { + if (refresh.isStale()) return; setWalletAssets([]); setWalletAssetMetadata("", "", 0, "", "", ""); } @@ -206,11 +231,11 @@ export default function WalletDataLoaderWrapper({ return getDRepIds(dRepId); } - async function getDRepInfo() { + async function getDRepInfo(refresh: RefreshContext) { try { const drepids = dRepIds(); if (!drepids) { - setDrepInfo(undefined); + if (!refresh.isStale()) setDrepInfo(undefined); return; } @@ -219,6 +244,8 @@ export default function WalletDataLoaderWrapper({ `/governance/dreps/${drepids.cip105}`, ); if (!drepInfo) throw new Error(`No dRep for ID ${drepids.cip105} found.`); + // `drepInfo` is not keyed by wallet either; see getWalletAssets. + if (refresh.isStale()) return; setDrepInfo(drepInfo); } catch (err: any) { // DRep not found (404) is expected if DRep hasn't been registered yet @@ -227,6 +254,7 @@ export default function WalletDataLoaderWrapper({ if (!is404) { console.error(`Error fetching DRep info:`, err); } + if (refresh.isStale()) return; setDrepInfo(undefined); } } @@ -258,11 +286,8 @@ export default function WalletDataLoaderWrapper({ } } - async function refreshWallet() { - if (fetchingTransactions.current) return; - - fetchingTransactions.current = true; - setLoading(true); + /** Load one wallet's chain data into the store. */ + async function loadWallet(refresh: RefreshContext) { // Independent sources — run them together. Each catches its own errors // (fetchUtxos aside, which allSettled absorbs), so one slow or failing // source no longer holds the others back. @@ -272,31 +297,53 @@ export default function WalletDataLoaderWrapper({ const throttle = createBlockfrostThrottle({ minIntervalMs: 200 }); await Promise.allSettled([ fetchUtxos(), - getTransactionsOnChain(throttle), - getWalletAssets(throttle), - getDRepInfo(), + getTransactionsOnChain(throttle, refresh), + getWalletAssets(throttle, refresh), + getDRepInfo(refresh), fetchProxyData(), ]); - void ctx.transaction.getPendingTransactions.invalidate(); - void ctx.transaction.getAllTransactions.invalidate(); - // Also refresh proxy data - void ctx.proxy.getProxiesByUserOrWallet.invalidate(); - setRandomState(); - setLoading(false); - fetchingTransactions.current = false; - + } + + async function refreshWallet() { + setLoading(true); + try { + // Shared across both mounted controls: a refresh already running for + // this wallet is joined, not repeated (see src/lib/wallet-refresh.ts). + // With no wallet open there is no chain data to load, only queries. + if (appWallet) { + await walletRefreshCoordinator.run(appWallet.id, loadWallet); + } + void ctx.transaction.getPendingTransactions.invalidate(); + void ctx.transaction.getAllTransactions.invalidate(); + // Also refresh proxy data + void ctx.proxy.getProxiesByUserOrWallet.invalidate(); + setRandomState(); + } finally { + setLoading(false); + } + // Call the optional callback after action completes if (onAction) { onAction(); } } + const appWalletId = appWallet?.id; useEffect(() => { - if (appWallet && prevWalletIdRef.current !== appWallet.id) { - refreshWallet(); - prevWalletIdRef.current = appWallet.id; + if (!appWalletId) return; + // Load on a wallet switch, not on every mount: the mobile menu's copy of + // this control mounts each time the menu opens. If a refresh is already + // running, join it so this copy's spinner reflects it. + if ( + walletRefreshCoordinator.showWallet(appWalletId) || + walletRefreshCoordinator.isRunning(appWalletId) + ) { + void refreshWallet(); } - }, [appWallet]); + // Keyed on the id: appWallet is rebuilt whenever its UTxOs change, which + // the refresh itself does. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appWalletId]); // Session check and authorization modal are now handled in layout.tsx // This prevents duplicate authorization prompts diff --git a/src/lib/wallet-refresh.ts b/src/lib/wallet-refresh.ts new file mode 100644 index 00000000..e14d65fb --- /dev/null +++ b/src/lib/wallet-refresh.ts @@ -0,0 +1,101 @@ +/** + * One wallet refresh at a time, shared by every mounted refresh control. + * + * The layout mounts the refresh control twice: a desktop button that is only + * hidden with CSS on phones, and an item inside the mobile "More actions" + * menu. Radix unmounts a closed menu's content, so the mobile item mounted + * fresh on every open. Each instance kept its own "last wallet" ref and + * in-flight flag, so opening the menu started a second full refresh of the + * wallet the desktop instance had just loaded: up to four pages of + * transactions plus one Blockfrost lookup per transaction and per asset, + * each instance on its own throttle, at twice the rate the throttle exists to + * hold. + * + * The state lives here instead, at module level, so it survives remounts and + * is shared by every instance: + * + * - `showWallet` records which wallet the app is showing and reports whether + * that is a switch. Only a switch (or the first wallet) triggers an + * automatic refresh; a remount of the control does not. + * - `run` joins a refresh already in flight for the same wallet instead of + * starting another, so a second control shows the same spinner. + * - A refresh for a wallet the user has since left is "stale", and stays + * stale even if they come back (a fresh run starts instead). It stops + * issuing lookups and must not write the store's wallet-agnostic fields + * (`walletAssets`, `drepInfo`), which would otherwise show the previous + * wallet's assets and DRep on the new one. + */ + +export interface RefreshContext { + /** True once the app shows a different wallet than the one being refreshed. */ + isStale: () => boolean; +} + +export function createWalletRefreshCoordinator() { + let shownWalletId: string | null = null; + // Bumped on every switch. A run is stale once the generation it started in + // has passed, even if the user has since switched back: by then it may + // have skipped lookups, and its results are incomplete. + let generation = 0; + const inFlight = new Map< + string, + { promise: Promise; context: RefreshContext } + >(); + + return { + /** + * Record the wallet the app now shows. Returns true when it differs from + * the one shown before, i.e. the caller should load it. + */ + showWallet(walletId: string): boolean { + if (shownWalletId === walletId) return false; + shownWalletId = walletId; + generation++; + return true; + }, + + isRunning(walletId: string): boolean { + const run = inFlight.get(walletId); + return run !== undefined && !run.context.isStale(); + }, + + /** + * Run `task` for `walletId`, or join the run already in flight for it. + * Refreshes of different wallets do not wait on each other: the one the + * user left goes stale and winds down on its own. A stale run is never + * joined; a fresh one starts beside it. + */ + run( + walletId: string, + task: (context: RefreshContext) => Promise, + ): Promise { + const existing = inFlight.get(walletId); + if (existing && !existing.context.isStale()) return existing.promise; + + const startedIn = generation; + const context: RefreshContext = { + isStale: () => generation !== startedIn || shownWalletId !== walletId, + }; + // Started on a microtask so the entry is registered before the task can + // settle, even if it throws synchronously. + const entry: { promise: Promise; context: RefreshContext } = { + context, + promise: Promise.resolve() + .then(() => task(context)) + .finally(() => { + // A newer run for the same wallet may have taken the slot. + if (inFlight.get(walletId) === entry) inFlight.delete(walletId); + }), + }; + inFlight.set(walletId, entry); + return entry.promise; + }, + }; +} + +export type WalletRefreshCoordinator = ReturnType< + typeof createWalletRefreshCoordinator +>; + +/** The app-wide instance the refresh controls share. */ +export const walletRefreshCoordinator = createWalletRefreshCoordinator();