From 34dc7fc6ea9f94510851da0ccd747bb260a24ab8 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Fri, 24 Apr 2026 09:44:04 +0200 Subject: [PATCH 01/19] Add slot based filtering on /address/[addy] page --- .../account/HistoryCardComponents.tsx | 7 +- .../account/history/HistoryFilterBar.tsx | 120 ++++++++++++++++++ .../ui/TransactionHistoryCard.tsx | 23 +++- app/providers/accounts/history.tsx | 24 +++- 4 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 app/components/account/history/HistoryFilterBar.tsx diff --git a/app/components/account/HistoryCardComponents.tsx b/app/components/account/HistoryCardComponents.tsx index 81ecd48af..756d8c4d7 100644 --- a/app/components/account/HistoryCardComponents.tsx +++ b/app/components/account/HistoryCardComponents.tsx @@ -17,16 +17,21 @@ export function HistoryCardHeader({ analyticsSection, refresh, fetching, + actions, }: { title: string; analyticsSection: string; refresh: () => void; fetching: boolean; + actions?: React.ReactNode; }) { return (

{title}

- +
+ {actions} + +
); } diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx new file mode 100644 index 000000000..ad45440da --- /dev/null +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { Button } from '@components/shared/ui/button'; +import { Input } from '@components/shared/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@components/shared/ui/popover'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import React from 'react'; +import { Filter, X } from 'react-feather'; + +export const AFTER_SLOT_PARAM = 'afterSlot'; + +export function useAfterSlotParam(): number | undefined { + const searchParams = useSearchParams(); + const raw = searchParams?.get(AFTER_SLOT_PARAM); + if (!raw) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : undefined; +} + +function useUpdateAfterSlot() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return React.useCallback( + (next: number | undefined) => { + const params = new URLSearchParams(searchParams?.toString() ?? ''); + if (next === undefined) { + params.delete(AFTER_SLOT_PARAM); + } else { + params.set(AFTER_SLOT_PARAM, String(next)); + } + const qs = params.toString(); + router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }); + }, + [router, pathname, searchParams], + ); +} + +export function HistoryFilterBar({ afterSlot }: { afterSlot: number | undefined }) { + const updateAfterSlot = useUpdateAfterSlot(); + const [open, setOpen] = React.useState(false); + const [draft, setDraft] = React.useState(afterSlot !== undefined ? String(afterSlot) : ''); + + React.useEffect(() => { + setDraft(afterSlot !== undefined ? String(afterSlot) : ''); + }, [afterSlot]); + + const apply = () => { + const trimmed = draft.trim(); + if (!trimmed) { + updateAfterSlot(undefined); + } else { + const parsed = Number(trimmed); + if (Number.isFinite(parsed) && parsed >= 0) { + updateAfterSlot(Math.floor(parsed)); + } + } + setOpen(false); + }; + + const clear = () => updateAfterSlot(undefined); + + return ( +
+ {afterSlot !== undefined && ( + + After slot: {afterSlot.toLocaleString()} + + + )} + + + + + +
{ + e.preventDefault(); + apply(); + }} + className="e-flex e-flex-col e-gap-2" + > + + setDraft(e.target.value)} + /> +
+ {afterSlot !== undefined && ( + + )} + +
+
+
+
+
+ ); +} diff --git a/app/features/transaction-history/ui/TransactionHistoryCard.tsx b/app/features/transaction-history/ui/TransactionHistoryCard.tsx index 30b3bc939..7f296044e 100644 --- a/app/features/transaction-history/ui/TransactionHistoryCard.tsx +++ b/app/features/transaction-history/ui/TransactionHistoryCard.tsx @@ -6,7 +6,7 @@ import { ErrorCard } from '@components/common/ErrorCard'; import { LoadingCard } from '@components/common/LoadingCard'; import { Signature } from '@components/common/Signature'; import { Slot } from '@components/common/Slot'; -import { useAccountHistory, useFetchAccountHistory } from '@providers/accounts/history'; +import { useAccountHistory, useClearAccountHistories, useFetchAccountHistory } from '@providers/accounts/history'; import { FetchStatus } from '@providers/cache'; import { PublicKey } from '@solana/web3.js'; import { displayTimestampUtc } from '@utils/date'; @@ -17,14 +17,18 @@ import { useFetchRawTransaction, useRawTransactionDetails } from '@/app/provider import { DownloadDropdown } from '@/app/shared/components/DownloadDropdown'; import { toBase64 } from '@/app/shared/lib/bytes'; +import { HistoryFilterBar, useAfterSlotParam } from '@components/account/history/HistoryFilterBar'; + import { useInstructionNames } from '../lib/use-instruction-names'; import { InstructionList, InstructionListSkeleton } from './InstructionList'; export function TransactionHistoryCard({ address }: { address: string }) { const pubkey = useMemo(() => new PublicKey(address), [address]); + const afterSlot = useAfterSlotParam(); const history = useAccountHistory(address); - const fetchAccountHistory = useFetchAccountHistory(); - const refresh = () => fetchAccountHistory(pubkey, false, true); + const fetchAccountHistory = useFetchAccountHistory(25, afterSlot); + const clearHistories = useClearAccountHistories(); + const refresh = useCallback(() => fetchAccountHistory(pubkey, false, true), [fetchAccountHistory, pubkey]); const loadMore = () => fetchAccountHistory(pubkey, false); const transactionRows = React.useMemo(() => { @@ -40,6 +44,18 @@ export function TransactionHistoryCard({ address }: { address: string }) { } }, [address]); // eslint-disable-line react-hooks/exhaustive-deps + // Refetch from scratch when the afterSlot filter changes. The cache is keyed by + // address only, so we drop the cached entries before refetching to avoid mixing + // pre- and post-filter results in combineFetched. + const previousAfterSlot = React.useRef(afterSlot); + React.useEffect(() => { + if (previousAfterSlot.current !== afterSlot) { + previousAfterSlot.current = afterSlot; + clearHistories(); + refresh(); + } + }, [afterSlot, clearHistories, refresh]); + if (!history) { return null; } @@ -75,6 +91,7 @@ export function TransactionHistoryCard({ address }: { address: string }) { refresh={() => refresh()} title="Transaction History" analyticsSection="transaction_history_header" + actions={} />
diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index e027ef955..2357c9697 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -167,6 +167,7 @@ async function fetchAccountHistory( options: { before?: TransactionSignature; limit: number; + afterSlot?: number; }, fetchTransactions?: boolean, additionalSignatures?: string[], @@ -182,7 +183,9 @@ async function fetchAccountHistory( let history; try { const connection = new Connection(url); - const fetched = await connection.getSignaturesForAddress(pubkey, options); + // Hydrant accepts `afterSlot` (and `beforeSlot`) on getSignaturesForAddress; + // web3.js 1.x spreads unknown options into the RPC call, so pass it through. + const fetched = await connection.getSignaturesForAddress(pubkey, options as Parameters[1]); history = { fetched, foundOldest: fetched.length < options.limit, @@ -223,6 +226,17 @@ async function fetchAccountHistory( }); } +export function useClearAccountHistories() { + const { url } = useCluster(); + const dispatch = React.useContext(DispatchContext); + if (!dispatch) { + throw new Error(`useClearAccountHistories must be used within a HistoryProvider`); + } + return React.useCallback(() => { + dispatch({ type: ActionType.Clear, url }); + }, [dispatch, url]); +} + export function useAccountHistories() { const context = React.useContext(StateContext); @@ -296,7 +310,7 @@ export function useFetchTransactionsForHistory() { ); } -export function useFetchAccountHistory(limit = 25) { +export function useFetchAccountHistory(limit = 25, afterSlot?: number) { const { cluster, url } = useCluster(); const state = React.useContext(StateContext); const dispatch = React.useContext(DispatchContext); @@ -323,17 +337,17 @@ export function useFetchAccountHistory(limit = 25) { pubkey, cluster, url, - { before: oldest, limit }, + { afterSlot, before: oldest, limit }, fetchTransactions, additionalSignatures, ), ).catch(e => Logger.error(e)); } else { fetchOnce(pubkey.toBase58(), inFlight, () => - fetchAccountHistory(dispatch, pubkey, cluster, url, { limit }, fetchTransactions), + fetchAccountHistory(dispatch, pubkey, cluster, url, { afterSlot, limit }, fetchTransactions), ).catch(e => Logger.error(e)); } }, - [limit, state, dispatch, cluster, url, inFlight], + [limit, afterSlot, state, dispatch, cluster, url, inFlight], ); } From 1b31cc00d8986a75297f9cbea9fa559f63e4bba8 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Fri, 24 Apr 2026 10:08:07 +0200 Subject: [PATCH 02/19] Add beforeSlot filter --- .../account/history/HistoryFilterBar.tsx | 183 ++++++++++++------ .../ui/TransactionHistoryCard.tsx | 24 +-- app/providers/accounts/history.tsx | 21 +- 3 files changed, 158 insertions(+), 70 deletions(-) diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx index ad45440da..a24d79f41 100644 --- a/app/components/account/history/HistoryFilterBar.tsx +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -8,28 +8,44 @@ import React from 'react'; import { Filter, X } from 'react-feather'; export const AFTER_SLOT_PARAM = 'afterSlot'; +export const BEFORE_SLOT_PARAM = 'beforeSlot'; -export function useAfterSlotParam(): number | undefined { - const searchParams = useSearchParams(); - const raw = searchParams?.get(AFTER_SLOT_PARAM); +export type SlotFilters = { + afterSlot: number | undefined; + beforeSlot: number | undefined; +}; + +function parseSlotParam(raw: string | null | undefined): number | undefined { if (!raw) return undefined; const parsed = Number(raw); return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : undefined; } -function useUpdateAfterSlot() { +export function useSlotFilters(): SlotFilters { + const searchParams = useSearchParams(); + return { + afterSlot: parseSlotParam(searchParams?.get(AFTER_SLOT_PARAM)), + beforeSlot: parseSlotParam(searchParams?.get(BEFORE_SLOT_PARAM)), + }; +} + +function useUpdateSlotFilters() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); return React.useCallback( - (next: number | undefined) => { + (next: Partial) => { const params = new URLSearchParams(searchParams?.toString() ?? ''); - if (next === undefined) { - params.delete(AFTER_SLOT_PARAM); - } else { - params.set(AFTER_SLOT_PARAM, String(next)); - } + const setParam = (key: string, value: number | undefined) => { + if (value === undefined) { + params.delete(key); + } else { + params.set(key, String(value)); + } + }; + if ('afterSlot' in next) setParam(AFTER_SLOT_PARAM, next.afterSlot); + if ('beforeSlot' in next) setParam(BEFORE_SLOT_PARAM, next.beforeSlot); const qs = params.toString(); router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }); }, @@ -37,78 +53,135 @@ function useUpdateAfterSlot() { ); } -export function HistoryFilterBar({ afterSlot }: { afterSlot: number | undefined }) { - const updateAfterSlot = useUpdateAfterSlot(); +function slotDraftToValue(raw: string): number | undefined | 'invalid' { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) return 'invalid'; + return Math.floor(parsed); +} + +function FilterChip({ label, value, onClear }: { label: string; value: number; onClear: () => void }) { + return ( + + + {label}: {value.toLocaleString()} + + + + ); +} + +export function HistoryFilterBar({ afterSlot, beforeSlot }: SlotFilters) { + const updateFilters = useUpdateSlotFilters(); const [open, setOpen] = React.useState(false); - const [draft, setDraft] = React.useState(afterSlot !== undefined ? String(afterSlot) : ''); + const [afterDraft, setAfterDraft] = React.useState(afterSlot !== undefined ? String(afterSlot) : ''); + const [beforeDraft, setBeforeDraft] = React.useState(beforeSlot !== undefined ? String(beforeSlot) : ''); React.useEffect(() => { - setDraft(afterSlot !== undefined ? String(afterSlot) : ''); - }, [afterSlot]); + setAfterDraft(afterSlot !== undefined ? String(afterSlot) : ''); + setBeforeDraft(beforeSlot !== undefined ? String(beforeSlot) : ''); + }, [afterSlot, beforeSlot, open]); + + const afterValue = slotDraftToValue(afterDraft); + const beforeValue = slotDraftToValue(beforeDraft); + const afterInvalid = afterValue === 'invalid'; + const beforeInvalid = beforeValue === 'invalid'; + const rangeInvalid = + typeof afterValue === 'number' && typeof beforeValue === 'number' && afterValue > beforeValue; + const hasError = afterInvalid || beforeInvalid || rangeInvalid; const apply = () => { - const trimmed = draft.trim(); - if (!trimmed) { - updateAfterSlot(undefined); - } else { - const parsed = Number(trimmed); - if (Number.isFinite(parsed) && parsed >= 0) { - updateAfterSlot(Math.floor(parsed)); - } - } + if (hasError) return; + updateFilters({ + afterSlot: afterValue === 'invalid' ? undefined : afterValue, + beforeSlot: beforeValue === 'invalid' ? undefined : beforeValue, + }); + setOpen(false); + }; + + const clearAll = () => { + updateFilters({ afterSlot: undefined, beforeSlot: undefined }); setOpen(false); }; - const clear = () => updateAfterSlot(undefined); + const activeCount = (afterSlot !== undefined ? 1 : 0) + (beforeSlot !== undefined ? 1 : 0); + const triggerLabel = activeCount === 0 ? 'Filter' : 'Edit filter'; return ( -
+
{afterSlot !== undefined && ( - - After slot: {afterSlot.toLocaleString()} - - + updateFilters({ afterSlot: undefined })} + /> + )} + {beforeSlot !== undefined && ( + updateFilters({ beforeSlot: undefined })} + /> )} - +
{ e.preventDefault(); apply(); }} - className="e-flex e-flex-col e-gap-2" + className="e-flex e-flex-col e-gap-3" > - - setDraft(e.target.value)} - /> +
+ + setAfterDraft(e.target.value)} + /> +
+
+ + setBeforeDraft(e.target.value)} + /> +
+ {rangeInvalid && ( +
After slot must be ≤ before slot.
+ )}
- {afterSlot !== undefined && ( - )} -
diff --git a/app/features/transaction-history/ui/TransactionHistoryCard.tsx b/app/features/transaction-history/ui/TransactionHistoryCard.tsx index 7f296044e..e5f305893 100644 --- a/app/features/transaction-history/ui/TransactionHistoryCard.tsx +++ b/app/features/transaction-history/ui/TransactionHistoryCard.tsx @@ -17,16 +17,17 @@ import { useFetchRawTransaction, useRawTransactionDetails } from '@/app/provider import { DownloadDropdown } from '@/app/shared/components/DownloadDropdown'; import { toBase64 } from '@/app/shared/lib/bytes'; -import { HistoryFilterBar, useAfterSlotParam } from '@components/account/history/HistoryFilterBar'; +import { HistoryFilterBar, useSlotFilters } from '@components/account/history/HistoryFilterBar'; import { useInstructionNames } from '../lib/use-instruction-names'; import { InstructionList, InstructionListSkeleton } from './InstructionList'; export function TransactionHistoryCard({ address }: { address: string }) { const pubkey = useMemo(() => new PublicKey(address), [address]); - const afterSlot = useAfterSlotParam(); + const slotFilters = useSlotFilters(); + const { afterSlot, beforeSlot } = slotFilters; const history = useAccountHistory(address); - const fetchAccountHistory = useFetchAccountHistory(25, afterSlot); + const fetchAccountHistory = useFetchAccountHistory(25, slotFilters); const clearHistories = useClearAccountHistories(); const refresh = useCallback(() => fetchAccountHistory(pubkey, false, true), [fetchAccountHistory, pubkey]); const loadMore = () => fetchAccountHistory(pubkey, false); @@ -44,17 +45,18 @@ export function TransactionHistoryCard({ address }: { address: string }) { } }, [address]); // eslint-disable-line react-hooks/exhaustive-deps - // Refetch from scratch when the afterSlot filter changes. The cache is keyed by - // address only, so we drop the cached entries before refetching to avoid mixing - // pre- and post-filter results in combineFetched. - const previousAfterSlot = React.useRef(afterSlot); + // Refetch from scratch when a slot filter changes. The cache is keyed by address + // only, so we drop the cached entries before refetching to avoid mixing pre- and + // post-filter results in combineFetched. + const previousFilters = React.useRef({ afterSlot, beforeSlot }); React.useEffect(() => { - if (previousAfterSlot.current !== afterSlot) { - previousAfterSlot.current = afterSlot; + const prev = previousFilters.current; + if (prev.afterSlot !== afterSlot || prev.beforeSlot !== beforeSlot) { + previousFilters.current = { afterSlot, beforeSlot }; clearHistories(); refresh(); } - }, [afterSlot, clearHistories, refresh]); + }, [afterSlot, beforeSlot, clearHistories, refresh]); if (!history) { return null; @@ -91,7 +93,7 @@ export function TransactionHistoryCard({ address }: { address: string }) { refresh={() => refresh()} title="Transaction History" analyticsSection="transaction_history_header" - actions={} + actions={} />
diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index 2357c9697..de31b0fda 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -168,6 +168,7 @@ async function fetchAccountHistory( before?: TransactionSignature; limit: number; afterSlot?: number; + beforeSlot?: number; }, fetchTransactions?: boolean, additionalSignatures?: string[], @@ -310,7 +311,10 @@ export function useFetchTransactionsForHistory() { ); } -export function useFetchAccountHistory(limit = 25, afterSlot?: number) { +export function useFetchAccountHistory( + limit = 25, + slotFilters: { afterSlot?: number; beforeSlot?: number } = {}, +) { const { cluster, url } = useCluster(); const state = React.useContext(StateContext); const dispatch = React.useContext(DispatchContext); @@ -319,6 +323,8 @@ export function useFetchAccountHistory(limit = 25, afterSlot?: number) { throw new Error(`useFetchAccountHistory must be used within a HistoryProvider`); } + const { afterSlot, beforeSlot } = slotFilters; + return React.useCallback( (pubkey: PublicKey, fetchTransactions?: boolean, refresh?: boolean) => { const before = state.entries[pubkey.toBase58()]; @@ -337,17 +343,24 @@ export function useFetchAccountHistory(limit = 25, afterSlot?: number) { pubkey, cluster, url, - { afterSlot, before: oldest, limit }, + { afterSlot, before: oldest, beforeSlot, limit }, fetchTransactions, additionalSignatures, ), ).catch(e => Logger.error(e)); } else { fetchOnce(pubkey.toBase58(), inFlight, () => - fetchAccountHistory(dispatch, pubkey, cluster, url, { afterSlot, limit }, fetchTransactions), + fetchAccountHistory( + dispatch, + pubkey, + cluster, + url, + { afterSlot, beforeSlot, limit }, + fetchTransactions, + ), ).catch(e => Logger.error(e)); } }, - [limit, afterSlot, state, dispatch, cluster, url, inFlight], + [limit, afterSlot, beforeSlot, state, dispatch, cluster, url, inFlight], ); } From a604e7d67358368ce5318155e71348e52cdf8f76 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Fri, 24 Apr 2026 10:36:35 +0200 Subject: [PATCH 03/19] Add history provider tests --- .../accounts/__tests__/history.spec.tsx | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 app/providers/accounts/__tests__/history.spec.tsx diff --git a/app/providers/accounts/__tests__/history.spec.tsx b/app/providers/accounts/__tests__/history.spec.tsx new file mode 100644 index 000000000..31bf3bf2b --- /dev/null +++ b/app/providers/accounts/__tests__/history.spec.tsx @@ -0,0 +1,114 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@providers/cluster', () => ({ + useCluster: vi.fn(() => ({ + cluster: 0, + url: 'https://mock.rpc', + })), +})); + +vi.mock('@solana/web3.js', async () => { + const actual = await vi.importActual('@solana/web3.js'); + return { + ...actual, + Connection: vi.fn(), + PublicKey: actual.PublicKey, + }; +}); + +const mockConnection = { + getSignaturesForAddress: vi.fn(), +}; + +// Must import after mocks +import { HistoryProvider, useAccountHistory, useFetchAccountHistory } from '../history'; + +const ADDRESS = 'rexav5eNTUSNT1K2N7cfRjnthwhcP5BC25v2tA4rW4h'; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +function sig(signature: string, slot: number) { + return { blockTime: null, err: null, memo: null, signature, slot }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(Connection).mockImplementation(() => mockConnection as unknown as Connection); + mockConnection.getSignaturesForAddress.mockResolvedValue([]); +}); + +describe('useFetchAccountHistory — slot filters', () => { + it('passes afterSlot and beforeSlot to getSignaturesForAddress on the initial fetch', async () => { + const { result } = renderHook( + () => useFetchAccountHistory(25, { afterSlot: 100, beforeSlot: 500 }), + { wrapper }, + ); + + await act(async () => { + result.current(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(mockConnection.getSignaturesForAddress).toHaveBeenCalled()); + + const [pubkey, options] = mockConnection.getSignaturesForAddress.mock.calls[0]; + expect(pubkey.toBase58()).toBe(ADDRESS); + expect(options).toMatchObject({ afterSlot: 100, beforeSlot: 500, limit: 25 }); + expect((options as { before?: string }).before).toBeUndefined(); + }); + + it('omits filter keys when not provided', async () => { + const { result } = renderHook(() => useFetchAccountHistory(25, {}), { wrapper }); + + await act(async () => { + result.current(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(mockConnection.getSignaturesForAddress).toHaveBeenCalled()); + const [, options] = mockConnection.getSignaturesForAddress.mock.calls[0]; + expect(options).toMatchObject({ limit: 25 }); + expect((options as { afterSlot?: number }).afterSlot).toBeUndefined(); + expect((options as { beforeSlot?: number }).beforeSlot).toBeUndefined(); + }); + + it('threads slot filters alongside the `before` cursor when loading more', async () => { + mockConnection.getSignaturesForAddress.mockResolvedValueOnce( + Array.from({ length: 25 }, (_, i) => sig(`sig${i}`, 1000 - i)), + ); + + // Render the fetch hook + a reader of the same cache so we can observe the first page. + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, { afterSlot: 100, beforeSlot: 2000 }), + history: useAccountHistory(ADDRESS), + }), + { wrapper }, + ); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.history?.data?.fetched?.length).toBe(25)); + + mockConnection.getSignaturesForAddress.mockClear(); + mockConnection.getSignaturesForAddress.mockResolvedValueOnce([]); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(mockConnection.getSignaturesForAddress).toHaveBeenCalled()); + const [, options] = mockConnection.getSignaturesForAddress.mock.calls[0]; + expect(options).toMatchObject({ + afterSlot: 100, + before: 'sig24', + beforeSlot: 2000, + limit: 25, + }); + }); +}); From 5057d1e8fb365dc31cafebbe7400b1dc4993b15d Mon Sep 17 00:00:00 2001 From: ANSEL Date: Fri, 24 Apr 2026 10:36:48 +0200 Subject: [PATCH 04/19] Add filters component tests --- .../__tests__/HistoryFilterBar.spec.tsx | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 app/components/account/history/__tests__/HistoryFilterBar.spec.tsx diff --git a/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx b/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx new file mode 100644 index 000000000..507864c78 --- /dev/null +++ b/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const replaceMock = vi.fn(); +let mockSearchString = ''; + +vi.mock('next/navigation', () => ({ + usePathname: () => '/address/testAddress', + useRouter: () => ({ replace: replaceMock }), + useSearchParams: () => new URLSearchParams(mockSearchString), +})); + +// Must import after mocks +import { HistoryFilterBar, useSlotFilters } from '../HistoryFilterBar'; + +function setSearch(search: string) { + mockSearchString = search; +} + +beforeEach(() => { + replaceMock.mockClear(); + setSearch(''); +}); + +describe('useSlotFilters', () => { + it('returns undefined for both when no params are set', () => { + setSearch(''); + const { result } = renderHook(() => useSlotFilters()); + expect(result.current).toEqual({ afterSlot: undefined, beforeSlot: undefined }); + }); + + it('parses both slot bounds from the URL', () => { + setSearch('afterSlot=100&beforeSlot=200'); + const { result } = renderHook(() => useSlotFilters()); + expect(result.current).toEqual({ afterSlot: 100, beforeSlot: 200 }); + }); + + it('ignores negative and non-numeric values', () => { + setSearch('afterSlot=-5&beforeSlot=abc'); + const { result } = renderHook(() => useSlotFilters()); + expect(result.current).toEqual({ afterSlot: undefined, beforeSlot: undefined }); + }); + + it('floors fractional values', () => { + setSearch('afterSlot=100.9'); + const { result } = renderHook(() => useSlotFilters()); + expect(result.current.afterSlot).toBe(100); + }); +}); + +describe('HistoryFilterBar', () => { + it('shows a single Filter button when no filter is active', () => { + render(); + expect(screen.getByRole('button', { name: /^Filter$/ })).toBeInTheDocument(); + expect(screen.queryByText(/After slot:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Before slot:/)).not.toBeInTheDocument(); + }); + + it('renders a chip per active filter', () => { + render(); + expect(screen.getByText(/After slot:\s*100/)).toBeInTheDocument(); + // Use a regex since jsdom's toLocaleString thousand separator varies by ICU build. + expect(screen.getByText(/Before slot:\s*2[,.\s  ]?000[,.\s  ]?000/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Edit filter/ })).toBeInTheDocument(); + }); + + it('clears a single filter when its chip × is clicked', () => { + setSearch('afterSlot=100&beforeSlot=200'); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Clear after slot filter/ })); + + expect(replaceMock).toHaveBeenCalledTimes(1); + const [href] = replaceMock.mock.calls[0]; + expect(href).toBe('/address/testAddress?beforeSlot=200'); + }); + + it('applies both bounds from the popover', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /^Filter$/ })); + fireEvent.change(screen.getByPlaceholderText(/lower bound/), { target: { value: '100' } }); + fireEvent.change(screen.getByPlaceholderText(/upper bound/), { target: { value: '500' } }); + fireEvent.click(screen.getByRole('button', { name: /^Apply$/ })); + + expect(replaceMock).toHaveBeenCalledTimes(1); + const [href] = replaceMock.mock.calls[0]; + expect(href).toMatch(/afterSlot=100/); + expect(href).toMatch(/beforeSlot=500/); + }); + + it('preserves unrelated query params when updating the URL', () => { + setSearch('cluster=devnet&afterSlot=100'); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Clear after slot filter/ })); + + const [href] = replaceMock.mock.calls[0]; + expect(href).toBe('/address/testAddress?cluster=devnet'); + }); + + it('disables Apply and shows an error when after > before', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /^Filter$/ })); + fireEvent.change(screen.getByPlaceholderText(/lower bound/), { target: { value: '500' } }); + fireEvent.change(screen.getByPlaceholderText(/upper bound/), { target: { value: '100' } }); + + const apply = screen.getByRole('button', { name: /^Apply$/ }); + expect(apply).toBeDisabled(); + expect(screen.getByText(/After slot must be/)).toBeInTheDocument(); + + fireEvent.click(apply); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it('clears all filters via Clear all', () => { + setSearch('afterSlot=100&beforeSlot=500'); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Edit filter/ })); + fireEvent.click(screen.getByRole('button', { name: /Clear all/ })); + + const [href] = replaceMock.mock.calls[0]; + expect(href).toBe('/address/testAddress'); + }); +}); From 6cb93afa0de4a4a1479e3ec4da4742786e837829 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Fri, 24 Apr 2026 10:54:37 +0200 Subject: [PATCH 05/19] Fix responsive --- .../account/HistoryCardComponents.tsx | 17 +- .../account/history/HistoryFilterBar.tsx | 159 ++++++++++-------- .../ui/TransactionHistoryCard.tsx | 9 +- 3 files changed, 107 insertions(+), 78 deletions(-) diff --git a/app/components/account/HistoryCardComponents.tsx b/app/components/account/HistoryCardComponents.tsx index 756d8c4d7..f79ed77f3 100644 --- a/app/components/account/HistoryCardComponents.tsx +++ b/app/components/account/HistoryCardComponents.tsx @@ -18,19 +18,26 @@ export function HistoryCardHeader({ refresh, fetching, actions, + subHeader, }: { title: string; analyticsSection: string; refresh: () => void; fetching: boolean; actions?: React.ReactNode; + subHeader?: React.ReactNode; }) { return ( -
-

