diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx index 457d561..43f00cb 100644 --- a/src/components/ErrorBoundary.test.tsx +++ b/src/components/ErrorBoundary.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -444,4 +444,74 @@ describe("ErrorBoundary", () => { expect(screen.queryByText("Show component stack")).not.toBeInTheDocument(); }); }); + + describe("copy error details (#953)", () => { + it("renders a 'Copy error details' button in the fallback UI", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + + + + ); + + expect( + screen.getByRole("button", { name: /copy error details/i }) + ).toBeInTheDocument(); + }); + + it("copies a diagnostics report and confirms with 'Copied!'", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const writeText = vi.fn().mockResolvedValue(undefined); + const originalClipboard = navigator.clipboard; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + render( + + + + ); + + fireEvent.click( + screen.getByRole("button", { name: /copy error details/i }) + ); + + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + const report = writeText.mock.calls[0][0] as string; + expect(report).toContain("sorokit-ui error report"); + expect(report).toContain("Diagnostics boom"); + + await waitFor(() => + expect( + screen.getByRole("button", { name: /copied!/i }) + ).toBeInTheDocument() + ); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + }); + + it("keeps the details copyable even when production hides the message", () => { + vi.stubEnv("DEV", false); + vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + + + + ); + + expect( + screen.getByRole("button", { name: /copy error details/i }) + ).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 7cb4e0b..23fb0f0 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,8 +1,14 @@ -import { AlertCircleIcon, Refresh01Icon } from "@hugeicons/core-free-icons"; +import { + AlertCircleIcon, + Copy01Icon, + Refresh01Icon, + Tick01Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Component, type ErrorInfo, type ReactNode } from "react"; import { cn } from "../lib/utils"; +import { copyToClipboard } from "./AccountCard"; interface Props { children: ReactNode; @@ -27,15 +33,27 @@ interface State { error: Error | null; resetKey: number; componentStack: string | null; + copied: boolean; } export class ErrorBoundary extends Component { - state: State = { error: null, resetKey: 0, componentStack: null }; + state: State = { + error: null, + resetKey: 0, + componentStack: null, + copied: false, + }; + + private copyResetTimer: ReturnType | null = null; static getDerivedStateFromError(error: Error): Partial { return { error }; } + componentWillUnmount() { + if (this.copyResetTimer) clearTimeout(this.copyResetTimer); + } + componentDidCatch(error: Error, info: ErrorInfo) { this.setState({ error, componentStack: info.componentStack ?? null }); @@ -53,15 +71,48 @@ export class ErrorBoundary extends Component { // Runs before the state clears so a re-initialisation attempt is already // done by the time children re-mount under the new resetKey. this.props.onRetry?.(); + if (this.copyResetTimer) clearTimeout(this.copyResetTimer); this.setState((state) => ({ error: null, resetKey: state.resetKey + 1, componentStack: null, + copied: false, })); }; + private buildDiagnostics() { + const { error, componentStack } = this.state; + const lines = [ + "sorokit-ui error report", + `Time: ${new Date().toISOString()}`, + `URL: ${typeof window !== "undefined" ? window.location.href : "unknown"}`, + `User agent: ${ + typeof navigator !== "undefined" ? navigator.userAgent : "unknown" + }`, + "", + `Message: ${error?.message ?? "Unknown error"}`, + ]; + + if (error?.stack) lines.push("", "Stack:", error.stack); + if (componentStack) lines.push("", "Component stack:", componentStack); + + return lines.join("\n"); + } + + handleCopy = async () => { + const ok = await copyToClipboard(this.buildDiagnostics()); + if (!ok) return; + + if (this.copyResetTimer) clearTimeout(this.copyResetTimer); + this.setState({ copied: true }); + this.copyResetTimer = setTimeout(() => { + this.copyResetTimer = null; + this.setState({ copied: false }); + }, 2000); + }; + render() { - const { error, resetKey, componentStack } = this.state; + const { error, resetKey, componentStack, copied } = this.state; const { children, fallback, isolate, supportUrl } = this.props; if (!error) return
{children}
; @@ -138,7 +189,20 @@ export class ErrorBoundary extends Component { )} -
+
+ + )} ); } diff --git a/src/components/ui/PieChart.test.tsx b/src/components/ui/PieChart.test.tsx index bb7eafe..319d1b3 100644 --- a/src/components/ui/PieChart.test.tsx +++ b/src/components/ui/PieChart.test.tsx @@ -170,6 +170,53 @@ describe("PieChart", () => { }); }); + describe("keyboard accessibility of slices (#953)", () => { + it("makes every slice focusable and exposes it as a graphics-symbol", () => { + const { container } = render(); + const paths = container.querySelectorAll("path"); + + expect(paths).toHaveLength(3); + paths.forEach((path) => { + expect(path).toHaveAttribute("tabindex", "0"); + expect(path).toHaveAttribute("role", "graphics-symbol"); + }); + }); + + it("labels each slice with its name and percentage for screen readers", () => { + const { container } = render(); + const paths = container.querySelectorAll("path"); + + expect(paths[0]).toHaveAttribute( + "aria-label", + "Stellar Lumens: 60.0%", + ); + expect(paths[1]).toHaveAttribute("aria-label", "USD Coin: 30.0%"); + expect(paths[2]).toHaveAttribute("aria-label", "Aqua: 10.0%"); + }); + + it("applies a visible keyboard focus ring to each slice", () => { + const { container } = render(); + const path = container.querySelector("path")!; + const className = path.getAttribute("class") ?? ""; + + expect(className).toContain("focus-visible:outline-none"); + expect(className).toContain("focus-visible:ring-2"); + expect(className).toContain("focus-visible:ring-brand"); + }); + + it("blurs the tooltip when a focused slice loses focus", () => { + const { container } = render(); + const paths = container.querySelectorAll("path"); + + fireEvent.focus(paths[0]); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Stellar Lumens: 60.0%", + ); + fireEvent.blur(paths[0]); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + describe("slice tooltips and dynamic repositioning", () => { it("renders title tag inside each path for native fallback", () => { const { container } = render(); diff --git a/src/components/ui/PieChart.tsx b/src/components/ui/PieChart.tsx index 9e57d74..7d0ea98 100644 --- a/src/components/ui/PieChart.tsx +++ b/src/components/ui/PieChart.tsx @@ -341,7 +341,10 @@ export function PieChart({ tabIndex={0} role="graphics-symbol" aria-label={`${slice.label}: ${pct.toFixed(1)}%`} - className="transition-opacity duration-150 hover:opacity-80 focus:opacity-80 focus:outline-none cursor-pointer" + // A keyboard user tabs onto each slice; the focus ring (plus a + // brand-coloured stroke, since box-shadow rings don't render + // reliably on SVG) makes the focused slice obvious. + className="transition-opacity duration-150 hover:opacity-80 focus:opacity-80 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-base focus-visible:stroke-brand focus-visible:stroke-2" onMouseEnter={(e) => handlePointerMove(e, slice, pct)} onMouseMove={(e) => handlePointerMove(e, slice, pct)} onFocus={() => handleFocus(slice, pct, midAngle)} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index 470bb01..0c2b510 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -1,7 +1,10 @@ import * as ToastPrimitive from "@radix-ui/react-toast"; import { useEffect, useRef } from "react"; -import type { Toast as ToastType } from "@/context/ToastContext"; +import { + type Toast as ToastType, + useOptionalToast, +} from "@/context/ToastContext"; import { cn } from "@/lib/utils"; function CheckIcon({ className }: { className?: string }) { @@ -47,6 +50,10 @@ const iconMap = { interface ToastItemProps { toast: ToastType; onDismiss: (id: string) => void; + /** Pause the countdown while the pointer hovers the toast. */ + onPause?: (id: string) => void; + /** Resume the countdown once the pointer leaves the toast. */ + onResume?: (id: string) => void; } /** @@ -61,16 +68,31 @@ function focusAdjacentToast(current: HTMLElement) { if (next?.hasAttribute("data-toast-id")) next.focus(); } -function ToastItem({ toast, onDismiss }: ToastItemProps) { +function ToastItem({ toast, onDismiss, onPause, onResume }: ToastItemProps) { const { icon: Icon, bg, fg } = iconMap[toast.type]; return ( { if (!open) onDismiss(toast.id); }} + // Hovering or focusing a toast freezes its countdown so the user has + // time to read it or interact with its action button. + onMouseEnter={() => onPause?.(toast.id)} + onMouseLeave={() => onResume?.(toast.id)} + onFocus={() => onPause?.(toast.id)} + onBlur={(e) => { + // React bubbles focus/blur, so moving focus between the toast and one + // of its own buttons fires blur here too. Only resume once focus has + // actually left the toast subtree. + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + onResume?.(toast.id); + } + }} onKeyDown={(e) => { if (e.key !== "Escape") return; // Escape from anywhere inside this toast (the toast itself, its @@ -137,6 +159,10 @@ interface ToastContainerProps { export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) { const viewportRef = useRef(null); + // The timer lives in the provider, so read the pause/resume controls from + // context rather than threading them through every call site. Optional so + // the container can still be rendered standalone (outside a provider). + const toast = useOptionalToast(); // When the stack overflows, keep the newest toast (last, nearest the // corner) in view rather than leaving it scrolled off the bottom. @@ -149,8 +175,14 @@ export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) { return ( - {toasts.map((toast) => ( - + {toasts.map((item) => ( + ))} {/* Toasts are laid out in normal flow in a single column with a fixed gap, diff --git a/src/context/SorokitContext.ts b/src/context/SorokitContext.ts index 19f9b89..dcf38d7 100644 --- a/src/context/SorokitContext.ts +++ b/src/context/SorokitContext.ts @@ -13,6 +13,13 @@ export interface SorokitState { address: string | null; walletName: string | null; /** + * Ids of supported wallet extensions currently detected on `window`. Seeded + * on mount and refreshed when an extension injects late (`stellar#initialized` + * plus a short poll), so the connect UI can pick up a wallet that wasn't + * present when the page first rendered. + */ + detectedWallets?: string[]; + isHardware?: boolean; * Whether the connected wallet is a hardware wallet. * * Owned by the provider so detection lives in exactly one place (#845). It was diff --git a/src/context/SorokitProvider.test.tsx b/src/context/SorokitProvider.test.tsx index 291236b..9c723f2 100644 --- a/src/context/SorokitProvider.test.tsx +++ b/src/context/SorokitProvider.test.tsx @@ -4,10 +4,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProvider } from "@/__tests__/utils"; import { getClient } from "@/lib/client"; +import { createMockClient, MOCK_ADDRESS } from "@/lib/mock-client"; import { SorokitProvider } from "./SorokitProvider"; import { useSorokit } from "./useSorokit"; +const WALLET_GLOBALS = [ + "freighter", + "isFreighter", + "xbull", + "xBull", + "albedo", + "lobstr", +] as const; + +const DetectedWalletsProbe = () => { + const { detectedWallets } = useSorokit(); + return ( +
{(detectedWallets ?? []).join(",")}
+ ); +}; + const TestComponent = () => { const { address, account, balances, connectWallet, disconnectWallet, switchNetwork, refreshAccount, clearCustomNetworks, customNetworks, isLoadingAccount, isDisconnecting, isHardware, error, walletError, errorHistory } = useSorokit(); @@ -88,6 +105,12 @@ describe("SorokitProvider", () => { } as unknown as ReturnType; }); + afterEach(() => { + // Wallet globals are set directly on `window`, so a test that injects one + // would otherwise leak into the next test's detection snapshot. + for (const key of WALLET_GLOBALS) { + delete (window as unknown as Record)[key]; + } // #845 — the provider is the single source of truth for hardware state. These // cover the contract the button now relies on: isHardware is exposed, it is // set during connectWallet, and it does not outlive the session on disconnect. @@ -1409,4 +1432,171 @@ describe("SorokitProvider", () => { ); }); }); + + describe("delayed wallet-extension detection (#954)", () => { + it("detects an extension already injected before mount", () => { + (window as unknown as Record).lobstr = {}; + + renderWithProvider(, { client: mockClient }); + + expect(screen.getByTestId("detected-wallets")).toHaveTextContent("lobstr"); + }); + + it("detects an extension injected after mount when it fires stellar#initialized", async () => { + renderWithProvider(, { client: mockClient }); + expect(screen.getByTestId("detected-wallets")).toHaveTextContent(""); + + await act(async () => { + (window as unknown as Record).freighter = {}; + window.dispatchEvent(new Event("stellar#initialized")); + }); + + await waitFor(() => + expect(screen.getByTestId("detected-wallets")).toHaveTextContent( + "freighter", + ), + ); + }); + + it("polls briefly for an extension that injects without firing an event", async () => { + vi.useFakeTimers(); + renderWithProvider(, { client: mockClient }); + expect(screen.getByTestId("detected-wallets")).toHaveTextContent(""); + + await act(async () => { + (window as unknown as Record).albedo = {}; + vi.advanceTimersByTime(200); + }); + + expect(screen.getByTestId("detected-wallets")).toHaveTextContent("albedo"); + }); + + it("stops polling once the detection window elapses", async () => { + vi.useFakeTimers(); + const setSpy = vi.spyOn(window, "setInterval"); + renderWithProvider(, { client: mockClient }); + + expect(setSpy).toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(200 * 12); + }); + + // Injecting after the poll window is up is not detected without an event. + await act(async () => { + (window as unknown as Record).xbull = {}; + vi.advanceTimersByTime(200 * 12); + }); + + expect(screen.getByTestId("detected-wallets")).toHaveTextContent(""); + }); + }); + + describe("network switch cancellation (#954)", () => { + it("cancels in-flight account requests on a switch without surfacing an error", async () => { + let rejectBalances: ((reason?: unknown) => void) | undefined; + mockClient.account.getAccount = vi.fn(() => new Promise(() => {})); + mockClient.account.getBalances = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectBalances = reject; + }), + ); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + // Make sure the account query actually started before the switch lands. + await waitFor(() => + expect(mockClient.account.getBalances).toHaveBeenCalled(), + ); + + // A switch lands while the account query is still in flight. + await act(async () => { + fireEvent.click(screen.getByText("Switch")); + }); + expect(mockClient.network.switchNetwork).toHaveBeenCalled(); + + // Reject the superseded requests; the provider must swallow these rather + // than let them become unhandled promise rejections. + await act(async () => { + rejectBalances?.(new Error("aborted")); + }); + + expect(screen.getByTestId("error")).toHaveTextContent("none"); + expect(screen.getByTestId("address")).toHaveTextContent("none"); + }); + + it("surfaces a genuine rejection for the current query", async () => { + mockClient.account.getAccount = vi + .fn() + .mockRejectedValue(new Error("rpc unavailable")); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => + expect(screen.getByTestId("error")).toHaveTextContent("rpc unavailable"), + ); + }); + }); + + describe("headless mock client injection (#954)", () => { + it("uses an injected mock client when no wallet extension is present", async () => { + const client = createMockClient(); + + await act(async () => { + renderWithProvider(, { client }); + }); + + expect(getClient()).toBe(client); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => + expect(screen.getByTestId("address")).toHaveTextContent(MOCK_ADDRESS), + ); + await waitFor(() => + expect(screen.getByTestId("account")).toHaveTextContent("123456789"), + ); + expect(screen.getByTestId("balances")).toHaveTextContent("1"); + expect(screen.getByTestId("error")).toHaveTextContent("none"); + }); + + it("re-points an injected mock client factory at the new network on switch", async () => { + const headlessClient = createMockClient(); + const switchedClient = createMockClient(); + const createClientForNetwork = vi.fn().mockReturnValue(switchedClient); + + await act(async () => { + render( + + + , + ); + }); + + expect(getClient()).toBe(headlessClient); + + await act(async () => { + fireEvent.click(screen.getByText("Switch")); + }); + + expect(createClientForNetwork).toHaveBeenCalledWith( + expect.objectContaining({ name: "testnet" }), + ); + expect(getClient()).toBe(switchedClient); + }); + }); }); diff --git a/src/context/SorokitProvider.tsx b/src/context/SorokitProvider.tsx index e64982b..995af35 100644 --- a/src/context/SorokitProvider.tsx +++ b/src/context/SorokitProvider.tsx @@ -22,6 +22,32 @@ export const BUILTIN_NETWORKS: NetworkName[] = [ "custom", ]; +/** + * Wallet-extension globals, keyed by the id we report to consumers. Outer + * arrays carry aliases because extensions don't agree on casing (xBull vs + * xbull, freighter vs isFreighter). + */ +const WALLET_GLOBAL_KEYS: ReadonlyArray = [ + ["freighter", ["freighter", "isFreighter"]], + ["xbull", ["xbull", "xBull"]], + ["albedo", ["albedo"]], + ["lobstr", ["lobstr"]], +]; + +/** + * #954 — snapshot which supported wallet extensions have injected their + * globals. Extensions commonly inject a few milliseconds *after* DOM load, + * so this is called repeatedly (on mount, on the `stellar#initialized` + * event, and on a short poll) rather than once. + */ +function detectInjectedWallets(): string[] { + if (typeof window === "undefined") return []; + const win = window as unknown as Record; + return WALLET_GLOBAL_KEYS.filter(([, keys]) => + keys.some((key) => Boolean(win[key])), + ).map(([id]) => id); +} + export function SorokitProvider({ client, onError, @@ -60,6 +86,12 @@ export function SorokitProvider({ const [isDisconnecting, setIsDisconnecting] = useState(false); const [errorSeverity, setErrorSeverity] = useState<"info" | "error">("error"); const [errorHistory, setErrorHistory] = useState([]); + // #954 — ids of wallet extensions currently detected on `window`. Seeded + // synchronously so an extension injected before mount is visible on the + // first render, then updated as late injections land. + const [detectedWallets, setDetectedWallets] = useState(() => + detectInjectedWallets(), + ); const error = accountError ?? networkError ?? walletError ?? null; @@ -68,6 +100,43 @@ export function SorokitProvider({ onErrorRef.current = onError; }, [onError]); + // #954 — detect wallet extensions that inject after mount. Some announce + // readiness with `stellar#initialized`; others just assign their global a + // few frames later, so also poll briefly before giving up. + useEffect(() => { + if (typeof window === "undefined") return; + let cancelled = false; + + const refresh = () => { + if (cancelled) return; + const next = detectInjectedWallets(); + setDetectedWallets((prev) => + prev.length === next.length && prev.every((w, i) => w === next[i]) + ? prev + : next, + ); + }; + + refresh(); + window.addEventListener("stellar#initialized", refresh); + window.addEventListener("stellar:initialized", refresh); + + let attempts = 0; + const maxAttempts = 10; + const intervalId = window.setInterval(() => { + attempts += 1; + refresh(); + if (attempts >= maxAttempts) window.clearInterval(intervalId); + }, 200); + + return () => { + cancelled = true; + window.removeEventListener("stellar#initialized", refresh); + window.removeEventListener("stellar:initialized", refresh); + window.clearInterval(intervalId); + }; + }, []); + // #353 — connectWallet/disconnectWallet/switchNetwork/refreshAccount read // the client through this ref instead of closing over the `client` prop // directly, so their identities stay stable even when a parent passes a @@ -95,6 +164,20 @@ export function SorokitProvider({ // slower earlier response can't land after a newer one and show stale data. const isRefreshingRef = useRef(false); + // #954 — cancels the in-flight account/balance query. The client API has no + // AbortSignal parameter, so the controller is used as the provider's own + // cancellation flag; what matters is that a superseded query (a rapid network + // switch, a disconnect, a new address) has its rejection checked and + // swallowed instead of surfacing as an unhandled promise rejection. + const accountRequestRef = useRef(null); + + useEffect(() => { + return () => { + accountRequestRef.current?.abort(); + accountRequestRef.current = null; + }; + }, []); + // #537 — a single source of truth for "a network switch is in flight". // `NetworkScreen` and `NetworkSwitcher` both read this through the context, // so controls on one surface disable while the other surface started a @@ -185,14 +268,21 @@ export function SorokitProvider({ setAccountError(null); let active = true; + // Supersede any query already in flight (address change, client change, + // rapid network switching) before starting a new one. + accountRequestRef.current?.abort(); + const controller = new AbortController(); + accountRequestRef.current = controller; + const timerId = window.setTimeout(() => { + if (!active || controller.signal.aborted) return; setIsLoadingAccount(true); Promise.all([ client.account.getAccount(address), client.account.getBalances(address), ]) .then(([accountRes, balancesRes]) => { - if (!active) return; + if (!active || controller.signal.aborted) return; if (accountRes.data) setAccount(accountRes.data); if (balancesRes.data) setBalances(balancesRes.data); const combined = [accountRes.error, balancesRes.error] @@ -211,13 +301,27 @@ export function SorokitProvider({ reportError(balancesRes.error, "account", "error"); } }) + .catch((e) => { + // A rejected request from a query that has since been superseded is + // expected (e.g. a network switch aborted it); only a rejection for + // the *current* query is a real error worth surfacing. + if (!active || controller.signal.aborted) return; + const message = + e instanceof Error ? e.message : "Failed to load account data"; + setAccountError(message); + reportError(message, "account", "error"); + }) .finally(() => { - if (active) setIsLoadingAccount(false); + if (active && !controller.signal.aborted) setIsLoadingAccount(false); }); }, 0); return () => { active = false; + controller.abort(); + if (accountRequestRef.current === controller) { + accountRequestRef.current = null; + } window.clearTimeout(timerId); }; }, [address, client, reportError]); @@ -363,6 +467,11 @@ export function SorokitProvider({ setIsSwitchingNetwork(true); try { + // #954 — cancel any in-flight account/balance query before the switch + // builds a new client, so the old network's responses can't land on the + // new one (and their rejections don't surface as unhandled). + accountRequestRef.current?.abort(); + accountRequestRef.current = null; resetTransactionWatchers(); const { data, error } = await clientRef.current.network.switchNetwork(param); if (error) { @@ -450,11 +559,19 @@ export function SorokitProvider({ if (!address || isRefreshingRef.current) return; isRefreshingRef.current = true; setIsLoadingAccount(true); + + // Supersede the previous query and register this one so a network switch + // (which aborts `accountRequestRef`) cancels it cleanly. + accountRequestRef.current?.abort(); + const controller = new AbortController(); + accountRequestRef.current = controller; + try { const [accountRes, balancesRes] = await Promise.all([ clientRef.current.account.getAccount(address), clientRef.current.account.getBalances(address), ]); + if (controller.signal.aborted) return; if (accountRes.data) setAccount(accountRes.data); if (balancesRes.data) setBalances(balancesRes.data); if (accountRes.error && balancesRes.error) { @@ -468,7 +585,19 @@ export function SorokitProvider({ } else if (balancesRes.error) { reportError(balancesRes.error, "account", "error"); } + } catch (e) { + // Swallow a rejection for a query that has been superseded (e.g. the + // network switched mid-refresh); surface only genuine failures. + if (controller.signal.aborted) return; + reportError( + e instanceof Error ? e.message : "Failed to refresh account data", + "account", + "error", + ); } finally { + if (accountRequestRef.current === controller) { + accountRequestRef.current = null; + } setIsLoadingAccount(false); isRefreshingRef.current = false; } @@ -479,6 +608,7 @@ export function SorokitProvider({ client: currentClient, address, walletName, + detectedWallets, isHardware, isConnected: !!address, isConnecting, @@ -511,6 +641,7 @@ export function SorokitProvider({ currentClient, address, walletName, + detectedWallets, isHardware, isConnecting, isDisconnecting, diff --git a/src/context/ToastContext.test.tsx b/src/context/ToastContext.test.tsx index 5f770b2..e729868 100644 --- a/src/context/ToastContext.test.tsx +++ b/src/context/ToastContext.test.tsx @@ -284,6 +284,106 @@ describe("ToastContext", () => { }); }); + describe("auto-dismiss timer pause/resume", () => { + it("pauses the countdown while hovered and resumes from where it left off", () => { + renderToasts(); + act(() => { + api().info("Hover me", { duration: 1000 }); + }); + + const toast = screen.getByRole("alert"); + fireEvent.mouseEnter(toast); + + // Far beyond the original duration — a paused timer must not fire. + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(api().toasts).toHaveLength(1); + + // The remaining 1s is served after the pointer leaves. + fireEvent.mouseLeave(toast); + act(() => { + vi.advanceTimersByTime(999); + }); + expect(api().toasts).toHaveLength(1); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(api().toasts).toHaveLength(0); + }); + + it("pauses the countdown while focused and resumes on blur", () => { + renderToasts(); + act(() => { + api().info("Focus me", { duration: 1000 }); + }); + + const toast = screen.getByRole("alert"); + act(() => toast.focus()); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(api().toasts).toHaveLength(1); + + act(() => toast.blur()); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(api().toasts).toHaveLength(0); + }); + + it("keeps the countdown paused while hover and focus overlap", () => { + renderToasts(); + act(() => { + api().info("Both", { duration: 1000 }); + }); + + const toast = screen.getByRole("alert"); + // Pointer enters, then keyboard focus arrives; releasing only one of the + // two sources must not let the timer resume. + fireEvent.mouseEnter(toast); + act(() => toast.focus()); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(api().toasts).toHaveLength(1); + + fireEvent.mouseLeave(toast); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(api().toasts).toHaveLength(1); + + act(() => toast.blur()); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(api().toasts).toHaveLength(0); + }); + + it("pauseToast/resumeToast are exposed on the context", () => { + renderToasts(); + expect(typeof api().pauseToast).toBe("function"); + expect(typeof api().resumeToast).toBe("function"); + }); + + it("sticky toasts (duration 0) have no countdown to pause", () => { + renderToasts(); + act(() => { + api().info("Sticky", { duration: 0 }); + }); + + const toast = screen.getByRole("alert"); + fireEvent.mouseEnter(toast); + fireEvent.mouseLeave(toast); + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(api().toasts).toHaveLength(1); + }); + }); + describe("stacking", () => { it("renders every toast in a single viewport, oldest to newest", () => { renderToasts(); diff --git a/src/context/ToastContext.tsx b/src/context/ToastContext.tsx index 8e9c914..0852938 100644 --- a/src/context/ToastContext.tsx +++ b/src/context/ToastContext.tsx @@ -52,6 +52,14 @@ interface ToastContextValue { dismissAll: () => void; /** @deprecated Alias of `dismissAll`, kept for backwards compatibility. */ clearAll: () => void; + /** + * Pause a toast's auto-dismiss countdown (e.g. while the pointer hovers it + * or it holds keyboard focus). Pauses are reference-counted so overlapping + * hover/focus sources don't resume the timer early. + */ + pauseToast: (id: string) => void; + /** Resume a paused toast's countdown from where it left off. */ + resumeToast: (id: string) => void; } /* ------------------------------------------------------------------ */ @@ -67,23 +75,63 @@ const ToastContext = createContext(null); let toastCounter = 0; const DEFAULT_DURATION = 5000; +/** + * Per-toast countdown state. `remaining` is only meaningful while a toast is + * paused: it holds the milliseconds left when the countdown was frozen. + * `pauseCount` is reference-counted because a toast can be paused by more + * than one source at once (pointer hover *and* keyboard focus), and it must + * only resume once every source has released it. + */ +interface ToastTimer { + timeout: ReturnType | null; + remaining: number; + startedAt: number; + pauseCount: number; +} + export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]); const [unreadIds, setUnreadIds] = useState([]); - const timersRef = useRef>>( - new Map(), - ); + const timersRef = useRef>(new Map()); const removeToast = useCallback((id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)); setUnreadIds((prev) => prev.filter((unreadId) => unreadId !== id)); const timer = timersRef.current.get(id); if (timer) { - clearTimeout(timer); + if (timer.timeout !== null) clearTimeout(timer.timeout); timersRef.current.delete(id); } }, []); + const pauseToast = useCallback((id: string) => { + const entry = timersRef.current.get(id); + if (!entry) return; + entry.pauseCount += 1; + if (entry.pauseCount > 1 || entry.timeout === null) return; + // Freeze the countdown: remember what's left, then cancel the timer. + clearTimeout(entry.timeout); + entry.timeout = null; + const elapsed = Date.now() - entry.startedAt; + entry.remaining = Math.max(0, entry.remaining - elapsed); + }, []); + + const resumeToast = useCallback( + (id: string) => { + const entry = timersRef.current.get(id); + if (!entry || entry.pauseCount === 0) return; + entry.pauseCount -= 1; + if (entry.pauseCount > 0) return; + if (entry.remaining <= 0) { + removeToast(id); + return; + } + entry.startedAt = Date.now(); + entry.timeout = setTimeout(() => removeToast(id), entry.remaining); + }, + [removeToast], + ); + const addToast = useCallback( (input: Omit): string => { toastCounter += 1; @@ -95,8 +143,13 @@ export function ToastProvider({ children }: { children: ReactNode }) { if (toast.duration !== 0) { const ms = toast.duration ?? DEFAULT_DURATION; - const timer = setTimeout(() => removeToast(id), ms); - timersRef.current.set(id, timer); + const timeout = setTimeout(() => removeToast(id), ms); + timersRef.current.set(id, { + timeout, + remaining: ms, + startedAt: Date.now(), + pauseCount: 0, + }); } return id; @@ -105,7 +158,9 @@ export function ToastProvider({ children }: { children: ReactNode }) { ); const dismissAll = useCallback(() => { - timersRef.current.forEach((timer) => clearTimeout(timer)); + timersRef.current.forEach((timer) => { + if (timer.timeout !== null) clearTimeout(timer.timeout); + }); timersRef.current.clear(); setToasts([]); setUnreadIds([]); @@ -141,7 +196,9 @@ export function ToastProvider({ children }: { children: ReactNode }) { useEffect(() => { const timers = timersRef.current; return () => { - timers.forEach((timer) => clearTimeout(timer)); + timers.forEach((timer) => { + if (timer.timeout !== null) clearTimeout(timer.timeout); + }); timers.clear(); }; }, []); @@ -160,6 +217,8 @@ export function ToastProvider({ children }: { children: ReactNode }) { info, dismissAll, clearAll: dismissAll, + pauseToast, + resumeToast, }} > {children} diff --git a/src/hooks/useFeeData.test.tsx b/src/hooks/useFeeData.test.tsx new file mode 100644 index 0000000..76ffc82 --- /dev/null +++ b/src/hooks/useFeeData.test.tsx @@ -0,0 +1,174 @@ +import { act, fireEvent, screen } from "@testing-library/react"; +import { useEffect, useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProvider } from "@/__tests__/utils"; +import { createMockClient } from "@/lib/mock-client"; + +import { useFeeData } from "./useFeeData"; + +interface FeeState { + fee: { baseFee: string; recommended: string } | null; + loading: boolean; + error: string | null; +} + +function FeeProbe({ + refreshInterval = 0, + isVisible = true, + onState, +}: { + refreshInterval?: number; + isVisible?: boolean; + onState?: (state: FeeState) => void; +}) { + const { fee, loading, error } = useFeeData(refreshInterval, isVisible); + + useEffect(() => { + onState?.({ fee, loading, error }); + }, [fee, loading, error, onState]); + + return ( +
+ {fee ? `${fee.baseFee}/${fee.recommended}` : loading ? "loading" : "none"} +
+ ); +} + +function setVisibility(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); +} + +describe("useFeeData (#955 visibility pause)", () => { + beforeEach(() => { + setVisibility("visible"); + }); + + it("loads fee data on mount", async () => { + vi.useFakeTimers(); + const client = createMockClient(); + const estimateFee = vi.spyOn(client.transaction, "estimateFee"); + + renderWithProvider(, { client }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(estimateFee).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("fee")).toHaveTextContent("100/1000"); + }); + + it("polls on the requested interval while visible", async () => { + vi.useFakeTimers(); + const client = createMockClient(); + const estimateFee = vi.spyOn(client.transaction, "estimateFee"); + + renderWithProvider(, { client }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(estimateFee).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(estimateFee).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(estimateFee).toHaveBeenCalledTimes(3); + }); + + it("pauses polling while the tab is hidden and refreshes on return", async () => { + vi.useFakeTimers(); + const client = createMockClient(); + const estimateFee = vi.spyOn(client.transaction, "estimateFee"); + + renderWithProvider(, { client }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(estimateFee).toHaveBeenCalledTimes(1); + + // Background the tab: the polling interval should be torn down. + act(() => { + setVisibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(estimateFee).toHaveBeenCalledTimes(1); + + // Foregrounding refreshes immediately and re-arms the interval. + act(() => { + setVisibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(estimateFee).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(estimateFee).toHaveBeenCalledTimes(3); + }); + + it("does not poll while the caller reports the component invisible", async () => { + vi.useFakeTimers(); + const client = createMockClient(); + const estimateFee = vi.spyOn(client.transaction, "estimateFee"); + + renderWithProvider( + , + { client }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + + expect(estimateFee).not.toHaveBeenCalled(); + }); + + it("picks up polling again when the component becomes visible", async () => { + vi.useFakeTimers(); + const client = createMockClient(); + const estimateFee = vi.spyOn(client.transaction, "estimateFee"); + + function ToggleProbe() { + const [visible, setVisible] = useState(false); + return ( + <> + + + + ); + } + + renderWithProvider(, { client }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(estimateFee).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "show" })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(estimateFee).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useFeeData.ts b/src/hooks/useFeeData.ts index 170ce36..f291fba 100644 --- a/src/hooks/useFeeData.ts +++ b/src/hooks/useFeeData.ts @@ -30,6 +30,25 @@ export function useFeeData(refreshInterval = 0, isVisible: boolean = true) { const [, forceRender] = useState({}); const currentNetworkKey = networkKey(network); + // #955 — pause polling while the tab is backgrounded. Browsers throttle + // timers in hidden tabs anyway, so the poll buys nothing there but still + // costs network requests; `visibilitychange` lets the interval be cleared + // outright and re-armed with a fresh estimate when the tab comes back. + const [tabVisible, setTabVisible] = useState(() => + typeof document === "undefined" ? true : document.visibilityState !== "hidden", + ); + + useEffect(() => { + if (typeof document === "undefined") return; + const onVisibilityChange = () => { + setTabVisible(document.visibilityState !== "hidden"); + }; + onVisibilityChange(); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => + document.removeEventListener("visibilitychange", onVisibilityChange); + }, []); + const load = useCallback(async () => { if (!client || globalLoading) return; const generation = globalGeneration; @@ -92,7 +111,7 @@ export function useFeeData(refreshInterval = 0, isVisible: boolean = true) { }, []); useEffect(() => { - if (!isVisible || !client) return; + if (!isVisible || !tabVisible || !client) return; // Always fetch immediately on mount/resume, unless already loading if (!globalLoading) { @@ -115,7 +134,7 @@ export function useFeeData(refreshInterval = 0, isVisible: boolean = true) { } }; } - }, [client, isVisible, refreshInterval, load]); + }, [client, isVisible, tabVisible, refreshInterval, load]); return { fee: globalFeeData, diff --git a/src/hooks/useIsVisible.test.ts b/src/hooks/useIsVisible.test.ts index 4e85e57..6f34574 100644 --- a/src/hooks/useIsVisible.test.ts +++ b/src/hooks/useIsVisible.test.ts @@ -12,11 +12,16 @@ import { useIsVisible } from "./useIsVisible"; class MockIntersectionObserver { static instances: MockIntersectionObserver[] = []; callback: IntersectionObserverCallback; + options: IntersectionObserverInit | undefined; observedNode: Element | null = null; disconnected = false; - constructor(callback: IntersectionObserverCallback) { + constructor( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ) { this.callback = callback; + this.options = options; MockIntersectionObserver.instances.push(this); } @@ -121,6 +126,87 @@ describe("useIsVisible", () => { expect(MockIntersectionObserver.instances[0]!.disconnected).toBe(true); }); + describe("observer options (#955)", () => { + it("uses the default threshold of 0 when no options are given", () => { + const node = document.createElement("div"); + renderHook(() => { + const [ref] = useIsVisible(); + ref.current = node; + return ref; + }); + + expect(MockIntersectionObserver.instances[0]!.options).toMatchObject({ + threshold: 0, + }); + }); + + it("forwards a custom threshold and rootMargin to the observer", () => { + const node = document.createElement("div"); + renderHook(() => { + const [ref] = useIsVisible({ + threshold: 0.5, + rootMargin: "200px", + }); + ref.current = node; + return ref; + }); + + expect(MockIntersectionObserver.instances[0]!.options).toMatchObject({ + threshold: 0.5, + rootMargin: "200px", + }); + }); + + it("forwards an array of thresholds", () => { + const node = document.createElement("div"); + renderHook(() => { + const [ref] = useIsVisible({ threshold: [0, 0.5, 1] }); + ref.current = node; + return ref; + }); + + expect(MockIntersectionObserver.instances[0]!.options?.threshold).toEqual([ + 0, 0.5, 1, + ]); + }); + + it("recreates the observer when rootMargin changes", () => { + const node = document.createElement("div"); + const { rerender } = renderHook( + ({ margin }) => { + const [ref] = useIsVisible({ rootMargin: margin }); + ref.current = node; + return ref; + }, + { initialProps: { margin: "0px" } }, + ); + + expect(MockIntersectionObserver.instances.length).toBe(1); + rerender({ margin: "100px" }); + + expect(MockIntersectionObserver.instances.length).toBe(2); + expect(MockIntersectionObserver.instances[0]!.disconnected).toBe(true); + expect(MockIntersectionObserver.instances[1]!.options).toMatchObject({ + rootMargin: "100px", + }); + }); + + it("does not recreate the observer when an equivalent threshold array is passed again", () => { + const node = document.createElement("div"); + const { rerender } = renderHook(() => { + const [ref] = useIsVisible({ threshold: [0, 1] }); + ref.current = node; + return ref; + }); + + expect(MockIntersectionObserver.instances.length).toBe(1); + // A fresh array identity, same values — should not tear down the observer. + rerender(); + + expect(MockIntersectionObserver.instances.length).toBe(1); + }); + }); + it("fails open (stays visible) when IntersectionObserver is unavailable", () => { vi.unstubAllGlobals(); vi.stubGlobal("IntersectionObserver", undefined); diff --git a/src/hooks/useIsVisible.ts b/src/hooks/useIsVisible.ts index e6ebbed..4c24115 100644 --- a/src/hooks/useIsVisible.ts +++ b/src/hooks/useIsVisible.ts @@ -1,5 +1,25 @@ import { useEffect, useRef, useState } from "react"; +export interface UseIsVisibleOptions { + /** + * IntersectionObserver threshold(s). Defaults to `0` — any non-zero + * intersection counts as visible. Raise it (or pass an array) when a + * component should only be considered "visible" once enough of it is on + * screen, e.g. to gate an expensive animation on a mostly-visible panel. + */ + threshold?: number | number[]; + /** + * IntersectionObserver rootMargin. Useful to start/stop work slightly + * before an element actually enters the viewport (e.g. `"200px"`). + */ + rootMargin?: string; + /** + * IntersectionObserver root. Defaults to the viewport; pass a scroll + * container when visibility should be measured against it instead. + */ + root?: Element | Document | null; +} + /** * Tracks whether the element `ref` is attached to currently has a laid-out, * non-zero-area box — i.e. it isn't `display: none` (directly, or via a @@ -22,9 +42,19 @@ import { useEffect, useRef, useState } from "react"; * very first load while waiting on the initial IntersectionObserver * entry, which can arrive a frame or two after mount. */ -export function useIsVisible(): [React.RefObject, boolean] { +export function useIsVisible( + options: UseIsVisibleOptions = {}, +): [React.RefObject, boolean] { const ref = useRef(null); const [isVisible, setIsVisible] = useState(true); + const { threshold = 0, rootMargin, root } = options; + + // Thresholds are commonly passed as an inline array, which would be a new + // identity on every render and needlessly tear down/recreate the observer. + // Key the effect on the serialized value instead. + const thresholdKey = Array.isArray(threshold) + ? threshold.join(",") + : String(threshold); useEffect(() => { const node = ref.current; @@ -39,13 +69,16 @@ export function useIsVisible(): [React.RefObject, b ([entry]) => { if (entry) setIsVisible(entry.isIntersecting); }, - // threshold: 0 — any non-zero intersection counts as visible; this - // is a mount-gate, not a "is it prominently on screen" measurement. - { threshold: 0 }, + // Default threshold: 0 — any non-zero intersection counts as visible; + // this is a mount-gate, not a "is it prominently on screen" measurement. + { threshold, rootMargin, root }, ); observer.observe(node); return () => observer.disconnect(); - }, []); + // `thresholdKey` stands in for `threshold` (which may be an array); + // numeric/string options are compared by value, `root` by identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [thresholdKey, rootMargin, root]); return [ref, isVisible]; } diff --git a/src/hooks/useOutsideClick.test.ts b/src/hooks/useOutsideClick.test.ts index 7d9b954..ed8b7f0 100644 --- a/src/hooks/useOutsideClick.test.ts +++ b/src/hooks/useOutsideClick.test.ts @@ -89,6 +89,61 @@ describe("useOutsideClick", () => { expect(handler).not.toHaveBeenCalled(); }); + describe("explicit target rebinding (#955)", () => { + it("uses the explicitly passed target as the outside boundary", () => { + const handler = vi.fn(); + const target = document.createElement("div"); + const outside = document.createElement("div"); + document.body.appendChild(target); + document.body.appendChild(outside); + + renderHook(() => useOutsideClick(handler, true, target)); + + fireEvent.mouseDown(target); + expect(handler).not.toHaveBeenCalled(); + + fireEvent.mouseDown(outside); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("rebinds to a new target when the target element changes", () => { + const handler = vi.fn(); + const first = document.createElement("div"); + const second = document.createElement("div"); + document.body.appendChild(first); + document.body.appendChild(second); + + const { rerender } = renderHook( + ({ target }) => useOutsideClick(handler, true, target), + { initialProps: { target: first as HTMLDivElement | null } }, + ); + + // Clicks inside the first target are ignored. + fireEvent.mouseDown(first); + expect(handler).not.toHaveBeenCalled(); + + // Swap the boundary to the second element. + rerender({ target: second }); + + // The old boundary is now "outside". + fireEvent.mouseDown(first); + expect(handler).toHaveBeenCalledTimes(1); + + // The new boundary is respected. + fireEvent.mouseDown(second); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("does not fire while the explicit target is null", () => { + const handler = vi.fn(); + const outside = document.createElement("div"); + document.body.appendChild(outside); + + renderHook(() => useOutsideClick(handler, true, null)); + + fireEvent.mouseDown(outside); + expect(handler).not.toHaveBeenCalled(); + }); it("removes the exact document listeners that were added when disabled", () => { vi.restoreAllMocks(); const addSpy = vi.spyOn(document, "addEventListener"); diff --git a/src/hooks/useOutsideClick.ts b/src/hooks/useOutsideClick.ts index 715ce45..aaed8f0 100644 --- a/src/hooks/useOutsideClick.ts +++ b/src/hooks/useOutsideClick.ts @@ -3,10 +3,17 @@ import { useEffect, useRef } from "react"; /** * Hook that calls a handler when a click or touch event occurs outside of the referenced element. * Listens to `pointerdown`, `touchstart`, and `mousedown` to ensure full compatibility with touch devices (iOS Safari). + * + * The target can either be the returned ref (assigned via `ref={ref}` or + * `ref.current = node`) or passed explicitly as the third argument. Pass the + * element explicitly when it is conditionally mounted: the listeners are + * rebound whenever `target` changes, so a surface that mounts a different + * popover/panel later still gets the correct "outside" boundary. */ export function useOutsideClick( handler: (event: Event) => void, enabled: boolean = true, + target?: T | null, ) { const ref = useRef(null); const handlerRef = useRef(handler); @@ -17,7 +24,7 @@ export function useOutsideClick( useEffect(() => { const listener = (event: MouseEvent | TouchEvent | PointerEvent) => { - const el = ref.current; + const el = target ?? ref.current; if (!el || el.contains(event.target as Node)) { return; } @@ -42,7 +49,7 @@ export function useOutsideClick( document.removeEventListener("touchstart", listener, options); document.removeEventListener("mousedown", listener, options); }; - }, [enabled]); + }, [enabled, target]); return ref; }