Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
34dc7fc
Add slot based filtering on /address/[addy] page
anselsol Apr 24, 2026
1b31cc0
Add beforeSlot filter
anselsol Apr 24, 2026
a604e7d
Add history provider tests
anselsol Apr 24, 2026
5057d1e
Add filters component tests
anselsol Apr 24, 2026
6cb93af
Fix responsive
anselsol Apr 24, 2026
a4521e2
Simplify slots filters guard
anselsol Apr 24, 2026
1476563
Fix tests naming
anselsol Apr 24, 2026
46493db
Finalize responsive beahvior to be in sync with the refrehs btn
anselsol Apr 24, 2026
3a063f1
Switch to most up to date gTFA method
anselsol May 26, 2026
34cc733
Remove token account filter to simplify filters
anselsol May 26, 2026
daa9c05
Fix greptile feedbacks
anselsol May 26, 2026
1f13fc3
Add fallback for non triton and non helius rpcs
anselsol May 26, 2026
cc9e865
Fix foundOldest limit bug
anselsol May 26, 2026
90c549c
Fix lint and formatting under updated codestyle rules
anselsol May 27, 2026
cd2fc20
Merge remote-tracking branch 'upstream/master' into feat/add-slot-fil…
anselsol May 27, 2026
57269cf
Fix accounts history provider
anselsol May 28, 2026
5e246e5
Fix tests for the updated history provider
anselsol May 28, 2026
14b6bd5
Remove standard rpc unsupported untilSlot/beforeSlot filters in fallback
anselsol May 28, 2026
76eaec4
Fix popup calendar icon color
anselsol May 28, 2026
979ef69
Merge upstream/master into feat/add-slot-filters-for-programs
anselsol Jun 29, 2026
40541e2
Fix FilterChip clear-button rendering as UA-chromed button
anselsol Jun 29, 2026
0397b5c
Merge upstream/master into feat/add-slot-filters-for-programs
anselsol Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
380 changes: 380 additions & 0 deletions app/components/account/history/HistoryFilterBar.tsx

Large diffs are not rendered by default.

174 changes: 174 additions & 0 deletions app/components/account/history/__tests__/HistoryFilterBar.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* 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';

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, useHistoryFilters } from '../HistoryFilterBar';

function setSearch(search: string) {
mockSearchString = search;
}

beforeEach(() => {
replaceMock.mockClear();
setSearch('');
});

describe('useHistoryFilters', () => {
it('should return all-undefined when no params are set', () => {
setSearch('');
const { result } = renderHook(() => useHistoryFilters());
expect(result.current).toEqual({
blockTime: undefined,
slot: undefined,
status: undefined,
});
});

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('should ignore negative and non-numeric values', () => {
setSearch('slot.gte=-5&slot.lte=abc');
const { result } = renderHook(() => useHistoryFilters());
expect(result.current.slot).toBeUndefined();
});

it('should floor fractional values', () => {
setSearch('slot.gte=100.9');
const { result } = renderHook(() => useHistoryFilters());
expect(result.current.slot).toEqual({ gte: 100, lte: undefined });
});

it('should parse the status enum, rejecting unknown values', () => {
setSearch('status=failed');
const { result } = renderHook(() => useHistoryFilters());
expect(result.current).toMatchObject({ status: 'failed' });

setSearch('status=bogus');
const { result: rejected } = renderHook(() => useHistoryFilters());
expect(rejected.current).toMatchObject({ status: undefined });
});

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 });
});
});

describe('HistoryFilterBar', () => {
it('should show a single Filters button when no filter is active', () => {
render(<HistoryFilterBar />);
expect(screen.getByRole('button', { name: /^Filters$/ })).toBeInTheDocument();
expect(screen.queryByText(/Slot ≥:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Slot ≤:/)).not.toBeInTheDocument();
});

it('should render a chip per active slot bound', () => {
render(<HistoryFilterBar slot={{ gte: 100, lte: 2_000_000 }} />);
expect(screen.getByText(/Slot ≥:\s*100/)).toBeInTheDocument();
// Use a regex since jsdom's toLocaleString thousand separator varies by ICU build.
expect(screen.getByText(/Slot ≤:\s*2[,.\s ]?000[,.\s ]?000/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Edit filters/ })).toBeInTheDocument();
});

it('should clear a single slot bound when its chip × is clicked', () => {
setSearch('slot.gte=100&slot.lte=200');
render(<HistoryFilterBar slot={{ gte: 100, lte: 200 }} />);

fireEvent.click(screen.getByRole('button', { name: /Clear slot ≥ filter/ }));

expect(replaceMock).toHaveBeenCalledTimes(1);
const [href] = replaceMock.mock.calls[0];
expect(href).toBe('/address/testAddress?slot.lte=200');
});

it('should apply both slot bounds from the popover', () => {
render(<HistoryFilterBar />);

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$/ }));