{title}

-
- {actions} - +
+
+
+

{title}

+
+ {actions} + +
+
+ {subHeader &&
{subHeader}
}
); diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx index a24d79f41..fbc403d21 100644 --- a/app/components/account/history/HistoryFilterBar.tsx +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -80,7 +80,30 @@ function FilterChip({ label, value, onClear }: { label: string; value: number; o ); } -export function HistoryFilterBar({ afterSlot, beforeSlot }: SlotFilters) { +export function HistoryFilterChips({ afterSlot, beforeSlot }: SlotFilters) { + const updateFilters = useUpdateSlotFilters(); + if (afterSlot === undefined && beforeSlot === undefined) return null; + return ( + <> + {afterSlot !== undefined && ( + updateFilters({ afterSlot: undefined })} + /> + )} + {beforeSlot !== undefined && ( + updateFilters({ beforeSlot: undefined })} + /> + )} + + ); +} + +export function HistoryFilterTrigger({ afterSlot, beforeSlot }: SlotFilters) { const updateFilters = useUpdateSlotFilters(); const [open, setOpen] = React.useState(false); const [afterDraft, setAfterDraft] = React.useState(afterSlot !== undefined ? String(afterSlot) : ''); @@ -117,77 +140,71 @@ export function HistoryFilterBar({ afterSlot, beforeSlot }: SlotFilters) { const triggerLabel = activeCount === 0 ? 'Filter' : 'Edit filter'; return ( -
- {afterSlot !== undefined && ( - updateFilters({ afterSlot: undefined })} - /> - )} - {beforeSlot !== undefined && ( - updateFilters({ beforeSlot: undefined })} - /> - )} - - - - - - { - e.preventDefault(); - apply(); - }} - className="e-flex e-flex-col e-gap-3" - > -
- - setAfterDraft(e.target.value)} - /> -
-
- - setBeforeDraft(e.target.value)} - /> -
- {rangeInvalid && ( -
After slot must be ≤ before slot.
- )} -
- {activeCount > 0 && ( - - )} - + + + { + e.preventDefault(); + apply(); + }} + className="e-flex e-flex-col e-gap-3" + > +
+ + setAfterDraft(e.target.value)} + /> +
+
+ + setBeforeDraft(e.target.value)} + /> +
+ {rangeInvalid && ( +
After slot must be ≤ before slot.
+ )} +
+ {activeCount > 0 && ( + -
- -
- + )} + +
+ +
+
+ ); +} + +// Combined bar kept for tests / any consumer that wants chips + trigger inline. +export function HistoryFilterBar(props: SlotFilters) { + return ( +
+ +
); } diff --git a/app/features/transaction-history/ui/TransactionHistoryCard.tsx b/app/features/transaction-history/ui/TransactionHistoryCard.tsx index e5f305893..29df17ab0 100644 --- a/app/features/transaction-history/ui/TransactionHistoryCard.tsx +++ b/app/features/transaction-history/ui/TransactionHistoryCard.tsx @@ -17,7 +17,7 @@ import { useFetchRawTransaction, useRawTransactionDetails } from '@/app/provider import { DownloadDropdown } from '@/app/shared/components/DownloadDropdown'; import { toBase64 } from '@/app/shared/lib/bytes'; -import { HistoryFilterBar, useSlotFilters } from '@components/account/history/HistoryFilterBar'; +import { HistoryFilterChips, HistoryFilterTrigger, useSlotFilters } from '@components/account/history/HistoryFilterBar'; import { useInstructionNames } from '../lib/use-instruction-names'; import { InstructionList, InstructionListSkeleton } from './InstructionList'; @@ -93,7 +93,12 @@ export function TransactionHistoryCard({ address }: { address: string }) { refresh={() => refresh()} title="Transaction History" analyticsSection="transaction_history_header" - actions={} + actions={} + subHeader={ + afterSlot !== undefined || beforeSlot !== undefined ? ( + + ) : undefined + } />
From a4521e2d8b4d3089ff4c04624a22f5f287c3b800 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Fri, 24 Apr 2026 11:18:22 +0200 Subject: [PATCH 06/19] Simplify slots filters guard --- app/components/account/history/HistoryFilterBar.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx index fbc403d21..670be2b08 100644 --- a/app/components/account/history/HistoryFilterBar.tsx +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -124,10 +124,7 @@ export function HistoryFilterTrigger({ afterSlot, beforeSlot }: SlotFilters) { const apply = () => { if (hasError) return; - updateFilters({ - afterSlot: afterValue === 'invalid' ? undefined : afterValue, - beforeSlot: beforeValue === 'invalid' ? undefined : beforeValue, - }); + updateFilters({ afterSlot: afterValue, beforeSlot: beforeValue }); setOpen(false); }; @@ -137,7 +134,7 @@ export function HistoryFilterTrigger({ afterSlot, beforeSlot }: SlotFilters) { }; const activeCount = (afterSlot !== undefined ? 1 : 0) + (beforeSlot !== undefined ? 1 : 0); - const triggerLabel = activeCount === 0 ? 'Filter' : 'Edit filter'; + const triggerLabel = activeCount === 0 ? 'Filters' : 'Edit filters'; return ( From 14765639ff7a3e5356b03772e4e54ffd8699c213 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Fri, 24 Apr 2026 11:18:38 +0200 Subject: [PATCH 07/19] Fix tests naming --- .../history/__tests__/HistoryFilterBar.spec.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx b/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx index 507864c78..823338937 100644 --- a/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx +++ b/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx @@ -49,9 +49,9 @@ describe('useSlotFilters', () => { }); describe('HistoryFilterBar', () => { - it('shows a single Filter button when no filter is active', () => { + it('shows a single Filters button when no filter is active', () => { render(); - expect(screen.getByRole('button', { name: /^Filter$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Filters$/ })).toBeInTheDocument(); expect(screen.queryByText(/After slot:/)).not.toBeInTheDocument(); expect(screen.queryByText(/Before slot:/)).not.toBeInTheDocument(); }); @@ -61,7 +61,7 @@ describe('HistoryFilterBar', () => { expect(screen.getByText(/After slot:\s*100/)).toBeInTheDocument(); // Use a regex since jsdom's toLocaleString thousand separator varies by ICU build. expect(screen.getByText(/Before slot:\s*2[,.\s  ]?000[,.\s  ]?000/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Edit filter/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Edit filters/ })).toBeInTheDocument(); }); it('clears a single filter when its chip × is clicked', () => { @@ -78,7 +78,7 @@ describe('HistoryFilterBar', () => { it('applies both bounds from the popover', () => { render(); - fireEvent.click(screen.getByRole('button', { name: /^Filter$/ })); + fireEvent.click(screen.getByRole('button', { name: /^Filters$/ })); fireEvent.change(screen.getByPlaceholderText(/lower bound/), { target: { value: '100' } }); fireEvent.change(screen.getByPlaceholderText(/upper bound/), { target: { value: '500' } }); fireEvent.click(screen.getByRole('button', { name: /^Apply$/ })); @@ -102,7 +102,7 @@ describe('HistoryFilterBar', () => { it('disables Apply and shows an error when after > before', () => { render(); - fireEvent.click(screen.getByRole('button', { name: /^Filter$/ })); + fireEvent.click(screen.getByRole('button', { name: /^Filters$/ })); fireEvent.change(screen.getByPlaceholderText(/lower bound/), { target: { value: '500' } }); fireEvent.change(screen.getByPlaceholderText(/upper bound/), { target: { value: '100' } }); @@ -118,7 +118,7 @@ describe('HistoryFilterBar', () => { setSearch('afterSlot=100&beforeSlot=500'); render(); - fireEvent.click(screen.getByRole('button', { name: /Edit filter/ })); + fireEvent.click(screen.getByRole('button', { name: /Edit filters/ })); fireEvent.click(screen.getByRole('button', { name: /Clear all/ })); const [href] = replaceMock.mock.calls[0]; From 46493db7bd9b27582cae901f5b0c9ae5c156fb8d Mon Sep 17 00:00:00 2001 From: ANSEL Date: Fri, 24 Apr 2026 14:26:40 +0200 Subject: [PATCH 08/19] Finalize responsive beahvior to be in sync with the refrehs btn --- app/components/account/history/HistoryFilterBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx index 670be2b08..ec0b0c7f6 100644 --- a/app/components/account/history/HistoryFilterBar.tsx +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -139,9 +139,9 @@ export function HistoryFilterTrigger({ afterSlot, beforeSlot }: SlotFilters) { return ( - From 3a063f18925a267ffd4238c2401f458f5f45c07f Mon Sep 17 00:00:00 2001 From: ANSEL Date: Tue, 26 May 2026 12:13:10 +0200 Subject: [PATCH 09/19] Switch to most up to date gTFA method --- .../account/history/HistoryFilterBar.tsx | 269 +++++++++++++++--- .../__tests__/HistoryFilterBar.spec.tsx | 114 ++++++-- .../ui/TransactionHistoryCard.tsx | 34 ++- .../accounts/__tests__/history.spec.tsx | 142 ++++++--- app/providers/accounts/history.tsx | 182 +++++++++--- 5 files changed, 574 insertions(+), 167 deletions(-) diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx index ec0b0c7f6..68a0d58e1 100644 --- a/app/components/account/history/HistoryFilterBar.tsx +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -3,16 +3,24 @@ import { Button } from '@components/shared/ui/button'; import { Input } from '@components/shared/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@components/shared/ui/popover'; +import { HistoryFilters } from '@providers/accounts/history'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import React from 'react'; import { Filter, X } from 'react-feather'; -export const AFTER_SLOT_PARAM = 'afterSlot'; +export const UNTIL_SLOT_PARAM = 'untilSlot'; export const BEFORE_SLOT_PARAM = 'beforeSlot'; +export const STATUS_PARAM = 'status'; +export const BLOCK_TIME_FROM_PARAM = 'blockTimeFrom'; +export const BLOCK_TIME_TO_PARAM = 'blockTimeTo'; +export const TOKEN_ACCOUNTS_PARAM = 'tokenAccounts'; -export type SlotFilters = { - afterSlot: number | undefined; - beforeSlot: number | undefined; +const STATUS_VALUES = ['succeeded', 'failed'] as const; +const TOKEN_ACCOUNTS_VALUES = ['all', 'balanceChanged'] as const; + +const TOKEN_ACCOUNTS_LABELS: Record<(typeof TOKEN_ACCOUNTS_VALUES)[number], string> = { + all: 'All token accounts', + balanceChanged: 'Balance changed', }; function parseSlotParam(raw: string | null | undefined): number | undefined { @@ -21,31 +29,48 @@ function parseSlotParam(raw: string | null | undefined): number | undefined { return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : undefined; } -export function useSlotFilters(): SlotFilters { +function parseEnumParam(raw: string | null | undefined, allowed: readonly T[]): T | undefined { + return raw && (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined; +} + +export function useHistoryFilters(): HistoryFilters { const searchParams = useSearchParams(); return { - afterSlot: parseSlotParam(searchParams?.get(AFTER_SLOT_PARAM)), beforeSlot: parseSlotParam(searchParams?.get(BEFORE_SLOT_PARAM)), + blockTimeFrom: parseSlotParam(searchParams?.get(BLOCK_TIME_FROM_PARAM)), + blockTimeTo: parseSlotParam(searchParams?.get(BLOCK_TIME_TO_PARAM)), + status: parseEnumParam(searchParams?.get(STATUS_PARAM), STATUS_VALUES), + tokenAccounts: parseEnumParam(searchParams?.get(TOKEN_ACCOUNTS_PARAM), TOKEN_ACCOUNTS_VALUES), + untilSlot: parseSlotParam(searchParams?.get(UNTIL_SLOT_PARAM)), }; } -function useUpdateSlotFilters() { +const PARAM_BY_KEY: Record = { + beforeSlot: BEFORE_SLOT_PARAM, + blockTimeFrom: BLOCK_TIME_FROM_PARAM, + blockTimeTo: BLOCK_TIME_TO_PARAM, + status: STATUS_PARAM, + tokenAccounts: TOKEN_ACCOUNTS_PARAM, + untilSlot: UNTIL_SLOT_PARAM, +}; + +function useUpdateHistoryFilters() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); return React.useCallback( - (next: Partial) => { + (next: Partial) => { const params = new URLSearchParams(searchParams?.toString() ?? ''); - const setParam = (key: string, value: number | undefined) => { + (Object.keys(next) as (keyof HistoryFilters)[]).forEach(key => { + const value = next[key]; + const param = PARAM_BY_KEY[key]; if (value === undefined) { - params.delete(key); + params.delete(param); } else { - params.set(key, String(value)); + params.set(param, String(value)); } - }; - if ('afterSlot' in next) setParam(AFTER_SLOT_PARAM, next.afterSlot); - if ('beforeSlot' in next) setParam(BEFORE_SLOT_PARAM, next.beforeSlot); + }); const qs = params.toString(); router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }); }, @@ -61,11 +86,28 @@ function slotDraftToValue(raw: string): number | undefined | 'invalid' { return Math.floor(parsed); } -function FilterChip({ label, value, onClear }: { label: string; value: number; onClear: () => void }) { +// datetime-local <-> unix seconds (local time, matching what the input displays). +function unixToLocalInput(sec: number | undefined): string { + if (sec === undefined) return ''; + const d = new Date(sec * 1000); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad( + d.getMinutes(), + )}`; +} + +function localInputToUnix(raw: string): number | undefined { + if (!raw) return undefined; + const ms = new Date(raw).getTime(); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined; +} + +function FilterChip({ label, value, onClear }: { label: string; value: string; onClear: () => void }) { return ( - {label}: {value.toLocaleString()} + {label}: {value} - +
{ e.preventDefault(); @@ -153,16 +269,16 @@ export function HistoryFilterTrigger({ afterSlot, beforeSlot }: SlotFilters) { className="e-flex e-flex-col e-gap-3" >
- + setAfterDraft(e.target.value)} + value={untilDraft} + aria-invalid={untilInvalid || slotRangeInvalid} + onChange={e => setUntilDraft(e.target.value)} />
@@ -173,13 +289,72 @@ export function HistoryFilterTrigger({ afterSlot, beforeSlot }: SlotFilters) { pattern="[0-9]*" placeholder="upper bound (optional)" value={beforeDraft} - aria-invalid={beforeInvalid || rangeInvalid} + aria-invalid={beforeInvalid || slotRangeInvalid} onChange={e => setBeforeDraft(e.target.value)} />
- {rangeInvalid && ( -
After slot must be ≤ before slot.
+ {slotRangeInvalid && ( +
Until slot must be ≤ before slot.
+ )} + +
+ + +
+ +
+ + setFromDraft(e.target.value)} + /> +
+
+ + setToDraft(e.target.value)} + /> +
+ {timeRangeInvalid && ( +
From must be on or before To.
)} + +
+ + +
+
{activeCount > 0 && (
diff --git a/app/providers/accounts/__tests__/history.spec.tsx b/app/providers/accounts/__tests__/history.spec.tsx index 31bf3bf2b..e5deefe0f 100644 --- a/app/providers/accounts/__tests__/history.spec.tsx +++ b/app/providers/accounts/__tests__/history.spec.tsx @@ -19,33 +19,77 @@ vi.mock('@solana/web3.js', async () => { }; }); -const mockConnection = { - getSignaturesForAddress: vi.fn(), -}; - // Must import after mocks import { HistoryProvider, useAccountHistory, useFetchAccountHistory } from '../history'; const ADDRESS = 'rexav5eNTUSNT1K2N7cfRjnthwhcP5BC25v2tA4rW4h'; -function wrapper({ children }: { children: React.ReactNode }) { - return {children}; +function sig(signature: string, slot: number) { + return { blockTime: null, confirmationStatus: 'finalized', err: null, memo: null, signature, slot }; } -function sig(signature: string, slot: number) { - return { blockTime: null, err: null, memo: null, signature, slot }; +const fetchMock = vi.fn(); + +// Resolve the next fetch call with a getTransactionsForAddress result envelope. +function mockResult(data: ReturnType[], paginationToken: string | null) { + fetchMock.mockResolvedValueOnce({ + json: async () => ({ id: 1, jsonrpc: '2.0', result: { data, paginationToken } }), + }); +} + +// Parse the JSON body of the Nth fetch call into [address, options]. +function requestParams(call = 0): [string, Record] { + const body = JSON.parse(fetchMock.mock.calls[call][1].body); + expect(body.method).toBe('getTransactionsForAddress'); + return body.params; +} + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; } beforeEach(() => { vi.clearAllMocks(); - vi.mocked(Connection).mockImplementation(() => mockConnection as unknown as Connection); - mockConnection.getSignaturesForAddress.mockResolvedValue([]); + vi.mocked(Connection).mockImplementation(() => ({}) as unknown as Connection); + vi.stubGlobal('fetch', fetchMock); + // Fallback response; tests queue page-specific results with mockResult (once). + fetchMock.mockResolvedValue({ + json: async () => ({ id: 1, jsonrpc: '2.0', result: { data: [], paginationToken: null } }), + }); }); -describe('useFetchAccountHistory — slot filters', () => { - it('passes afterSlot and beforeSlot to getSignaturesForAddress on the initial fetch', async () => { +describe('useFetchAccountHistory — getTransactionsForAddress', () => { + it('maps slot filters onto the filters object on the initial fetch', async () => { + const { result } = renderHook(() => useFetchAccountHistory(25, { beforeSlot: 500, untilSlot: 100 }), { + wrapper, + }); + + await act(async () => { + result.current(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + const [address, options] = requestParams(); + expect(address).toBe(ADDRESS); + expect(options).toMatchObject({ + filters: { slot: { gte: 100, lte: 500 } }, + limit: 25, + paginationToken: null, + sortOrder: 'desc', + transactionDetails: 'signatures', + }); + }); + + it('maps status, block time, and token-account filters', async () => { const { result } = renderHook( - () => useFetchAccountHistory(25, { afterSlot: 100, beforeSlot: 500 }), + () => + useFetchAccountHistory(25, { + blockTimeFrom: 1_700_000_000, + blockTimeTo: 1_700_100_000, + status: 'failed', + tokenAccounts: 'balanceChanged', + }), { wrapper }, ); @@ -53,37 +97,37 @@ describe('useFetchAccountHistory — slot filters', () => { result.current(new PublicKey(ADDRESS)); }); - await waitFor(() => expect(mockConnection.getSignaturesForAddress).toHaveBeenCalled()); - - const [pubkey, options] = mockConnection.getSignaturesForAddress.mock.calls[0]; - expect(pubkey.toBase58()).toBe(ADDRESS); - expect(options).toMatchObject({ afterSlot: 100, beforeSlot: 500, limit: 25 }); - expect((options as { before?: string }).before).toBeUndefined(); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const [, options] = requestParams(); + expect(options.filters).toEqual({ + blockTime: { gte: 1_700_000_000, lte: 1_700_100_000 }, + status: 'failed', + tokenAccounts: 'balanceChanged', + }); }); - it('omits filter keys when not provided', async () => { + it('omits the filters key when no filter is provided', async () => { const { result } = renderHook(() => useFetchAccountHistory(25, {}), { wrapper }); await act(async () => { result.current(new PublicKey(ADDRESS)); }); - await waitFor(() => expect(mockConnection.getSignaturesForAddress).toHaveBeenCalled()); - const [, options] = mockConnection.getSignaturesForAddress.mock.calls[0]; - expect(options).toMatchObject({ limit: 25 }); - expect((options as { afterSlot?: number }).afterSlot).toBeUndefined(); - expect((options as { beforeSlot?: number }).beforeSlot).toBeUndefined(); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const [, options] = requestParams(); + expect(options).toMatchObject({ limit: 25, paginationToken: null }); + expect('filters' in options).toBe(false); }); - it('threads slot filters alongside the `before` cursor when loading more', async () => { - mockConnection.getSignaturesForAddress.mockResolvedValueOnce( + it('threads the paginationToken from the previous page when loading more', async () => { + mockResult( Array.from({ length: 25 }, (_, i) => sig(`sig${i}`, 1000 - i)), + 'token-page-2', ); - // Render the fetch hook + a reader of the same cache so we can observe the first page. const { result } = renderHook( () => ({ - fetch: useFetchAccountHistory(25, { afterSlot: 100, beforeSlot: 2000 }), + fetch: useFetchAccountHistory(25, { untilSlot: 100 }), history: useAccountHistory(ADDRESS), }), { wrapper }, @@ -95,20 +139,46 @@ describe('useFetchAccountHistory — slot filters', () => { await waitFor(() => expect(result.current.history?.data?.fetched?.length).toBe(25)); - mockConnection.getSignaturesForAddress.mockClear(); - mockConnection.getSignaturesForAddress.mockResolvedValueOnce([]); + fetchMock.mockClear(); + mockResult([], null); await act(async () => { result.current.fetch(new PublicKey(ADDRESS)); }); - await waitFor(() => expect(mockConnection.getSignaturesForAddress).toHaveBeenCalled()); - const [, options] = mockConnection.getSignaturesForAddress.mock.calls[0]; + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const [, options] = requestParams(); expect(options).toMatchObject({ - afterSlot: 100, - before: 'sig24', - beforeSlot: 2000, + filters: { slot: { gte: 100 } }, limit: 25, + paginationToken: 'token-page-2', + }); + }); + + it('stops paginating once a page returns a null token', async () => { + mockResult([sig('only', 10)], null); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + history: useAccountHistory(ADDRESS), + }), + { wrapper }, + ); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.history?.data?.foundOldest).toBe(true)); + + fetchMock.mockClear(); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); }); + + // foundOldest short-circuits the load-more, so no further request is made. + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index de31b0fda..75c714244 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -8,7 +8,6 @@ import { Connection, ParsedTransactionWithMeta, PublicKey, - TransactionSignature, } from '@solana/web3.js'; import { Cluster } from '@utils/cluster'; import { fetchAll } from '@utils/fetch-all'; @@ -22,18 +21,32 @@ import { Logger } from '@/app/shared/lib/logger'; type TransactionMap = Map; type FailedTransactionSignatures = Set; +// Filters surfaced in the UI, mapped onto the Triton `getTransactionsForAddress` +// `filters` object inside `buildRpcFilters`. +export type HistoryFilters = { + untilSlot?: number; // lower slot bound -> filters.slot.gte + beforeSlot?: number; // upper slot bound -> filters.slot.lte + status?: 'succeeded' | 'failed'; // filters.status (omit for "any") + blockTimeFrom?: number; // unix seconds, lower bound -> filters.blockTime.gte + blockTimeTo?: number; // unix seconds, upper bound -> filters.blockTime.lte + tokenAccounts?: 'all' | 'balanceChanged'; // filters.tokenAccounts (omit for "none") +}; + type AccountHistory = { fetched: ConfirmedSignatureInfo[]; transactionMap?: TransactionMap; failedTransactionSignatures?: FailedTransactionSignatures; foundOldest: boolean; + // Opaque cursor returned by the RPC; threaded back to load the next page. + paginationToken?: string | null; }; type HistoryUpdate = { history?: AccountHistory; transactionMap?: TransactionMap; failedTransactionSignatures?: FailedTransactionSignatures; - before?: TransactionSignature; + // true when this page extends the tail (Load More); false on a refresh. + append?: boolean; }; type State = Cache.State; @@ -42,25 +55,23 @@ type Dispatch = Cache.Dispatch; function combineFetched( fetched: ConfirmedSignatureInfo[], current: ConfirmedSignatureInfo[] | undefined, - before: TransactionSignature | undefined, -) { + append: boolean, +): { combined: ConfirmedSignatureInfo[]; replaced: boolean } { if (current === undefined || current.length === 0) { - return fetched; + return { combined: fetched, replaced: true }; } - // History was refreshed, fetch results should be prepended if contiguous - if (before === undefined) { - const end = fetched.findIndex(f => f.signature === current[0].signature); - if (end < 0) return fetched; - return fetched.slice(0, end).concat(current); + // More history was loaded: append, dropping any signatures we already hold. + if (append) { + const seen = new Set(current.map(c => c.signature)); + return { combined: current.concat(fetched.filter(f => !seen.has(f.signature))), replaced: false }; } - // More history was loaded, fetch results should be appended - if (current[current.length - 1].signature === before) { - return current.concat(fetched); - } - - return fetched; + // History was refreshed: prepend the newly-seen prefix if the page overlaps + // what we already have, otherwise treat it as a full replacement. + const end = fetched.findIndex(f => f.signature === current[0].signature); + if (end < 0) return { combined: fetched, replaced: true }; + return { combined: fetched.slice(0, end).concat(current), replaced: false }; } function mergeFailedTransactionSignatures( @@ -88,16 +99,24 @@ function reconcile(history: AccountHistory | undefined, update: HistoryUpdate | return history; } + const append = update.append ?? false; + const { combined, replaced } = combineFetched(update.history.fetched, history?.fetched, append); + + // The tail cursor only changes when we extended the tail (append) or replaced + // the whole list; a refresh that merely prepends new items keeps the old tail. + const tailFromUpdate = append || replaced; + const transactionMap = mergeTransactionMap(history?.transactionMap, update.transactionMap); const failedTransactionSignatures = mergeFailedTransactionSignatures( - update.before === undefined ? undefined : history?.failedTransactionSignatures, + append ? history?.failedTransactionSignatures : undefined, update.failedTransactionSignatures, ); return { failedTransactionSignatures, - fetched: combineFetched(update.history.fetched, history?.fetched, update?.before), - foundOldest: update?.history?.foundOldest || history?.foundOldest || false, + fetched: combined, + foundOldest: tailFromUpdate ? update.history.foundOldest : (history?.foundOldest ?? false), + paginationToken: tailFromUpdate ? update.history.paginationToken : history?.paginationToken, transactionMap, }; } @@ -159,16 +178,77 @@ async function fetchParsedTransactions(url: string, cluster: Cluster, transactio return { failedTransactionSignatures, transactionMap }; } +// Maps the UI filter selection onto the Triton `getTransactionsForAddress` +// `filters` object. Returns undefined when no filter is active so the key is omitted. +function buildRpcFilters(filters: HistoryFilters): Record | undefined { + const out: Record = {}; + + const slot: Record = {}; + if (filters.untilSlot !== undefined) slot.gte = filters.untilSlot; + if (filters.beforeSlot !== undefined) slot.lte = filters.beforeSlot; + if (Object.keys(slot).length > 0) out.slot = slot; + + const blockTime: Record = {}; + if (filters.blockTimeFrom !== undefined) blockTime.gte = filters.blockTimeFrom; + if (filters.blockTimeTo !== undefined) blockTime.lte = filters.blockTimeTo; + if (Object.keys(blockTime).length > 0) out.blockTime = blockTime; + + if (filters.status) out.status = filters.status; + if (filters.tokenAccounts) out.tokenAccounts = filters.tokenAccounts; + + return Object.keys(out).length > 0 ? out : undefined; +} + +type RpcHistoryItem = ConfirmedSignatureInfo & { transactionIndex?: number }; +type GetTransactionsForAddressResult = { + data: RpcHistoryItem[]; + paginationToken: string | null; +}; + +// Calls the Triton `getTransactionsForAddress` method directly (it is not part of +// web3.js). `signatures` detail level keeps the response shape compatible with the +// existing `ConfirmedSignatureInfo`-based table. +async function getTransactionsForAddress( + url: string, + address: string, + options: { limit: number; paginationToken?: string | null; filters: HistoryFilters }, +): Promise { + const params: Record = { + limit: options.limit, + paginationToken: options.paginationToken ?? null, + sortOrder: 'desc', + transactionDetails: 'signatures', + }; + const filters = buildRpcFilters(options.filters); + if (filters) params.filters = filters; + + const response = await fetch(url, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'getTransactionsForAddress', + params: [address, params], + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + const json = await response.json(); + if (json.error) { + throw new Error(json.error.message ?? 'getTransactionsForAddress failed'); + } + return json.result as GetTransactionsForAddressResult; +} + async function fetchAccountHistory( dispatch: Dispatch, pubkey: PublicKey, cluster: Cluster, url: string, options: { - before?: TransactionSignature; limit: number; - afterSlot?: number; - beforeSlot?: number; + paginationToken?: string | null; + filters: HistoryFilters; + append: boolean; }, fetchTransactions?: boolean, additionalSignatures?: string[], @@ -183,13 +263,16 @@ async function fetchAccountHistory( let status; let history; try { - const connection = new Connection(url); - // Hydrant accepts `afterSlot` (and `beforeSlot`) on getSignaturesForAddress; - // web3.js 1.x spreads unknown options into the RPC call, so pass it through. - const fetched = await connection.getSignaturesForAddress(pubkey, options as Parameters[1]); + const result = await getTransactionsForAddress(url, pubkey.toBase58(), { + filters: options.filters, + limit: options.limit, + paginationToken: options.paginationToken, + }); history = { - fetched, - foundOldest: fetched.length < options.limit, + fetched: result.data, + // A null/absent cursor or a short page means there is nothing more to load. + foundOldest: !result.paginationToken || result.data.length < options.limit, + paginationToken: result.paginationToken, }; status = FetchStatus.Fetched; } catch (error) { @@ -215,7 +298,7 @@ async function fetchAccountHistory( dispatch({ data: { - before: options?.before, + append: options.append, failedTransactionSignatures, history, transactionMap, @@ -311,10 +394,7 @@ export function useFetchTransactionsForHistory() { ); } -export function useFetchAccountHistory( - limit = 25, - slotFilters: { afterSlot?: number; beforeSlot?: number } = {}, -) { +export function useFetchAccountHistory(limit = 25, filters: HistoryFilters = {}) { const { cluster, url } = useCluster(); const state = React.useContext(StateContext); const dispatch = React.useContext(DispatchContext); @@ -323,10 +403,19 @@ export function useFetchAccountHistory( throw new Error(`useFetchAccountHistory must be used within a HistoryProvider`); } - const { afterSlot, beforeSlot } = slotFilters; + // Destructure into primitives so the callback identity tracks filter changes. + const { untilSlot, beforeSlot, status, blockTimeFrom, blockTimeTo, tokenAccounts } = filters; return React.useCallback( (pubkey: PublicKey, fetchTransactions?: boolean, refresh?: boolean) => { + const activeFilters: HistoryFilters = { + beforeSlot, + blockTimeFrom, + blockTimeTo, + status, + tokenAccounts, + untilSlot, + }; const before = state.entries[pubkey.toBase58()]; if (!refresh && before?.data?.fetched && before.data.fetched.length > 0) { if (before.data.foundOldest) return; @@ -336,14 +425,18 @@ export function useFetchAccountHistory( additionalSignatures = getUnfetchedSignatures(before); } - const oldest = before.data.fetched[before.data.fetched.length - 1].signature; fetchOnce(pubkey.toBase58(), inFlight, () => fetchAccountHistory( dispatch, pubkey, cluster, url, - { afterSlot, before: oldest, beforeSlot, limit }, + { + append: true, + filters: activeFilters, + limit, + paginationToken: before.data?.paginationToken, + }, fetchTransactions, additionalSignatures, ), @@ -355,12 +448,25 @@ export function useFetchAccountHistory( pubkey, cluster, url, - { afterSlot, beforeSlot, limit }, + { append: false, filters: activeFilters, limit }, fetchTransactions, ), ).catch(e => Logger.error(e)); } }, - [limit, afterSlot, beforeSlot, state, dispatch, cluster, url, inFlight], + [ + limit, + untilSlot, + beforeSlot, + status, + blockTimeFrom, + blockTimeTo, + tokenAccounts, + state, + dispatch, + cluster, + url, + inFlight, + ], ); } From 34cc73372c4111bce150a4fd707b0969654b0986 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Tue, 26 May 2026 13:37:32 +0200 Subject: [PATCH 10/19] Remove token account filter to simplify filters --- .../account/history/HistoryFilterBar.tsx | 232 ++++++++---------- .../__tests__/HistoryFilterBar.spec.tsx | 102 ++++---- .../ui/TransactionHistoryCard.tsx | 18 +- .../accounts/__tests__/history.spec.tsx | 11 +- app/providers/accounts/history.tsx | 65 +++-- 5 files changed, 187 insertions(+), 241 deletions(-) diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx index 68a0d58e1..d6bc41091 100644 --- a/app/components/account/history/HistoryFilterBar.tsx +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -8,19 +8,18 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import React from 'react'; import { Filter, X } from 'react-feather'; -export const UNTIL_SLOT_PARAM = 'untilSlot'; -export const BEFORE_SLOT_PARAM = 'beforeSlot'; +// URL params map one-to-one onto the Triton `getTransactionsForAddress` filter paths. +export const SLOT_GTE_PARAM = 'slot.gte'; +export const SLOT_LTE_PARAM = 'slot.lte'; +export const BLOCK_TIME_GTE_PARAM = 'blockTime.gte'; +export const BLOCK_TIME_LTE_PARAM = 'blockTime.lte'; export const STATUS_PARAM = 'status'; -export const BLOCK_TIME_FROM_PARAM = 'blockTimeFrom'; -export const BLOCK_TIME_TO_PARAM = 'blockTimeTo'; -export const TOKEN_ACCOUNTS_PARAM = 'tokenAccounts'; const STATUS_VALUES = ['succeeded', 'failed'] as const; -const TOKEN_ACCOUNTS_VALUES = ['all', 'balanceChanged'] as const; -const TOKEN_ACCOUNTS_LABELS: Record<(typeof TOKEN_ACCOUNTS_VALUES)[number], string> = { - all: 'All token accounts', - balanceChanged: 'Balance changed', +const STATUS_LABELS: Record<(typeof STATUS_VALUES)[number], string> = { + failed: 'Failed', + succeeded: 'Succeeded', }; function parseSlotParam(raw: string | null | undefined): number | undefined { @@ -33,26 +32,29 @@ function parseEnumParam(raw: string | null | undefined, allowe return raw && (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined; } +// Collapses an undefined-only range back to `undefined` so consumers can treat a +// present range object as "this filter is active". +function toRange(gte: number | undefined, lte: number | undefined) { + return gte === undefined && lte === undefined ? undefined : { gte, lte }; +} + export function useHistoryFilters(): HistoryFilters { const searchParams = useSearchParams(); return { - beforeSlot: parseSlotParam(searchParams?.get(BEFORE_SLOT_PARAM)), - blockTimeFrom: parseSlotParam(searchParams?.get(BLOCK_TIME_FROM_PARAM)), - blockTimeTo: parseSlotParam(searchParams?.get(BLOCK_TIME_TO_PARAM)), + blockTime: toRange( + parseSlotParam(searchParams?.get(BLOCK_TIME_GTE_PARAM)), + parseSlotParam(searchParams?.get(BLOCK_TIME_LTE_PARAM)), + ), + slot: toRange( + parseSlotParam(searchParams?.get(SLOT_GTE_PARAM)), + parseSlotParam(searchParams?.get(SLOT_LTE_PARAM)), + ), status: parseEnumParam(searchParams?.get(STATUS_PARAM), STATUS_VALUES), - tokenAccounts: parseEnumParam(searchParams?.get(TOKEN_ACCOUNTS_PARAM), TOKEN_ACCOUNTS_VALUES), - untilSlot: parseSlotParam(searchParams?.get(UNTIL_SLOT_PARAM)), }; } -const PARAM_BY_KEY: Record = { - beforeSlot: BEFORE_SLOT_PARAM, - blockTimeFrom: BLOCK_TIME_FROM_PARAM, - blockTimeTo: BLOCK_TIME_TO_PARAM, - status: STATUS_PARAM, - tokenAccounts: TOKEN_ACCOUNTS_PARAM, - untilSlot: UNTIL_SLOT_PARAM, -}; +// Update operates directly on URL param names so callers reference the same gTFA paths. +type ParamUpdate = Record; function useUpdateHistoryFilters() { const router = useRouter(); @@ -60,15 +62,13 @@ function useUpdateHistoryFilters() { const searchParams = useSearchParams(); return React.useCallback( - (next: Partial) => { + (next: ParamUpdate) => { const params = new URLSearchParams(searchParams?.toString() ?? ''); - (Object.keys(next) as (keyof HistoryFilters)[]).forEach(key => { - const value = next[key]; - const param = PARAM_BY_KEY[key]; + Object.entries(next).forEach(([key, value]) => { if (value === undefined) { - params.delete(param); + params.delete(key); } else { - params.set(param, String(value)); + params.set(key, String(value)); } }); const qs = params.toString(); @@ -123,58 +123,45 @@ function FilterChip({ label, value, onClear }: { label: string; value: string; o } export function HistoryFilterChips(filters: HistoryFilters) { - const { untilSlot, beforeSlot, status, blockTimeFrom, blockTimeTo, tokenAccounts } = filters; + const { slot, blockTime, status } = filters; const updateFilters = useUpdateHistoryFilters(); - const hasAny = - untilSlot !== undefined || - beforeSlot !== undefined || - status !== undefined || - blockTimeFrom !== undefined || - blockTimeTo !== undefined || - tokenAccounts !== undefined; + const hasAny = slot !== undefined || blockTime !== undefined || status !== undefined; if (!hasAny) return null; return ( <> - {untilSlot !== undefined && ( + {slot?.gte !== undefined && ( updateFilters({ untilSlot: undefined })} + label="Slot ≥" + value={slot.gte.toLocaleString()} + onClear={() => updateFilters({ [SLOT_GTE_PARAM]: undefined })} /> )} - {beforeSlot !== undefined && ( + {slot?.lte !== undefined && ( updateFilters({ beforeSlot: undefined })} + label="Slot ≤" + value={slot.lte.toLocaleString()} + onClear={() => updateFilters({ [SLOT_LTE_PARAM]: undefined })} /> )} {status !== undefined && ( updateFilters({ status: undefined })} - /> - )} - {blockTimeFrom !== undefined && ( - updateFilters({ blockTimeFrom: undefined })} + value={STATUS_LABELS[status]} + onClear={() => updateFilters({ [STATUS_PARAM]: undefined })} /> )} - {blockTimeTo !== undefined && ( + {blockTime?.gte !== undefined && ( updateFilters({ blockTimeTo: undefined })} + label="Block time ≥" + value={new Date(blockTime.gte * 1000).toLocaleString()} + onClear={() => updateFilters({ [BLOCK_TIME_GTE_PARAM]: undefined })} /> )} - {tokenAccounts !== undefined && ( + {blockTime?.lte !== undefined && ( updateFilters({ tokenAccounts: undefined })} + label="Block time ≤" + value={new Date(blockTime.lte * 1000).toLocaleString()} + onClear={() => updateFilters({ [BLOCK_TIME_LTE_PARAM]: undefined })} /> )} @@ -185,71 +172,67 @@ const SELECT_CLASS = 'e-w-full e-rounded-md e-border e-border-neutral-700 e-bg-neutral-900 e-px-2 e-py-1.5 e-text-sm e-text-neutral-100'; export function HistoryFilterTrigger(filters: HistoryFilters) { - const { untilSlot, beforeSlot, status, blockTimeFrom, blockTimeTo, tokenAccounts } = filters; + const { slot, blockTime, status } = filters; const updateFilters = useUpdateHistoryFilters(); const [open, setOpen] = React.useState(false); - const [untilDraft, setUntilDraft] = React.useState(''); - const [beforeDraft, setBeforeDraft] = React.useState(''); + const [slotGteDraft, setSlotGteDraft] = React.useState(''); + const [slotLteDraft, setSlotLteDraft] = React.useState(''); const [statusDraft, setStatusDraft] = React.useState(''); - const [fromDraft, setFromDraft] = React.useState(''); - const [toDraft, setToDraft] = React.useState(''); - const [tokenAccountsDraft, setTokenAccountsDraft] = React.useState(''); + const [blockTimeGteDraft, setBlockTimeGteDraft] = React.useState(''); + const [blockTimeLteDraft, setBlockTimeLteDraft] = React.useState(''); React.useEffect(() => { - setUntilDraft(untilSlot !== undefined ? String(untilSlot) : ''); - setBeforeDraft(beforeSlot !== undefined ? String(beforeSlot) : ''); + setSlotGteDraft(slot?.gte !== undefined ? String(slot.gte) : ''); + setSlotLteDraft(slot?.lte !== undefined ? String(slot.lte) : ''); setStatusDraft(status ?? ''); - setFromDraft(unixToLocalInput(blockTimeFrom)); - setToDraft(unixToLocalInput(blockTimeTo)); - setTokenAccountsDraft(tokenAccounts ?? ''); - }, [untilSlot, beforeSlot, status, blockTimeFrom, blockTimeTo, tokenAccounts, open]); + setBlockTimeGteDraft(unixToLocalInput(blockTime?.gte)); + setBlockTimeLteDraft(unixToLocalInput(blockTime?.lte)); + }, [slot, blockTime, status, open]); - const untilValue = slotDraftToValue(untilDraft); - const beforeValue = slotDraftToValue(beforeDraft); - const fromValue = localInputToUnix(fromDraft); - const toValue = localInputToUnix(toDraft); + const slotGteValue = slotDraftToValue(slotGteDraft); + const slotLteValue = slotDraftToValue(slotLteDraft); + const blockTimeGteValue = localInputToUnix(blockTimeGteDraft); + const blockTimeLteValue = localInputToUnix(blockTimeLteDraft); - const untilInvalid = untilValue === 'invalid'; - const beforeInvalid = beforeValue === 'invalid'; + const slotGteInvalid = slotGteValue === 'invalid'; + const slotLteInvalid = slotLteValue === 'invalid'; const slotRangeInvalid = - typeof untilValue === 'number' && typeof beforeValue === 'number' && untilValue > beforeValue; - const timeRangeInvalid = fromValue !== undefined && toValue !== undefined && fromValue > toValue; - const hasError = untilInvalid || beforeInvalid || slotRangeInvalid || timeRangeInvalid; + typeof slotGteValue === 'number' && typeof slotLteValue === 'number' && slotGteValue > slotLteValue; + const timeRangeInvalid = + blockTimeGteValue !== undefined && blockTimeLteValue !== undefined && blockTimeGteValue > blockTimeLteValue; + const hasError = slotGteInvalid || slotLteInvalid || slotRangeInvalid || timeRangeInvalid; const apply = () => { if (hasError) return; // The hasError guard above rules out the 'invalid' sentinel for both slots. updateFilters({ - beforeSlot: beforeValue as number | undefined, - blockTimeFrom: fromValue, - blockTimeTo: toValue, - status: parseEnumParam(statusDraft, STATUS_VALUES), - tokenAccounts: parseEnumParam(tokenAccountsDraft, TOKEN_ACCOUNTS_VALUES), - untilSlot: untilValue as number | undefined, + [BLOCK_TIME_GTE_PARAM]: blockTimeGteValue, + [BLOCK_TIME_LTE_PARAM]: blockTimeLteValue, + [SLOT_GTE_PARAM]: slotGteValue as number | undefined, + [SLOT_LTE_PARAM]: slotLteValue as number | undefined, + [STATUS_PARAM]: parseEnumParam(statusDraft, STATUS_VALUES), }); setOpen(false); }; const clearAll = () => { updateFilters({ - beforeSlot: undefined, - blockTimeFrom: undefined, - blockTimeTo: undefined, - status: undefined, - tokenAccounts: undefined, - untilSlot: undefined, + [BLOCK_TIME_GTE_PARAM]: undefined, + [BLOCK_TIME_LTE_PARAM]: undefined, + [SLOT_GTE_PARAM]: undefined, + [SLOT_LTE_PARAM]: undefined, + [STATUS_PARAM]: undefined, }); setOpen(false); }; const activeCount = - (untilSlot !== undefined ? 1 : 0) + - (beforeSlot !== undefined ? 1 : 0) + + (slot?.gte !== undefined ? 1 : 0) + + (slot?.lte !== undefined ? 1 : 0) + (status !== undefined ? 1 : 0) + - (blockTimeFrom !== undefined ? 1 : 0) + - (blockTimeTo !== undefined ? 1 : 0) + - (tokenAccounts !== undefined ? 1 : 0); + (blockTime?.gte !== undefined ? 1 : 0) + + (blockTime?.lte !== undefined ? 1 : 0); const triggerLabel = activeCount === 0 ? 'Filters' : 'Edit filters'; return ( @@ -269,33 +252,31 @@ export function HistoryFilterTrigger(filters: HistoryFilters) { className="e-flex e-flex-col e-gap-3" >
- + setUntilDraft(e.target.value)} + value={slotGteDraft} + aria-invalid={slotGteInvalid || slotRangeInvalid} + onChange={e => setSlotGteDraft(e.target.value)} />
- + setBeforeDraft(e.target.value)} + value={slotLteDraft} + aria-invalid={slotLteInvalid || slotRangeInvalid} + onChange={e => setSlotLteDraft(e.target.value)} />
- {slotRangeInvalid && ( -
Until slot must be ≤ before slot.
- )} + {slotRangeInvalid &&
Slot ≥ must be ≤ slot ≤.
}
- + setFromDraft(e.target.value)} + onChange={e => setBlockTimeGteDraft(e.target.value)} />
- + setToDraft(e.target.value)} + onChange={e => setBlockTimeLteDraft(e.target.value)} />
{timeRangeInvalid && ( -
From must be on or before To.
+
Block time ≥ must be on or before block time ≤.
)} -
- - -
-
{activeCount > 0 && ( + ); + } + return ( diff --git a/app/features/transaction-history/ui/TransactionHistoryCard.tsx b/app/features/transaction-history/ui/TransactionHistoryCard.tsx index c3ed8c018..cf193c00f 100644 --- a/app/features/transaction-history/ui/TransactionHistoryCard.tsx +++ b/app/features/transaction-history/ui/TransactionHistoryCard.tsx @@ -6,7 +6,12 @@ import { ErrorCard } from '@components/common/ErrorCard'; import { LoadingCard } from '@components/common/LoadingCard'; import { Signature } from '@components/common/Signature'; import { Slot } from '@components/common/Slot'; -import { useAccountHistory, useFetchAccountHistory, useResetAccountHistory } from '@providers/accounts/history'; +import { + useAccountHistory, + useFetchAccountHistory, + useHistoryFiltersSupported, + useResetAccountHistory, +} from '@providers/accounts/history'; import { FetchStatus } from '@providers/cache'; import { PublicKey } from '@solana/web3.js'; import { displayTimestampUtc } from '@utils/date'; @@ -17,7 +22,12 @@ import { useFetchRawTransaction, useRawTransactionDetails } from '@/app/provider import { DownloadDropdown } from '@/app/shared/components/DownloadDropdown'; import { toBase64 } from '@/app/shared/lib/bytes'; -import { HistoryFilterChips, HistoryFilterTrigger, useHistoryFilters } from '@components/account/history/HistoryFilterBar'; +import { + HistoryFilterChips, + HistoryFilterTrigger, + useClearHistoryFilters, + useHistoryFilters, +} from '@components/account/history/HistoryFilterBar'; import { useInstructionNames } from '../lib/use-instruction-names'; import { InstructionList, InstructionListSkeleton } from './InstructionList'; @@ -30,6 +40,8 @@ export function TransactionHistoryCard({ address }: { address: string }) { const history = useAccountHistory(address); const fetchAccountHistory = useFetchAccountHistory(25, filters); const resetHistory = useResetAccountHistory(); + const filtersSupported = useHistoryFiltersSupported(); + const clearFilters = useClearHistoryFilters(); const refresh = useCallback(() => fetchAccountHistory(pubkey, false, true), [fetchAccountHistory, pubkey]); const loadMore = () => fetchAccountHistory(pubkey, false); @@ -59,6 +71,14 @@ export function TransactionHistoryCard({ address }: { address: string }) { } }, [filtersKey, address, resetHistory, refresh]); + // If the endpoint turns out not to support filtering, drop any active filters so the + // (unfiltered) results aren't shown alongside misleading filter chips. + React.useEffect(() => { + if (!filtersSupported && hasActiveFilters) { + clearFilters(); + } + }, [filtersSupported, hasActiveFilters, clearFilters]); + if (!history) { return null; } diff --git a/app/providers/accounts/__tests__/history.spec.tsx b/app/providers/accounts/__tests__/history.spec.tsx index f0567280b..854e0a08a 100644 --- a/app/providers/accounts/__tests__/history.spec.tsx +++ b/app/providers/accounts/__tests__/history.spec.tsx @@ -24,7 +24,13 @@ vi.mock('@/app/shared/lib/logger', () => ({ Logger: { error: vi.fn() } })); // Must import after mocks import { FetchStatus } from '@providers/cache'; -import { HistoryProvider, useAccountHistory, useFetchAccountHistory, useResetAccountHistory } from '../history'; +import { + HistoryProvider, + useAccountHistory, + useFetchAccountHistory, + useHistoryFiltersSupported, + useResetAccountHistory, +} from '../history'; const ADDRESS = 'rexav5eNTUSNT1K2N7cfRjnthwhcP5BC25v2tA4rW4h'; const ADDRESS_B = '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d'; @@ -317,4 +323,26 @@ describe('getSignaturesForAddress fallback', () => { await waitFor(() => expect(result.current.history?.status).toBe(FetchStatus.FetchFailed)); expect(mockConnection.getSignaturesForAddress).not.toHaveBeenCalled(); }); + + it('marks filtering unsupported after a method-not-found, and stays supported otherwise', async () => { + mockRpcError(-32601, 'Method not found'); + mockConnection.getSignaturesForAddress.mockResolvedValueOnce([sig('legacy', 5)]); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + supported: useHistoryFiltersSupported(), + }), + { wrapper }, + ); + + // Optimistically supported until the first request reveals otherwise. + expect(result.current.supported).toBe(true); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.supported).toBe(false)); + }); }); diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index 6e05f54c3..8d8467b00 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -128,24 +128,39 @@ const InFlightContext = React.createContext | undefined>(undefined); // the freshly-cleared cache. See `useResetAccountHistory`. const GenerationContext = React.createContext | undefined>(undefined); +// Whether the current endpoint supports getTransactionsForAddress. Flips to false the +// first time the method is not found, so the UI can disable filtering (the +// getSignaturesForAddress fallback can't honour block-time/status filters). +type MethodSupport = { supported: boolean; markUnsupported: () => void }; +const MethodSupportContext = React.createContext(undefined); + type HistoryProviderProps = { children: React.ReactNode }; export function HistoryProvider({ children }: HistoryProviderProps) { const { url } = useCluster(); const [state, dispatch] = Cache.useCustomReducer(url, reconcile); const inFlightRef = React.useRef(new Set()); const generationRef = React.useRef(new Map()); + const [supported, setSupported] = React.useState(true); React.useEffect(() => { dispatch({ type: ActionType.Clear, url }); inFlightRef.current.clear(); generationRef.current.clear(); + setSupported(true); }, [dispatch, url]); + const markUnsupported = React.useCallback(() => setSupported(false), []); + const methodSupport = React.useMemo(() => ({ markUnsupported, supported }), [markUnsupported, supported]); + return ( - {children} + + + {children} + + @@ -303,6 +318,9 @@ async function fetchAccountHistory( // Returns false once this request has been superseded (e.g. by a filter change), // in which case its result is dropped rather than written into the cache. isCurrent: () => boolean = () => true, + // Called when the endpoint reports getTransactionsForAddress as unavailable, so the + // UI can disable filtering before the request falls back to getSignaturesForAddress. + onMethodNotFound?: () => void, ) { dispatch({ key: pubkey.toBase58(), @@ -328,8 +346,9 @@ async function fetchAccountHistory( status = FetchStatus.Fetched; } catch (error) { if (isMethodNotFound(error)) { - // Endpoint doesn't implement getTransactionsForAddress: fall back to the - // standard getSignaturesForAddress path. + // Endpoint doesn't implement getTransactionsForAddress: disable filtering + // and fall back to the standard getSignaturesForAddress path. + onMethodNotFound?.(); try { history = await fetchViaSignatures(url, pubkey, { before: options.before, @@ -426,6 +445,12 @@ export function useAccountHistories() { return context.entries; } +// Whether the current endpoint supports getTransactionsForAddress, and therefore +// filtering. Defaults to true outside a HistoryProvider (e.g. isolated component tests). +export function useHistoryFiltersSupported(): boolean { + return React.useContext(MethodSupportContext)?.supported ?? true; +} + export function useAccountHistory(address: string): Cache.CacheEntry | undefined { const context = React.useContext(StateContext); @@ -495,9 +520,11 @@ export function useFetchAccountHistory(limit = 25, filters: HistoryFilters = {}) const dispatch = React.useContext(DispatchContext); const inFlight = React.useContext(InFlightContext); const generations = React.useContext(GenerationContext); - if (!state || !dispatch || !inFlight || !generations) { + const methodSupport = React.useContext(MethodSupportContext); + if (!state || !dispatch || !inFlight || !generations || !methodSupport) { throw new Error(`useFetchAccountHistory must be used within a HistoryProvider`); } + const { markUnsupported } = methodSupport; // Destructure into primitives so the callback identity tracks filter changes. const { slot, blockTime, status } = filters; @@ -547,6 +574,7 @@ export function useFetchAccountHistory(limit = 25, filters: HistoryFilters = {}) fetchTransactions, additionalSignatures, isCurrent, + markUnsupported, ), ).catch(e => Logger.error(e)); } else { @@ -560,6 +588,7 @@ export function useFetchAccountHistory(limit = 25, filters: HistoryFilters = {}) fetchTransactions, undefined, isCurrent, + markUnsupported, ), ).catch(e => Logger.error(e)); } @@ -577,6 +606,7 @@ export function useFetchAccountHistory(limit = 25, filters: HistoryFilters = {}) url, inFlight, generations, + markUnsupported, ], ); } From cc9e86559ee967cdb8d93aa4645d215bd541faaa Mon Sep 17 00:00:00 2001 From: ANSEL Date: Tue, 26 May 2026 14:57:30 +0200 Subject: [PATCH 13/19] Fix foundOldest limit bug --- .../accounts/__tests__/history.spec.tsx | 30 +++++++++++++++++++ app/providers/accounts/history.tsx | 5 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/providers/accounts/__tests__/history.spec.tsx b/app/providers/accounts/__tests__/history.spec.tsx index 854e0a08a..149b8fd24 100644 --- a/app/providers/accounts/__tests__/history.spec.tsx +++ b/app/providers/accounts/__tests__/history.spec.tsx @@ -180,6 +180,36 @@ describe('useFetchAccountHistory — getTransactionsForAddress', () => { }); }); + it('keeps loading more on a short page that still carries a token', async () => { + // Fewer items than the limit, but a non-null token means more data exists. + mockResult([sig('partial', 10)], 'token-page-2'); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + history: useAccountHistory(ADDRESS), + }), + { wrapper }, + ); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.history?.data?.fetched?.length).toBe(1)); + expect(result.current.history?.data?.foundOldest).toBe(false); + + // Load More should issue another request, threading the token forward. + fetchMock.mockClear(); + mockResult([], null); + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(requestParams()[1]).toMatchObject({ paginationToken: 'token-page-2' }); + }); + it('stops paginating once a page returns a null token', async () => { mockResult([sig('only', 10)], null); diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index 8d8467b00..a3a6499c9 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -339,8 +339,9 @@ async function fetchAccountHistory( }); history = { fetched: result.data, - // A null/absent cursor or a short page means there is nothing more to load. - foundOldest: !result.paginationToken || result.data.length < options.limit, + // A null/absent paginationToken is the canonical end-of-stream signal; a + // short page is not, since the server may still hand back a token for more. + foundOldest: !result.paginationToken, paginationToken: result.paginationToken, }; status = FetchStatus.Fetched; From 90c549cd7a6782bc2e475ae5c84553f392e1227e Mon Sep 17 00:00:00 2001 From: ANSEL Date: Wed, 27 May 2026 09:51:04 +0200 Subject: [PATCH 14/19] Fix lint and formatting under updated codestyle rules The rebase onto upstream master pulled in stricter rules and updated prettier/tailwind config (#1015, #1007): - Prefix test titles with 'should' (vitest/valid-title) - File-level eslint-disable for RegExp test assertions (no-restricted-syntax) - Return undefined instead of null in app/components (unicorn/no-null) - Re-sort the HistoryFilterBar import in TransactionHistoryCard (simple-import-sort) - Reflow imports/JSX and tailwind class order to satisfy prettier Co-Authored-By: Claude Opus 4.7 --- .../account/history/HistoryFilterBar.tsx | 4 +-- .../__tests__/HistoryFilterBar.spec.tsx | 33 ++++++++++--------- .../ui/TransactionHistoryCard.tsx | 13 ++++---- .../accounts/__tests__/history.spec.tsx | 22 ++++++------- app/providers/accounts/history.tsx | 11 ++----- 5 files changed, 38 insertions(+), 45 deletions(-) diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx index 727a4e2ab..b5b915fe9 100644 --- a/app/components/account/history/HistoryFilterBar.tsx +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -142,7 +142,7 @@ export function HistoryFilterChips(filters: HistoryFilters) { const { slot, blockTime, status } = filters; const updateFilters = useUpdateHistoryFilters(); const hasAny = slot !== undefined || blockTime !== undefined || status !== undefined; - if (!hasAny) return null; + if (!hasAny) return undefined; return ( <> {slot?.gte !== undefined && ( @@ -277,7 +277,7 @@ export function HistoryFilterTrigger(filters: HistoryFilters) { {triggerLabel} - + { e.preventDefault(); diff --git a/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx b/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx index f278a4a90..1c9030b03 100644 --- a/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx +++ b/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax -- test assertions use RegExp for pattern matching */ import { fireEvent, render, renderHook, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -23,7 +24,7 @@ beforeEach(() => { }); describe('useHistoryFilters', () => { - it('returns all-undefined when no params are set', () => { + it('should return all-undefined when no params are set', () => { setSearch(''); const { result } = renderHook(() => useHistoryFilters()); expect(result.current).toEqual({ @@ -33,25 +34,25 @@ describe('useHistoryFilters', () => { }); }); - it('parses both slot bounds from the gTFA filter paths', () => { + it('should parse both slot bounds from the gTFA filter paths', () => { setSearch('slot.gte=100&slot.lte=200'); const { result } = renderHook(() => useHistoryFilters()); expect(result.current.slot).toEqual({ gte: 100, lte: 200 }); }); - it('ignores negative and non-numeric values', () => { + it('should ignore negative and non-numeric values', () => { setSearch('slot.gte=-5&slot.lte=abc'); const { result } = renderHook(() => useHistoryFilters()); expect(result.current.slot).toBeUndefined(); }); - it('floors fractional values', () => { + it('should floor fractional values', () => { setSearch('slot.gte=100.9'); const { result } = renderHook(() => useHistoryFilters()); expect(result.current.slot).toEqual({ gte: 100, lte: undefined }); }); - it('parses the status enum, rejecting unknown values', () => { + it('should parse the status enum, rejecting unknown values', () => { setSearch('status=failed'); const { result } = renderHook(() => useHistoryFilters()); expect(result.current).toMatchObject({ status: 'failed' }); @@ -61,7 +62,7 @@ describe('useHistoryFilters', () => { expect(rejected.current).toMatchObject({ status: undefined }); }); - it('parses block-time bounds from the gTFA filter paths', () => { + it('should parse block-time bounds from the gTFA filter paths', () => { setSearch('blockTime.gte=1700000000&blockTime.lte=1700100000'); const { result } = renderHook(() => useHistoryFilters()); expect(result.current.blockTime).toEqual({ gte: 1_700_000_000, lte: 1_700_100_000 }); @@ -69,14 +70,14 @@ describe('useHistoryFilters', () => { }); describe('HistoryFilterBar', () => { - it('shows a single Filters button when no filter is active', () => { + it('should show a single Filters button when no filter is active', () => { render(); expect(screen.getByRole('button', { name: /^Filters$/ })).toBeInTheDocument(); expect(screen.queryByText(/Slot ≥:/)).not.toBeInTheDocument(); expect(screen.queryByText(/Slot ≤:/)).not.toBeInTheDocument(); }); - it('renders a chip per active slot bound', () => { + it('should render a chip per active slot bound', () => { render(); expect(screen.getByText(/Slot ≥:\s*100/)).toBeInTheDocument(); // Use a regex since jsdom's toLocaleString thousand separator varies by ICU build. @@ -84,7 +85,7 @@ describe('HistoryFilterBar', () => { expect(screen.getByRole('button', { name: /Edit filters/ })).toBeInTheDocument(); }); - it('clears a single slot bound when its chip × is clicked', () => { + it('should clear a single slot bound when its chip × is clicked', () => { setSearch('slot.gte=100&slot.lte=200'); render(); @@ -95,7 +96,7 @@ describe('HistoryFilterBar', () => { expect(href).toBe('/address/testAddress?slot.lte=200'); }); - it('applies both slot bounds from the popover', () => { + it('should apply both slot bounds from the popover', () => { render(); fireEvent.click(screen.getByRole('button', { name: /^Filters$/ })); @@ -109,7 +110,7 @@ describe('HistoryFilterBar', () => { expect(href).toMatch(/slot\.lte=500/); }); - it('preserves unrelated query params when updating the URL', () => { + it('should preserve unrelated query params when updating the URL', () => { setSearch('cluster=devnet&slot.gte=100'); render(); @@ -119,7 +120,7 @@ describe('HistoryFilterBar', () => { expect(href).toBe('/address/testAddress?cluster=devnet'); }); - it('disables Apply and shows an error when slot ≥ exceeds slot ≤', () => { + it('should disable Apply and show an error when slot ≥ exceeds slot ≤', () => { render(); fireEvent.click(screen.getByRole('button', { name: /^Filters$/ })); @@ -134,7 +135,7 @@ describe('HistoryFilterBar', () => { expect(replaceMock).not.toHaveBeenCalled(); }); - it('applies the status select', () => { + it('should apply the status select', () => { render(); fireEvent.click(screen.getByRole('button', { name: /^Filters$/ })); @@ -145,12 +146,12 @@ describe('HistoryFilterBar', () => { expect(href).toMatch(/status=succeeded/); }); - it('renders a chip for the status filter', () => { + it('should render a chip for the status filter', () => { render(); expect(screen.getByText(/Status:\s*Failed/)).toBeInTheDocument(); }); - it('clears a single non-slot filter via its chip', () => { + it('should clear a single non-slot filter via its chip', () => { setSearch('status=failed&slot.lte=200'); render(); @@ -160,7 +161,7 @@ describe('HistoryFilterBar', () => { expect(href).toBe('/address/testAddress?slot.lte=200'); }); - it('clears all filters via Clear all', () => { + it('should clear all filters via Clear all', () => { setSearch('slot.gte=100&slot.lte=500'); render(); diff --git a/app/features/transaction-history/ui/TransactionHistoryCard.tsx b/app/features/transaction-history/ui/TransactionHistoryCard.tsx index cf193c00f..5d90a593f 100644 --- a/app/features/transaction-history/ui/TransactionHistoryCard.tsx +++ b/app/features/transaction-history/ui/TransactionHistoryCard.tsx @@ -1,5 +1,11 @@ 'use client'; +import { + HistoryFilterChips, + HistoryFilterTrigger, + useClearHistoryFilters, + useHistoryFilters, +} from '@components/account/history/HistoryFilterBar'; import { getTransactionRows, HistoryCardFooter, HistoryCardHeader } from '@components/account/HistoryCardComponents'; import { Copyable } from '@components/common/Copyable'; import { ErrorCard } from '@components/common/ErrorCard'; @@ -22,13 +28,6 @@ import { useFetchRawTransaction, useRawTransactionDetails } from '@/app/provider import { DownloadDropdown } from '@/app/shared/components/DownloadDropdown'; import { toBase64 } from '@/app/shared/lib/bytes'; -import { - HistoryFilterChips, - HistoryFilterTrigger, - useClearHistoryFilters, - useHistoryFilters, -} from '@components/account/history/HistoryFilterBar'; - import { useInstructionNames } from '../lib/use-instruction-names'; import { InstructionList, InstructionListSkeleton } from './InstructionList'; diff --git a/app/providers/accounts/__tests__/history.spec.tsx b/app/providers/accounts/__tests__/history.spec.tsx index 149b8fd24..a9ebce01a 100644 --- a/app/providers/accounts/__tests__/history.spec.tsx +++ b/app/providers/accounts/__tests__/history.spec.tsx @@ -87,7 +87,7 @@ beforeEach(() => { }); describe('useFetchAccountHistory — getTransactionsForAddress', () => { - it('maps slot filters onto the filters object on the initial fetch', async () => { + it('should map slot filters onto the filters object on the initial fetch', async () => { const { result } = renderHook(() => useFetchAccountHistory(25, { slot: { gte: 100, lte: 500 } }), { wrapper, }); @@ -109,7 +109,7 @@ describe('useFetchAccountHistory — getTransactionsForAddress', () => { }); }); - it('maps status and block time filters', async () => { + it('should map status and block time filters', async () => { const { result } = renderHook( () => useFetchAccountHistory(25, { @@ -131,7 +131,7 @@ describe('useFetchAccountHistory — getTransactionsForAddress', () => { }); }); - it('omits the filters key when no filter is provided', async () => { + it('should omit the filters key when no filter is provided', async () => { const { result } = renderHook(() => useFetchAccountHistory(25, {}), { wrapper }); await act(async () => { @@ -144,7 +144,7 @@ describe('useFetchAccountHistory — getTransactionsForAddress', () => { expect('filters' in options).toBe(false); }); - it('threads the paginationToken from the previous page when loading more', async () => { + it('should thread the paginationToken from the previous page when loading more', async () => { mockResult( Array.from({ length: 25 }, (_, i) => sig(`sig${i}`, 1000 - i)), 'token-page-2', @@ -180,7 +180,7 @@ describe('useFetchAccountHistory — getTransactionsForAddress', () => { }); }); - it('keeps loading more on a short page that still carries a token', async () => { + it('should keep loading more on a short page that still carries a token', async () => { // Fewer items than the limit, but a non-null token means more data exists. mockResult([sig('partial', 10)], 'token-page-2'); @@ -210,7 +210,7 @@ describe('useFetchAccountHistory — getTransactionsForAddress', () => { expect(requestParams()[1]).toMatchObject({ paginationToken: 'token-page-2' }); }); - it('stops paginating once a page returns a null token', async () => { + it('should stop paginating once a page returns a null token', async () => { mockResult([sig('only', 10)], null); const { result } = renderHook( @@ -239,7 +239,7 @@ describe('useFetchAccountHistory — getTransactionsForAddress', () => { }); describe('useResetAccountHistory', () => { - it('discards an in-flight response that resolves after a reset (no stale write)', async () => { + it('should discard an in-flight response that resolves after a reset (no stale write)', async () => { // First request is left pending to model a page-load still in flight. const pending = deferred>(); fetchMock.mockReturnValueOnce(pending.promise); @@ -278,7 +278,7 @@ describe('useResetAccountHistory', () => { expect(result.current.history?.data?.fetched[0].signature).toBe('filtered'); }); - it('clears only the target address, leaving other addresses intact', async () => { + it('should clear only the target address, leaving other addresses intact', async () => { const { result } = renderHook( () => ({ fetch: useFetchAccountHistory(25, {}), @@ -311,7 +311,7 @@ describe('useResetAccountHistory', () => { }); describe('getSignaturesForAddress fallback', () => { - it('falls back when getTransactionsForAddress is not found, mapping slot bounds', async () => { + it('should fall back when getTransactionsForAddress is not found, mapping slot bounds', async () => { mockRpcError(-32601, 'Method not found'); mockConnection.getSignaturesForAddress.mockResolvedValueOnce([sig('legacy', 5)]); @@ -335,7 +335,7 @@ describe('getSignaturesForAddress fallback', () => { expect(opts).toMatchObject({ beforeSlot: 99, limit: 25, untilSlot: 10 }); }); - it('does not fall back on a generic RPC error', async () => { + it('should not fall back on a generic RPC error', async () => { mockRpcError(-32000, 'boom'); const { result } = renderHook( @@ -354,7 +354,7 @@ describe('getSignaturesForAddress fallback', () => { expect(mockConnection.getSignaturesForAddress).not.toHaveBeenCalled(); }); - it('marks filtering unsupported after a method-not-found, and stays supported otherwise', async () => { + it('should mark filtering unsupported after a method-not-found, and stay supported otherwise', async () => { mockRpcError(-32601, 'Method not found'); mockConnection.getSignaturesForAddress.mockResolvedValueOnce([sig('legacy', 5)]); diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index a3a6499c9..00b618cf1 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -3,12 +3,7 @@ import * as Cache from '@providers/cache'; import { ActionType, FetchStatus } from '@providers/cache'; import { useCluster } from '@providers/cluster'; -import { - ConfirmedSignatureInfo, - Connection, - ParsedTransactionWithMeta, - PublicKey, -} from '@solana/web3.js'; +import { ConfirmedSignatureInfo, Connection, ParsedTransactionWithMeta, PublicKey } from '@solana/web3.js'; import { Cluster } from '@utils/cluster'; import { fetchAll } from '@utils/fetch-all'; import { fetchOnce } from '@utils/fetch-once'; @@ -157,9 +152,7 @@ export function HistoryProvider({ children }: HistoryProviderProps) { - - {children} - + {children} From 57269cf4e0d0b50d6ce5135e50808ffcaded3310 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Thu, 28 May 2026 10:22:18 +0200 Subject: [PATCH 15/19] Fix accounts history provider --- app/providers/accounts/history.tsx | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index a3304371d..2d2c4af17 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -253,14 +253,23 @@ async function getTransactionsForAddress( headers: { 'content-type': 'application/json' }, method: 'POST', }); - const json = await response.json(); - if (json.error) { + // Parse the body before consulting the HTTP status: standard RPC nodes return the + // JSON-RPC "method not found" error with HTTP 200, so checking response.ok first + // would mask the -32601 code that drives the getSignaturesForAddress fallback. + const json = await response.json().catch(() => null); + if (json?.error) { const error = new Error(json.error.message ?? 'getTransactionsForAddress failed') as Error & { code?: number; }; error.code = json.error.code; throw error; } + if (!response.ok) { + throw new Error(`getTransactionsForAddress HTTP ${response.status}`); + } + if (!json?.result) { + throw new Error('getTransactionsForAddress: malformed response'); + } return json.result as GetTransactionsForAddressResult; } @@ -269,6 +278,10 @@ async function getTransactionsForAddress( function isMethodNotFound(error: unknown): boolean { const e = error as { code?: number; message?: string }; if (e?.code === -32601) return true; + // A structured JSON-RPC code is authoritative: any other numeric code means this is + // not a method-not-found, so don't let a coincidental message substring (e.g. a proxy + // error page) downgrade the endpoint and permanently disable filtering for the session. + if (typeof e?.code === 'number') return false; const message = typeof e?.message === 'string' ? e.message.toLowerCase() : ''; return message.includes('method not found') || message.includes('unsupported method'); } @@ -402,17 +415,6 @@ async function fetchAccountHistory( }); } -export function useClearAccountHistories() { - const { url } = useCluster(); - const dispatch = React.useContext(DispatchContext); - if (!dispatch) { - throw new Error(`useClearAccountHistories must be used within a HistoryProvider`); - } - return React.useCallback(() => { - dispatch({ type: ActionType.Clear, url }); - }, [dispatch, url]); -} - // Resets a single address's history so the next fetch starts from a clean slate. // Bumps the address generation (so any in-flight request for it is discarded) and // evicts its in-flight marker (so the immediately-following refetch isn't deduped), From 5e246e5edc19017a2f20ab612287a5a2a4939ea9 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Thu, 28 May 2026 10:23:16 +0200 Subject: [PATCH 16/19] Fix tests for the updated history provider --- app/providers/accounts/__tests__/history.spec.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/providers/accounts/__tests__/history.spec.tsx b/app/providers/accounts/__tests__/history.spec.tsx index a9ebce01a..067980164 100644 --- a/app/providers/accounts/__tests__/history.spec.tsx +++ b/app/providers/accounts/__tests__/history.spec.tsx @@ -40,7 +40,7 @@ function sig(signature: string, slot: number) { } function envelope(data: ReturnType[], paginationToken: string | null) { - return { json: async () => ({ id: 1, jsonrpc: '2.0', result: { data, paginationToken } }) }; + return { json: async () => ({ id: 1, jsonrpc: '2.0', result: { data, paginationToken } }), ok: true, status: 200 }; } // A promise we resolve by hand, to model a request that is still in flight. @@ -62,7 +62,12 @@ function mockResult(data: ReturnType[], paginationToken: string | nu // Resolve the next fetch call with a JSON-RPC error (e.g. method-not-found). function mockRpcError(code: number, message: string) { - fetchMock.mockResolvedValueOnce({ json: async () => ({ error: { code, message }, id: 1, jsonrpc: '2.0' }) }); + // Standard RPC nodes return JSON-RPC errors (including method-not-found) with HTTP 200. + fetchMock.mockResolvedValueOnce({ + json: async () => ({ error: { code, message }, id: 1, jsonrpc: '2.0' }), + ok: true, + status: 200, + }); } // Parse the JSON body of the Nth fetch call into [address, options]. @@ -83,6 +88,8 @@ beforeEach(() => { // Fallback response; tests queue page-specific results with mockResult (once). fetchMock.mockResolvedValue({ json: async () => ({ id: 1, jsonrpc: '2.0', result: { data: [], paginationToken: null } }), + ok: true, + status: 200, }); }); From 14b6bd5fdbd762cb40377aa0cfb59245afeeba72 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Thu, 28 May 2026 10:52:56 +0200 Subject: [PATCH 17/19] Remove standard rpc unsupported untilSlot/beforeSlot filters in fallback --- app/providers/accounts/__tests__/history.spec.tsx | 6 +++--- app/providers/accounts/history.tsx | 13 +++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/providers/accounts/__tests__/history.spec.tsx b/app/providers/accounts/__tests__/history.spec.tsx index 067980164..e54048f6e 100644 --- a/app/providers/accounts/__tests__/history.spec.tsx +++ b/app/providers/accounts/__tests__/history.spec.tsx @@ -318,7 +318,7 @@ describe('useResetAccountHistory', () => { }); describe('getSignaturesForAddress fallback', () => { - it('should fall back when getTransactionsForAddress is not found, mapping slot bounds', async () => { + it('should fall back when getTransactionsForAddress is not found, applying no filters', async () => { mockRpcError(-32601, 'Method not found'); mockConnection.getSignaturesForAddress.mockResolvedValueOnce([sig('legacy', 5)]); @@ -338,8 +338,8 @@ describe('getSignaturesForAddress fallback', () => { expect(mockConnection.getSignaturesForAddress).toHaveBeenCalledTimes(1); const [pubkey, opts] = mockConnection.getSignaturesForAddress.mock.calls[0]; expect(pubkey.toBase58()).toBe(ADDRESS); - // Slot bounds are passed via the Hydrant untilSlot/beforeSlot extension. - expect(opts).toMatchObject({ beforeSlot: 99, limit: 25, untilSlot: 10 }); + // Standard RPCs support none of the filters, so slot bounds are not forwarded. + expect(opts).toEqual({ limit: 25 }); }); it('should not fall back on a generic RPC error', async () => { diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index 2d2c4af17..f5475be59 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -131,7 +131,7 @@ const GenerationContext = React.createContext | undefined>(u // Whether the current endpoint supports getTransactionsForAddress. Flips to false the // first time the method is not found, so the UI can disable filtering (the -// getSignaturesForAddress fallback can't honour block-time/status filters). +// getSignaturesForAddress fallback can't honour any of the filters). type MethodSupport = { supported: boolean; markUnsupported: () => void }; const MethodSupportContext = React.createContext(undefined); @@ -288,19 +288,17 @@ function isMethodNotFound(error: unknown): boolean { // Legacy fallback path for endpoints without getTransactionsForAddress. Uses // getSignaturesForAddress, whose pagination cursor is the trailing `before` signature -// rather than a paginationToken. Slot bounds are passed through as the Hydrant -// `untilSlot`/`beforeSlot` extension (a no-op on nodes that don't support it); block -// time and status filters are not applied on this path. +// rather than a paginationToken. Standard RPCs support none of the filters, so slot, +// block time and status are all left unapplied here — the caller marks filtering +// unsupported and clears them. async function fetchViaSignatures( url: string, pubkey: PublicKey, - options: { limit: number; before?: string; filters: HistoryFilters }, + options: { limit: number; before?: string }, ): Promise { const connection = new Connection(url); const rpcOptions: Record = { limit: options.limit }; if (options.before) rpcOptions.before = options.before; - if (options.filters.slot?.gte !== undefined) rpcOptions.untilSlot = options.filters.slot.gte; - if (options.filters.slot?.lte !== undefined) rpcOptions.beforeSlot = options.filters.slot.lte; const fetched = await connection.getSignaturesForAddress( pubkey, rpcOptions as Parameters[1], @@ -365,7 +363,6 @@ async function fetchAccountHistory( try { history = await fetchViaSignatures(url, pubkey, { before: options.before, - filters: options.filters, limit: options.limit, }); status = FetchStatus.Fetched; From 76eaec400997300cb8cccb917dc5362f22e32815 Mon Sep 17 00:00:00 2001 From: ANSEL Date: Thu, 28 May 2026 10:58:46 +0200 Subject: [PATCH 18/19] Fix popup calendar icon color --- app/components/shared/ui/input.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/shared/ui/input.tsx b/app/components/shared/ui/input.tsx index 713620b19..e5662b20e 100644 --- a/app/components/shared/ui/input.tsx +++ b/app/components/shared/ui/input.tsx @@ -9,6 +9,9 @@ const inputVariants = cva( 'e-font-normal e-font-mono', 'e-flex e-h-9 e-w-full e-rounded e-border', 'e-px-4 e-py-2.5 e-text-xs', + // Native date/time pickers render a dark calendar glyph that's invisible on + // our dark input backgrounds; invert it so it matches the light input text. + '[&::-webkit-calendar-picker-indicator]:e-invert', 'focus-visible:e-outline-none focus-visible:e-ring-2 focus-visible:e-ring-offset-2 focus-visible:e-ring-offset-neutral-900', 'disabled:e-cursor-not-allowed disabled:e-opacity-50', 'aria-[invalid="true"]:!e-border-destructive aria-[invalid="true"]:focus-visible:e-ring-destructive', From 40541e2448aa3c445508d21a47a16573a64877ec Mon Sep 17 00:00:00 2001 From: ANSEL Date: Mon, 29 Jun 2026 12:32:17 +0200 Subject: [PATCH 19/19] Fix FilterChip clear-button rendering as UA-chromed button The repo's global stylesheet reverts Tailwind Preflight's button reset, so unstyled