diff --git a/app/components/account/HistoryCardComponents.tsx b/app/components/account/HistoryCardComponents.tsx
index 056423320..5f7d6f4eb 100644
--- a/app/components/account/HistoryCardComponents.tsx
+++ b/app/components/account/HistoryCardComponents.tsx
@@ -1,6 +1,6 @@
import { ConfirmedSignatureInfo, TransactionError } from '@solana/web3.js';
import React from 'react';
-import { RefreshCw } from 'react-feather';
+import { Eye, EyeOff, RefreshCw } from 'react-feather';
export type TransactionRow = {
slot: number;
@@ -16,27 +16,52 @@ export function HistoryCardHeader({
title,
refresh,
fetching,
+ hideFailedTxs,
+ onToggleHideFailedTxs,
}: {
title: string;
refresh: () => void;
fetching: boolean;
+ hideFailedTxs?: boolean;
+ onToggleHideFailedTxs?: (value: boolean) => void;
}) {
return (
{title}
-
);
}
diff --git a/app/components/account/__tests__/HistoryCardComponents.spec.tsx b/app/components/account/__tests__/HistoryCardComponents.spec.tsx
new file mode 100644
index 000000000..5b6fcda1f
--- /dev/null
+++ b/app/components/account/__tests__/HistoryCardComponents.spec.tsx
@@ -0,0 +1,288 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { getTransactionRows,HistoryCardFooter, HistoryCardHeader } from '../HistoryCardComponents';
+
+describe('HistoryCardHeader', () => {
+ it('should render title and refresh button', () => {
+ const mockRefresh = vi.fn();
+
+ render();
+
+ expect(screen.getByText('Transaction History')).toBeInTheDocument();
+ expect(screen.getByText('Refresh')).toBeInTheDocument();
+ });
+
+ it('should call refresh when refresh button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockRefresh = vi.fn();
+
+ render();
+
+ const refreshButton = screen.getByRole('button', { name: /refresh/i });
+
+ await user.click(refreshButton);
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('should disable refresh button when fetching', () => {
+ const mockRefresh = vi.fn();
+
+ render();
+
+ const refreshButton = screen.getByRole('button', { name: /loading/i });
+ expect(refreshButton).toBeDisabled();
+ });
+
+ it('should not render filter button when filter props are not provided', () => {
+ const mockRefresh = vi.fn();
+
+ render();
+
+ expect(screen.queryByText('Hide Failed')).not.toBeInTheDocument();
+ expect(screen.queryByText('Show All')).not.toBeInTheDocument();
+ });
+
+ it('should render "Hide Failed" button when hideFailedTxs is false', () => {
+ const mockRefresh = vi.fn();
+ const mockToggle = vi.fn();
+
+ render(
+
+ );
+
+ expect(screen.getByText('Hide Failed')).toBeInTheDocument();
+ });
+
+ it('should render "Show All" button when hideFailedTxs is true', () => {
+ const mockRefresh = vi.fn();
+ const mockToggle = vi.fn();
+
+ render(
+
+ );
+
+ expect(screen.getByText('Show All')).toBeInTheDocument();
+ });
+
+ it('should call onToggleHideFailedTxs with opposite value when filter button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockRefresh = vi.fn();
+ const mockToggle = vi.fn();
+
+ render(
+
+ );
+
+ const filterButton = screen.getByRole('button', { name: /hide failed/i });
+
+ await user.click(filterButton);
+ expect(mockToggle).toHaveBeenCalledTimes(1);
+ expect(mockToggle).toHaveBeenCalledWith(true);
+ });
+
+ it('should disable filter button when fetching', () => {
+ const mockRefresh = vi.fn();
+ const mockToggle = vi.fn();
+
+ render(
+
+ );
+
+ const filterButton = screen.getByRole('button', { name: /hide failed/i });
+ expect(filterButton).toBeDisabled();
+ });
+
+ it('should render both filter and refresh buttons when filter props are provided', () => {
+ const mockRefresh = vi.fn();
+ const mockToggle = vi.fn();
+
+ render(
+
+ );
+
+ expect(screen.getByText('Hide Failed')).toBeInTheDocument();
+ expect(screen.getByText('Refresh')).toBeInTheDocument();
+ });
+});
+
+describe('HistoryCardFooter', () => {
+ it('should render "Load More" button when not at oldest', () => {
+ const mockLoadMore = vi.fn();
+
+ render();
+
+ expect(screen.getByText('Load More')).toBeInTheDocument();
+ });
+
+ it('should render "Fetched full history" when at oldest', () => {
+ const mockLoadMore = vi.fn();
+
+ render();
+
+ expect(screen.getByText('Fetched full history')).toBeInTheDocument();
+ expect(screen.queryByText('Load More')).not.toBeInTheDocument();
+ });
+
+ it('should call loadMore when button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockLoadMore = vi.fn();
+
+ render();
+
+ const loadMoreButton = screen.getByRole('button', { name: /load more/i });
+
+ await user.click(loadMoreButton);
+ expect(mockLoadMore).toHaveBeenCalledTimes(1);
+ });
+
+ it('should disable "Load More" button when fetching', () => {
+ const mockLoadMore = vi.fn();
+
+ render();
+
+ const loadMoreButton = screen.getByRole('button', { name: /loading/i });
+ expect(loadMoreButton).toBeDisabled();
+ });
+});
+
+describe('getTransactionRows', () => {
+ it('should return empty array for empty input', () => {
+ const result = getTransactionRows([]);
+ expect(result).toEqual([]);
+ });
+
+ it('should mark transaction with err as failed', () => {
+ const mockSignatures = [
+ {
+ blockTime: 1234567890,
+ confirmationStatus: 'finalized' as const,
+ err: { InstructionError: [0, 'Custom error'] },
+ memo: null,
+ signature: 'signature1',
+ slot: 100,
+ },
+ ];
+
+ const result = getTransactionRows(mockSignatures);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].statusClass).toBe('warning');
+ expect(result[0].statusText).toBe('Failed');
+ expect(result[0].err).not.toBeNull();
+ });
+
+ it('should mark transaction without err as success', () => {
+ const mockSignatures = [
+ {
+ blockTime: 1234567890,
+ confirmationStatus: 'finalized' as const,
+ err: null,
+ memo: null,
+ signature: 'signature2',
+ slot: 101,
+ },
+ ];
+
+ const result = getTransactionRows(mockSignatures);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].statusClass).toBe('success');
+ expect(result[0].statusText).toBe('Success');
+ expect(result[0].err).toBeNull();
+ });
+
+ it('should process multiple transactions correctly', () => {
+ const mockSignatures = [
+ {
+ blockTime: 1234567890,
+ confirmationStatus: 'finalized' as const,
+ err: null,
+ memo: null,
+ signature: 'signature1',
+ slot: 100,
+ },
+ {
+ blockTime: 1234567891,
+ confirmationStatus: 'finalized' as const,
+ err: { InstructionError: [0, 'Custom error'] },
+ memo: null,
+ signature: 'signature2',
+ slot: 101,
+ },
+ {
+ blockTime: 1234567892,
+ confirmationStatus: 'finalized' as const,
+ err: null,
+ memo: null,
+ signature: 'signature3',
+ slot: 102,
+ },
+ ];
+
+ const result = getTransactionRows(mockSignatures);
+
+ expect(result).toHaveLength(3);
+ expect(result[0].statusClass).toBe('success');
+ expect(result[1].statusClass).toBe('warning');
+ expect(result[2].statusClass).toBe('success');
+ });
+
+ it('should group transactions by slot', () => {
+ const mockSignatures = [
+ {
+ blockTime: 1234567890,
+ confirmationStatus: 'finalized' as const,
+ err: null,
+ memo: null,
+ signature: 'signature1',
+ slot: 100,
+ },
+ {
+ blockTime: 1234567890,
+ confirmationStatus: 'finalized' as const,
+ err: { InstructionError: [0, 'Custom error'] },
+ memo: null,
+ signature: 'signature2',
+ slot: 100,
+ },
+ ];
+
+ const result = getTransactionRows(mockSignatures);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].slot).toBe(100);
+ expect(result[1].slot).toBe(100);
+ });
+});
diff --git a/app/components/account/history/TransactionHistoryCard.tsx b/app/components/account/history/TransactionHistoryCard.tsx
index 9043d2396..221651bd1 100644
--- a/app/components/account/history/TransactionHistoryCard.tsx
+++ b/app/components/account/history/TransactionHistoryCard.tsx
@@ -11,6 +11,8 @@ import { displayTimestampUtc } from '@utils/date';
import React, { useMemo } from 'react';
import Moment from 'react-moment';
+import { useHideFailedTransactions } from '@/app/hooks/useHideFailedTransactions';
+
import { getTransactionRows, HistoryCardFooter, HistoryCardHeader } from '../HistoryCardComponents';
export function TransactionHistoryCard({ address }: { address: string }) {
@@ -19,13 +21,15 @@ export function TransactionHistoryCard({ address }: { address: string }) {
const fetchAccountHistory = useFetchAccountHistory();
const refresh = () => fetchAccountHistory(pubkey, false, true);
const loadMore = () => fetchAccountHistory(pubkey, false);
+ const [hideFailedTxs, setHideFailedTxs] = useHideFailedTransactions();
const transactionRows = React.useMemo(() => {
if (history?.data?.fetched) {
- return getTransactionRows(history.data.fetched);
+ const rows = getTransactionRows(history.data.fetched);
+ return hideFailedTxs ? rows.filter(row => !row.err) : rows;
}
return [];
- }, [history]);
+ }, [history, hideFailedTxs]);
React.useEffect(() => {
if (!history) {
@@ -80,7 +84,13 @@ export function TransactionHistoryCard({ address }: { address: string }) {
const fetching = history.status === FetchStatus.Fetching;
return (
-
refresh()} title="Transaction History" />
+ refresh()}
+ title="Transaction History"
+ hideFailedTxs={hideFailedTxs}
+ onToggleHideFailedTxs={setHideFailedTxs}
+ />
diff --git a/app/hooks/__tests__/useHideFailedTransactions.spec.tsx b/app/hooks/__tests__/useHideFailedTransactions.spec.tsx
new file mode 100644
index 000000000..719b98429
--- /dev/null
+++ b/app/hooks/__tests__/useHideFailedTransactions.spec.tsx
@@ -0,0 +1,118 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { useHideFailedTransactions } from '../useHideFailedTransactions';
+
+const STORAGE_KEY = 'hideFailedTransactions';
+
+describe('useHideFailedTransactions', () => {
+ beforeEach(() => {
+ // Clear localStorage before each test
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ // Clean up after each test
+ localStorage.clear();
+ });
+
+ it('should initialize with false when localStorage is empty', () => {
+ const { result } = renderHook(() => useHideFailedTransactions());
+ const [hideFailedTxs] = result.current;
+
+ expect(hideFailedTxs).toBe(false);
+ });
+
+ it('should initialize with true when localStorage has "true"', () => {
+ localStorage.setItem(STORAGE_KEY, 'true');
+
+ const { result } = renderHook(() => useHideFailedTransactions());
+ const [hideFailedTxs] = result.current;
+
+ expect(hideFailedTxs).toBe(true);
+ });
+
+ it('should initialize with false when localStorage has "false"', () => {
+ localStorage.setItem(STORAGE_KEY, 'false');
+
+ const { result } = renderHook(() => useHideFailedTransactions());
+ const [hideFailedTxs] = result.current;
+
+ expect(hideFailedTxs).toBe(false);
+ });
+
+ it('should persist state to localStorage when set to true', () => {
+ const { result } = renderHook(() => useHideFailedTransactions());
+ const [, setHideFailedTxs] = result.current;
+
+ act(() => {
+ setHideFailedTxs(true);
+ });
+
+ expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
+ expect(result.current[0]).toBe(true);
+ });
+
+ it('should persist state to localStorage when set to false', () => {
+ localStorage.setItem(STORAGE_KEY, 'true');
+
+ const { result } = renderHook(() => useHideFailedTransactions());
+ const [, setHideFailedTxs] = result.current;
+
+ act(() => {
+ setHideFailedTxs(false);
+ });
+
+ expect(localStorage.getItem(STORAGE_KEY)).toBe('false');
+ expect(result.current[0]).toBe(false);
+ });
+
+ it('should toggle state correctly', () => {
+ const { result } = renderHook(() => useHideFailedTransactions());
+
+ // Initial state is false
+ expect(result.current[0]).toBe(false);
+
+ // Toggle to true
+ act(() => {
+ result.current[1](true);
+ });
+ expect(result.current[0]).toBe(true);
+ expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
+
+ // Toggle to false
+ act(() => {
+ result.current[1](false);
+ });
+ expect(result.current[0]).toBe(false);
+ expect(localStorage.getItem(STORAGE_KEY)).toBe('false');
+ });
+
+ it('should preserve state across hook remounts', () => {
+ // First render
+ const { result: result1, unmount } = renderHook(() => useHideFailedTransactions());
+
+ act(() => {
+ result1.current[1](true);
+ });
+
+ expect(result1.current[0]).toBe(true);
+
+ // Unmount and remount
+ unmount();
+ const { result: result2 } = renderHook(() => useHideFailedTransactions());
+
+ // State should be preserved from localStorage
+ expect(result2.current[0]).toBe(true);
+ });
+
+ it('should handle invalid localStorage values gracefully', () => {
+ localStorage.setItem(STORAGE_KEY, 'invalid-value');
+
+ const { result } = renderHook(() => useHideFailedTransactions());
+ const [hideFailedTxs] = result.current;
+
+ // Should default to false for invalid values
+ expect(hideFailedTxs).toBe(false);
+ });
+});
diff --git a/app/hooks/useHideFailedTransactions.ts b/app/hooks/useHideFailedTransactions.ts
new file mode 100644
index 000000000..7b79a2d52
--- /dev/null
+++ b/app/hooks/useHideFailedTransactions.ts
@@ -0,0 +1,42 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+const STORAGE_KEY = 'hideFailedTransactions';
+
+/**
+ * Custom hook to manage the "hide failed transactions" filter preference.
+ * State is persisted to localStorage and survives page reloads.
+ *
+ * @returns [hideFailedTxs, setHideFailedTxs] - Current state and setter function
+ */
+export function useHideFailedTransactions(): [boolean, (value: boolean) => void] {
+ const [hideFailedTxs, setHideFailedTxsState] = useState(() => {
+ // Lazy initialization - read from localStorage on mount
+ if (typeof window === 'undefined') {
+ return false; // SSR safe default
+ }
+
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ return stored === 'true';
+ } catch {
+ // Handle localStorage access errors gracefully
+ return false;
+ }
+ });
+
+ useEffect(() => {
+ // Sync state changes to localStorage
+ if (typeof window !== 'undefined') {
+ try {
+ localStorage.setItem(STORAGE_KEY, String(hideFailedTxs));
+ } catch {
+ // Handle localStorage write errors gracefully (e.g., quota exceeded)
+ console.warn('Failed to save hideFailedTransactions preference to localStorage');
+ }
+ }
+ }, [hideFailedTxs]);
+
+ return [hideFailedTxs, setHideFailedTxsState];
+}
\ No newline at end of file
diff --git a/bench/BUILD.md b/bench/BUILD.md
index 962d8a556..755ae22c0 100644
--- a/bench/BUILD.md
+++ b/bench/BUILD.md
@@ -1,32 +1,32 @@
| Type | Route | Size | First Load JS |
|------|-------|------|---------------|
-| Static | `/` | 15.6 kB | 1.01 MB |
+| Static | `/` | 15.6 kB | 1 MB |
| Static | `/_not-found` | 326 B | 157 kB |
-| Dynamic | `/address/[address]` | 6.43 kB | 284 kB |
-| Dynamic | `/address/[address]/anchor-account` | 5.76 kB | 987 kB |
+| Dynamic | `/address/[address]` | 6.7 kB | 284 kB |
+| Dynamic | `/address/[address]/anchor-account` | 5.75 kB | 984 kB |
| Dynamic | `/address/[address]/anchor-program` | 326 B | 157 kB |
-| Dynamic | `/address/[address]/attestation` | 5.78 kB | 967 kB |
-| Dynamic | `/address/[address]/attributes` | 2.49 kB | 927 kB |
-| Dynamic | `/address/[address]/blockhashes` | 1.88 kB | 921 kB |
-| Dynamic | `/address/[address]/compression` | 4.74 kB | 953 kB |
-| Dynamic | `/address/[address]/concurrent-merkle-tree` | 3.63 kB | 947 kB |
-| Dynamic | `/address/[address]/domains` | 13.7 kB | 928 kB |
-| Dynamic | `/address/[address]/entries` | 2.98 kB | 934 kB |
-| Dynamic | `/address/[address]/feature-gate` | 325 B | 157 kB |
-| Dynamic | `/address/[address]/idl` | 132 kB | 598 kB |
-| Dynamic | `/address/[address]/instructions` | 2.11 kB | 1.03 MB |
-| Dynamic | `/address/[address]/metadata` | 3.89 kB | 941 kB |
-| Dynamic | `/address/[address]/nftoken-collection-nfts` | 5.84 kB | 967 kB |
-| Dynamic | `/address/[address]/program-multisig` | 3.4 kB | 990 kB |
-| Dynamic | `/address/[address]/rewards` | 3.47 kB | 925 kB |
-| Dynamic | `/address/[address]/security` | 8.13 kB | 1.01 MB |
-| Dynamic | `/address/[address]/slot-hashes` | 3.39 kB | 925 kB |
-| Dynamic | `/address/[address]/stake-history` | 3.52 kB | 925 kB |
-| Dynamic | `/address/[address]/token-extensions` | 8.46 kB | 986 kB |
-| Dynamic | `/address/[address]/tokens` | 7.97 kB | 1.12 MB |
-| Dynamic | `/address/[address]/transfers` | 3.52 kB | 1.06 MB |
-| Dynamic | `/address/[address]/verified-build` | 5.94 kB | 992 kB |
-| Dynamic | `/address/[address]/vote-history` | 3.4 kB | 925 kB |
+| Dynamic | `/address/[address]/attestation` | 5.79 kB | 965 kB |
+| Dynamic | `/address/[address]/attributes` | 2.48 kB | 924 kB |
+| Dynamic | `/address/[address]/blockhashes` | 1.87 kB | 918 kB |
+| Dynamic | `/address/[address]/compression` | 4.74 kB | 951 kB |
+| Dynamic | `/address/[address]/concurrent-merkle-tree` | 3.63 kB | 945 kB |
+| Dynamic | `/address/[address]/domains` | 13.8 kB | 925 kB |
+| Dynamic | `/address/[address]/entries` | 2.97 kB | 931 kB |
+| Dynamic | `/address/[address]/feature-gate` | 326 B | 157 kB |
+| Dynamic | `/address/[address]/idl` | 132 kB | 597 kB |
+| Dynamic | `/address/[address]/instructions` | 2.23 kB | 1.02 MB |
+| Dynamic | `/address/[address]/metadata` | 3.89 kB | 938 kB |
+| Dynamic | `/address/[address]/nftoken-collection-nfts` | 5.86 kB | 964 kB |
+| Dynamic | `/address/[address]/program-multisig` | 3.39 kB | 987 kB |
+| Dynamic | `/address/[address]/rewards` | 3.46 kB | 922 kB |
+| Dynamic | `/address/[address]/security` | 8.2 kB | 1 MB |
+| Dynamic | `/address/[address]/slot-hashes` | 3.37 kB | 922 kB |
+| Dynamic | `/address/[address]/stake-history` | 3.51 kB | 922 kB |
+| Dynamic | `/address/[address]/token-extensions` | 8.46 kB | 983 kB |
+| Dynamic | `/address/[address]/tokens` | 7.96 kB | 1.12 MB |
+| Dynamic | `/address/[address]/transfers` | 3.62 kB | 1.06 MB |
+| Dynamic | `/address/[address]/verified-build` | 5.93 kB | 989 kB |
+| Dynamic | `/address/[address]/vote-history` | 3.39 kB | 922 kB |
| Dynamic | `/api/anchor` | 0 B | 0 B |
| Dynamic | `/api/domain-info/[domain]` | 0 B | 0 B |
| Static | `/api/metadata/proxy` | 0 B | 0 B |
@@ -34,15 +34,15 @@
| Dynamic | `/api/programMetadataIdl` | 0 B | 0 B |
| Dynamic | `/api/verified-programs/list/[page]` | 0 B | 0 B |
| Dynamic | `/api/verified-programs/metadata/[programId]` | 0 B | 0 B |
-| Dynamic | `/block/[slot]` | 10.3 kB | 938 kB |
-| Dynamic | `/block/[slot]/accounts` | 4.46 kB | 918 kB |
-| Dynamic | `/block/[slot]/programs` | 5.04 kB | 919 kB |
-| Dynamic | `/block/[slot]/rewards` | 4.97 kB | 924 kB |
-| Dynamic | `/epoch/[epoch]` | 6.77 kB | 257 kB |
-| Static | `/feature-gates` | 3.35 kB | 923 kB |
+| Dynamic | `/block/[slot]` | 10.3 kB | 935 kB |
+| Dynamic | `/block/[slot]/accounts` | 4.49 kB | 915 kB |
+| Dynamic | `/block/[slot]/programs` | 5.09 kB | 916 kB |
+| Dynamic | `/block/[slot]/rewards` | 5.02 kB | 921 kB |
+| Dynamic | `/epoch/[epoch]` | 6.83 kB | 257 kB |
+| Static | `/feature-gates` | 3.38 kB | 921 kB |
| Static | `/opengraph-image.png` | 0 B | 0 B |
-| Static | `/supply` | 6.48 kB | 925 kB |
-| Dynamic | `/tx/[signature]` | 37.5 kB | 1.39 MB |
-| Dynamic | `/tx/[signature]/inspect` | 609 B | 1.19 MB |
-| Static | `/tx/inspector` | 623 B | 1.19 MB |
-| Static | `/verified-programs` | 6.09 kB | 165 kB |
\ No newline at end of file
+| Static | `/supply` | 6.52 kB | 922 kB |
+| Dynamic | `/tx/[signature]` | 37.4 kB | 1.38 MB |
+| Dynamic | `/tx/[signature]/inspect` | 605 B | 1.19 MB |
+| Static | `/tx/inspector` | 618 B | 1.19 MB |
+| Static | `/verified-programs` | 6.21 kB | 165 kB |
\ No newline at end of file