expect(replaceMock).toHaveBeenCalledTimes(1);
const [href] = replaceMock.mock.calls[0];
expect(href).toMatch(/slot\.gte=100/);
expect(href).toMatch(/slot\.lte=500/);
});

it('should preserve unrelated query params when updating the URL', () => {
setSearch('cluster=devnet&slot.gte=100');
render(<HistoryFilterBar slot={{ gte: 100 }} />);

fireEvent.click(screen.getByRole('button', { name: /Clear slot ≥ filter/ }));

const [href] = replaceMock.mock.calls[0];
expect(href).toBe('/address/testAddress?cluster=devnet');
});

it('should disable Apply and show an error when slot ≥ exceeds slot ≤', () => {
render(<HistoryFilterBar />);

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' } });

const apply = screen.getByRole('button', { name: /^Apply$/ });
expect(apply).toBeDisabled();
expect(screen.getByText(/Slot ≥ must be/)).toBeInTheDocument();

fireEvent.click(apply);
expect(replaceMock).not.toHaveBeenCalled();
});

it('should apply the status select', () => {
render(<HistoryFilterBar />);

fireEvent.click(screen.getByRole('button', { name: /^Filters$/ }));
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'succeeded' } });
fireEvent.click(screen.getByRole('button', { name: /^Apply$/ }));

const [href] = replaceMock.mock.calls[0];
expect(href).toMatch(/status=succeeded/);
});

it('should render a chip for the status filter', () => {
render(<HistoryFilterBar status="failed" />);
expect(screen.getByText(/Status:\s*Failed/)).toBeInTheDocument();
});

it('should clear a single non-slot filter via its chip', () => {
setSearch('status=failed&slot.lte=200');
render(<HistoryFilterBar slot={{ lte: 200 }} status="failed" />);

fireEvent.click(screen.getByRole('button', { name: /Clear status filter/ }));

const [href] = replaceMock.mock.calls[0];
expect(href).toBe('/address/testAddress?slot.lte=200');
});

it('should clear all filters via Clear all', () => {
setSearch('slot.gte=100&slot.lte=500');
render(<HistoryFilterBar slot={{ gte: 100, lte: 500 }} />);

fireEvent.click(screen.getByRole('button', { name: /Edit filters/ }));
fireEvent.click(screen.getByRole('button', { name: /Clear all/ }));

const [href] = replaceMock.mock.calls[0];
expect(href).toBe('/address/testAddress');
});
});
3 changes: 3 additions & 0 deletions app/components/shared/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const inputVariants = cva(
'font-normal font-mono',
'flex h-9 w-full rounded border',
'px-4 py-2.5 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]:invert',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900',
'disabled:cursor-not-allowed disabled:opacity-50',
'aria-[invalid="true"]:!border-destructive aria-[invalid="true"]:focus-visible:ring-destructive',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type BaseTransactionHistoryCardProps = {
foundOldest: boolean;
onRefresh: () => void;
onLoadMore: () => void;
headerActions?: ReactNode;
headerSubRow?: ReactNode;
};

export function BaseTransactionHistoryCard({
Expand All @@ -36,6 +38,8 @@ export function BaseTransactionHistoryCard({
foundOldest,
onRefresh,
onLoadMore,
headerActions,
headerSubRow,
}: BaseTransactionHistoryCardProps) {
const hasTimestamps = rows.some(row => row.blockTime);

Expand All @@ -46,6 +50,8 @@ export function BaseTransactionHistoryCard({
analyticsSection="transaction_history_header"
refresh={onRefresh}
fetching={fetching}
actions={headerActions}
subHeader={headerSubRow}
/>
<BaseTable ui="dashkit" variant="card" nowrap>
<BaseTable.Head>
Expand Down
48 changes: 44 additions & 4 deletions app/features/transaction-history/ui/TransactionHistoryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
'use client';

import {
HistoryFilterChips,
HistoryFilterTrigger,
useClearHistoryFilters,
useHistoryFilters,
} from '@components/account/history/HistoryFilterBar';
import { getTransactionRows } from '@components/account/HistoryCardComponents';
import { ErrorCard } from '@components/common/ErrorCard';
import { LoadingCard } from '@components/common/LoadingCard';
import { useAccountHistory, useFetchAccountHistory } 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 { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';

import { BaseTransactionHistoryCard, type TransactionHistoryRowView } from './BaseTransactionHistoryCard';
import { InstructionsCell } from './InstructionsCell';
import { TransactionRawDataCell } from './TransactionRawDataCell';

export function TransactionHistoryCard({ address }: { address: string }) {
const pubkey = useMemo(() => new PublicKey(address), [address]);
const filters = useHistoryFilters();
const hasActiveFilters = Object.values(filters).some(value => value !== undefined);
const filtersKey = JSON.stringify(filters);
const history = useAccountHistory(address);
const fetchAccountHistory = useFetchAccountHistory();
const fetchAccountHistory = useFetchAccountHistory(25, filters);
const resetHistory = useResetAccountHistory();
const filtersSupported = useHistoryFiltersSupported();
const clearFilters = useClearHistoryFilters();

// Signatures only — the parsed transactions for instruction names are fetched lazily per row, one at a
// time (see InstructionsCell), so the page never batch-hammers the RPC into 429s.
const refresh = () => fetchAccountHistory(pubkey, false, true);
const refresh = useCallback(() => fetchAccountHistory(pubkey, false, true), [fetchAccountHistory, pubkey]);
const loadMore = () => fetchAccountHistory(pubkey, false);

const rows: TransactionHistoryRowView[] = history?.data?.fetched
Expand All @@ -39,6 +56,27 @@ export function TransactionHistoryCard({ address }: { address: string }) {
}
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps

// Refetch from scratch when any filter changes. The cache is keyed by address
// only, so we reset this address's entry (which also supersedes any in-flight
// request for it) before refetching to avoid mixing pre- and post-filter results
// in combineFetched.
const previousFiltersKey = useRef(filtersKey);
useEffect(() => {
if (previousFiltersKey.current !== filtersKey) {
previousFiltersKey.current = filtersKey;
resetHistory(address);
refresh();
}
}, [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.
useEffect(() => {
if (!filtersSupported && hasActiveFilters) {
Comment thread
askov marked this conversation as resolved.
clearFilters();
}
}, [filtersSupported, hasActiveFilters, clearFilters]);

if (!history?.data) {
return !history || history.status === FetchStatus.Fetching ? (
<LoadingCard message="Loading history" />
Expand All @@ -54,6 +92,8 @@ export function TransactionHistoryCard({ address }: { address: string }) {
foundOldest={history.data.foundOldest}
onRefresh={refresh}
onLoadMore={loadMore}
headerActions={<HistoryFilterTrigger {...filters} />}
headerSubRow={hasActiveFilters ? <HistoryFilterChips {...filters} /> : undefined}
/>
);
}
10 changes: 5 additions & 5 deletions app/providers/accounts/__tests__/history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ describe('reconcile', () => {
it('should ignore an empty refresh so a flaky RPC response cannot wipe loaded history or flip foundOldest', () => {
const history = { fetched: [sig('a'), sig('b')], foundOldest: false };

const result = reconcile(history, { before: undefined, history: { fetched: [], foundOldest: true } });
const result = reconcile(history, { append: false, history: { fetched: [], foundOldest: true } });

expect(result).toBe(history);
});

it('should still record an empty result on the first load (a genuinely empty account)', () => {
const result = reconcile(undefined, { before: undefined, history: { fetched: [], foundOldest: true } });
const result = reconcile(undefined, { append: false, history: { fetched: [], foundOldest: true } });

expect(result?.fetched).toEqual([]);
expect(result?.foundOldest).toBe(true);
Expand All @@ -25,17 +25,17 @@ describe('reconcile', () => {
const history = { fetched: [sig('a')], foundOldest: false };

const result = reconcile(history, {
before: undefined,
append: false,
history: { fetched: [sig('b'), sig('a')], foundOldest: false },
});

expect(result?.fetched.map(s => s.signature)).toEqual(['b', 'a']);
});

it('should keep the end-of-history signal when load-more (before set) returns empty', () => {
it('should keep the end-of-history signal when load-more (append) returns empty', () => {
const history = { fetched: [sig('a')], foundOldest: false };

const result = reconcile(history, { before: 'a', history: { fetched: [], foundOldest: true } });
const result = reconcile(history, { append: true, history: { fetched: [], foundOldest: true } });

expect(result?.fetched.map(s => s.signature)).toEqual(['a']);
expect(result?.foundOldest).toBe(true);
Expand Down
Loading