From 6b1fd3fd3e44b3416a411450292e6575edfdc455 Mon Sep 17 00:00:00 2001 From: Radu Mojic Date: Tue, 21 Jul 2026 18:27:32 +0300 Subject: [PATCH 1/5] searchAfter base setup --- src/appConstants/apiFields.constants.ts | 6 +++-- src/appConstants/general.constants.ts | 4 +++ src/components/BlocksTable/BlocksTable.tsx | 8 ++++-- src/components/EventsTable/EventsTable.tsx | 3 ++- .../ScResultsTable/ScResultsTable.tsx | 7 ++++- .../TransactionsTable/TransactionsTable.tsx | 7 ++++- src/helpers/getValue/index.ts | 1 + src/helpers/isCondition/index.ts | 1 + src/hooks/adapter/helpers.ts | 26 ++++++++++++++----- .../adapter/requests/useAccountRequests.ts | 6 +++-- .../requests/useTransactionRequests.ts | 4 +-- src/hooks/fetch/useFetchBlocks.ts | 3 ++- src/hooks/fetch/useFetchCustomTransfers.ts | 3 ++- src/hooks/fetch/useFetchEvents.ts | 3 ++- src/hooks/fetch/useFetchTransactions.ts | 3 ++- .../CollectionDetails/CollectionNfts.tsx | 5 +++- src/pages/Collections/Collections.tsx | 10 +++++-- src/types/adapter.types.ts | 3 +++ src/types/block.types.ts | 1 + src/types/collection.types.ts | 1 + src/types/events.types.ts | 1 + src/types/nft.types.ts | 1 + src/types/transaction.types.ts | 2 ++ 23 files changed, 85 insertions(+), 24 deletions(-) diff --git a/src/appConstants/apiFields.constants.ts b/src/appConstants/apiFields.constants.ts index 920c12e81..86e4479bd 100644 --- a/src/appConstants/apiFields.constants.ts +++ b/src/appConstants/apiFields.constants.ts @@ -19,7 +19,8 @@ export const TRANSACTIONS_TABLE_FIELDS = [ 'guardianSignature', 'relayer', 'isRelayed', - 'relayedVersion' + 'relayedVersion', + 'searchAfter' ]; export const IDENTITIES_FIELDS = [ @@ -80,7 +81,8 @@ export const BLOCKS_FIELDS = [ 'gasPenalized', 'maxGasLimit', 'proposer', - 'proposerIdentity' + 'proposerIdentity', + 'searchAfter' ]; export const LATEST_BLOCKS_FIELDS = [ diff --git a/src/appConstants/general.constants.ts b/src/appConstants/general.constants.ts index 88ddf7432..cfc7be9fb 100644 --- a/src/appConstants/general.constants.ts +++ b/src/appConstants/general.constants.ts @@ -34,6 +34,10 @@ export const TEMP_LOCAL_NOTIFICATION_DISMISSED = 'barnardGovernance'; export const CUSTOM_NETWORK_ID = 'custom-network'; export const NEW_VERSION_NOTIFICATION = 'newExplorerVersion'; export const NAVIGATION_SEARCH_STATE = 'fromSearch'; +export const CURSOR_HISTORY_STORAGE_KEY = 'explorerCursors'; + +export const MAX_CURSOR_HISTORY_PAGES = 500; +export const MAX_CURSOR_HISTORY_LISTS = 5; export const SC_INIT_CHARACTERS_LENGTH = 13; diff --git a/src/components/BlocksTable/BlocksTable.tsx b/src/components/BlocksTable/BlocksTable.tsx index a8cd7c4a3..f31673944 100644 --- a/src/components/BlocksTable/BlocksTable.tsx +++ b/src/components/BlocksTable/BlocksTable.tsx @@ -63,7 +63,11 @@ export const BlocksTable = ({ )} - 0} /> + 0} + items={blocks} + /> @@ -195,7 +199,7 @@ export const BlocksTable = ({
- 0} /> + 0} items={blocks} />
diff --git a/src/components/EventsTable/EventsTable.tsx b/src/components/EventsTable/EventsTable.tsx index 81cae6c70..fee513ffa 100644 --- a/src/components/EventsTable/EventsTable.tsx +++ b/src/components/EventsTable/EventsTable.tsx @@ -46,6 +46,7 @@ export const EventsTable = ({ total={totalEvents} show={events.length > 0} className='d-flex ms-auto me-auto me-sm-0' + items={events} /> @@ -104,7 +105,7 @@ export const EventsTable = ({
- 0} /> + 0} items={events} />
diff --git a/src/components/ScResultsTable/ScResultsTable.tsx b/src/components/ScResultsTable/ScResultsTable.tsx index 730a6161f..dcc5a8fad 100644 --- a/src/components/ScResultsTable/ScResultsTable.tsx +++ b/src/components/ScResultsTable/ScResultsTable.tsx @@ -35,6 +35,7 @@ export const ScResultsTable = ({ show={scResults.length > 0} hasTestId={false} className='d-flex ms-auto me-auto me-sm-0' + items={scResults} /> @@ -66,7 +67,11 @@ export const ScResultsTable = ({
- 0} /> + 0} + items={scResults} + />
diff --git a/src/components/TransactionsTable/TransactionsTable.tsx b/src/components/TransactionsTable/TransactionsTable.tsx index 784c1495c..c0837e146 100644 --- a/src/components/TransactionsTable/TransactionsTable.tsx +++ b/src/components/TransactionsTable/TransactionsTable.tsx @@ -65,6 +65,7 @@ export const TransactionsTable = ({ total={totalTransactions} show={transactions.length > 0} className='d-flex ms-auto me-auto me-sm-0' + items={transactions} /> @@ -137,7 +138,11 @@ export const TransactionsTable = ({
- 0} /> + 0} + items={transactions} + />
diff --git a/src/helpers/getValue/index.ts b/src/helpers/getValue/index.ts index a25372ef0..b0a1e1cb2 100644 --- a/src/helpers/getValue/index.ts +++ b/src/helpers/getValue/index.ts @@ -6,6 +6,7 @@ export * from './getColors'; export * from './getCustomPageName'; export * from './getDisplayReceiver'; export * from './getItemsPage'; +export * from './getLastOffsetPage'; export * from './getNftText'; export * from './getNodeIcon'; export * from './getNodeIssue'; diff --git a/src/helpers/isCondition/index.ts b/src/helpers/isCondition/index.ts index d9c451bff..e112e38ec 100644 --- a/src/helpers/isCondition/index.ts +++ b/src/helpers/isCondition/index.ts @@ -1,5 +1,6 @@ export * from './addressIsBech32'; export * from './isContract'; +export * from './isCursorPage'; export * from './isEgldToken'; export * from './isEllipsisActive'; export * from './isHash'; diff --git a/src/hooks/adapter/helpers.ts b/src/hooks/adapter/helpers.ts index ad583e143..ab9f5de22 100644 --- a/src/hooks/adapter/helpers.ts +++ b/src/hooks/adapter/helpers.ts @@ -31,6 +31,7 @@ export const getAccountParams = (address?: string) => export function getTransactionsParams({ page, size, + searchAfter, order, fields = TRANSACTIONS_TABLE_FIELDS.join(','), @@ -67,7 +68,7 @@ export function getTransactionsParams({ ...(isCount ? {} : { - ...getPageParams({ page, size }), + ...getPageParams({ page, size, searchAfter }), ...(fields ? { fields } : {}), ...(order ? { order } : {}), ...(withScResults ? { withScResults } : {}), @@ -102,6 +103,7 @@ export function getTransactionsParams({ export function getEventsParams({ page, size, + searchAfter, address, identifier, @@ -114,7 +116,7 @@ export function getEventsParams({ isCount = false }: GetEventsType) { const params: AdapterProviderPropsType['params'] = { - ...(isCount ? {} : getPageParams({ page, size })), + ...(isCount ? {} : getPageParams({ page, size, searchAfter })), ...(address ? { address } : {}), ...(identifier ? { identifier } : {}), ...(txHash ? { txHash } : {}), @@ -209,6 +211,7 @@ export function getNodeParams({ export function getBlocksParams({ page, size, + searchAfter, fields = BLOCKS_FIELDS.join(','), shard, @@ -224,7 +227,7 @@ export function getBlocksParams({ ...(isCount ? {} : { - ...getPageParams({ page, size }), + ...getPageParams({ page, size, searchAfter }), ...(withProposerIdentity ? { withProposerIdentity } : {}), ...(fields !== undefined ? { fields } : {}) }), @@ -291,6 +294,7 @@ export function getTokensParams({ export function getCollectionsParams({ page, size, + searchAfter, sort, order, fields, @@ -310,7 +314,7 @@ export function getCollectionsParams({ ...(isCount ? {} : { - ...getPageParams({ page, size }), + ...getPageParams({ page, size, searchAfter }), ...(sort !== undefined ? { sort } : {}), ...(order !== undefined ? { order } : {}), ...(fields !== undefined ? { fields } : {}), @@ -328,6 +332,7 @@ export function getCollectionsParams({ export function getNftsParams({ page, size, + searchAfter, sort, order, fields, @@ -356,7 +361,7 @@ export function getNftsParams({ ...(isCount ? {} : { - ...getPageParams({ page, size }), + ...getPageParams({ page, size, searchAfter }), ...(sort !== undefined ? { sort } : {}), ...(order !== undefined ? { order } : {}), ...(fields !== undefined ? { fields } : {}), @@ -397,7 +402,16 @@ export const getShardAndEpochParams = ( return result; }; -export const getPageParams = ({ page = 1, size = PAGE_SIZE }: BaseApiType) => { +export const getPageParams = ({ + page = 1, + size = PAGE_SIZE, + searchAfter +}: BaseApiType) => { + // the api rejects the request unless `from` is absent alongside a cursor + if (searchAfter) { + return { size, searchAfter }; + } + const from = new BigNumber(page).minus(1).times(size); const isMoreThanMax = from.plus(size).isGreaterThan(MAX_RESULTS); diff --git a/src/hooks/adapter/requests/useAccountRequests.ts b/src/hooks/adapter/requests/useAccountRequests.ts index 9cd27eeef..44c8de28f 100644 --- a/src/hooks/adapter/requests/useAccountRequests.ts +++ b/src/hooks/adapter/requests/useAccountRequests.ts @@ -153,6 +153,7 @@ export const useAccountRequests = () => { address, page, size, + searchAfter, timeout, signal }: BaseApiType & GetAccountResourceType) => @@ -160,7 +161,7 @@ export const useAccountRequests = () => { url: `/accounts/${address}/contracts`, timeout, signal, - params: getPageParams({ page, size }) + params: getPageParams({ page, size, searchAfter }) }), getAccountContractsCount: ( @@ -261,6 +262,7 @@ export const useAccountRequests = () => { type, page, size, + searchAfter, timeout, signal }: BaseApiType & GetAccountResourceType & { type: AccountRolesTypeEnum }) => @@ -268,7 +270,7 @@ export const useAccountRequests = () => { url: `/accounts/${address}/roles/${type}`, timeout, signal, - params: getPageParams({ page, size }) + params: getPageParams({ page, size, searchAfter }) }), getAccountRolesCount: ({ diff --git a/src/hooks/adapter/requests/useTransactionRequests.ts b/src/hooks/adapter/requests/useTransactionRequests.ts index d6c160eed..b9d479004 100644 --- a/src/hooks/adapter/requests/useTransactionRequests.ts +++ b/src/hooks/adapter/requests/useTransactionRequests.ts @@ -88,12 +88,12 @@ export const useTransactionRequests = () => { getScResult: (hash: string, { signal, timeout }: AxiosParamsApiType = {}) => provider({ url: `/results/${hash}`, signal, timeout }), - getScResults: ({ page, size, signal, timeout }: BaseApiType) => + getScResults: ({ page, size, searchAfter, signal, timeout }: BaseApiType) => provider({ url: '/results', signal, timeout, - params: getPageParams({ page, size }) + params: getPageParams({ page, size, searchAfter }) }), getScResultsCount: ({ signal, timeout }: AxiosParamsApiType = {}) => diff --git a/src/hooks/fetch/useFetchBlocks.ts b/src/hooks/fetch/useFetchBlocks.ts index a9d18baa1..f6b0bef2d 100644 --- a/src/hooks/fetch/useFetchBlocks.ts +++ b/src/hooks/fetch/useFetchBlocks.ts @@ -15,7 +15,7 @@ interface BlocksWebsocketResponseType { export const useFetchBlocks = (props: Omit) => { const dispatch = useDispatch(); const blockFilters = useGetBlockFilters(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { dataCountPromise, filters, websocketConfig } = props; const { blocks, blocksCount, isDataReady, isRefreshPaused } = @@ -57,6 +57,7 @@ export const useFetchBlocks = (props: Omit) => { filters: { page, size, + searchAfter, ...blockFilters, ...filters }, diff --git a/src/hooks/fetch/useFetchCustomTransfers.ts b/src/hooks/fetch/useFetchCustomTransfers.ts index 123165b33..1c3d9aa3b 100644 --- a/src/hooks/fetch/useFetchCustomTransfers.ts +++ b/src/hooks/fetch/useFetchCustomTransfers.ts @@ -21,7 +21,7 @@ export interface CustomTransfersWebsocketResponseType { export const useFetchCustomTransfers = (props: FetchCustomTransfersProps) => { const dispatch = useDispatch(); const transactionFilters = useGetTransactionFilters(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { dataCountPromise, filters, websocketConfig } = props; @@ -83,6 +83,7 @@ export const useFetchCustomTransfers = (props: FetchCustomTransfersProps) => { filters: { page, size, + searchAfter, ...transactionFilters, ...filters }, diff --git a/src/hooks/fetch/useFetchEvents.ts b/src/hooks/fetch/useFetchEvents.ts index c9f5cfa7a..51fae21e4 100644 --- a/src/hooks/fetch/useFetchEvents.ts +++ b/src/hooks/fetch/useFetchEvents.ts @@ -15,7 +15,7 @@ interface EventsWebsocketResponseType { export const useFetchEvents = (props: Omit) => { const dispatch = useDispatch(); const eventFilters = useGetEventFilters(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { dataCountPromise, filters } = props; const { events, eventsCount, isDataReady, isRefreshPaused } = @@ -57,6 +57,7 @@ export const useFetchEvents = (props: Omit) => { filters: { page, size, + searchAfter, ...eventFilters, ...filters }, diff --git a/src/hooks/fetch/useFetchTransactions.ts b/src/hooks/fetch/useFetchTransactions.ts index cf8ee858b..bd12317cc 100644 --- a/src/hooks/fetch/useFetchTransactions.ts +++ b/src/hooks/fetch/useFetchTransactions.ts @@ -21,7 +21,7 @@ export interface TransactionsWebsocketResponseType { export const useFetchTransactions = (props: FetchTransactionsProps) => { const dispatch = useDispatch(); const transactionFilters = useGetTransactionFilters(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { hasMaxTransactionsSize, dataCountPromise, filters, websocketConfig } = props; @@ -69,6 +69,7 @@ export const useFetchTransactions = (props: FetchTransactionsProps) => { filters: { page, size: maxTransactionsSize, + searchAfter, ...transactionFilters, ...filters }, diff --git a/src/pages/CollectionDetails/CollectionNfts.tsx b/src/pages/CollectionDetails/CollectionNfts.tsx index 2399eeb26..dced6865a 100644 --- a/src/pages/CollectionDetails/CollectionNfts.tsx +++ b/src/pages/CollectionDetails/CollectionNfts.tsx @@ -25,7 +25,7 @@ export const CollectionNfts = () => { const { collectionState } = useSelector(collectionSelector); const { type } = collectionState; const { getCollectionNfts, getCollectionNftsCount } = useAdapter(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { search } = useGetSearch(); const { hash: collection } = useParams() as any; @@ -41,6 +41,7 @@ export const CollectionNfts = () => { search, page, size, + searchAfter, collection, ...(type === NftTypeEnum.NonFungibleESDT ? { withOwner: true } : {}), ...(type === NftTypeEnum.SemiFungibleESDT ? { withSupply: true } : {}) @@ -72,6 +73,7 @@ export const CollectionNfts = () => { total={totalCollectionNfts} show={collectionNfts.length > 0} className='d-flex ms-auto me-auto me-sm-0' + items={collectionNfts} /> @@ -158,6 +160,7 @@ export const CollectionNfts = () => { 0} + items={collectionNfts} /> diff --git a/src/pages/Collections/Collections.tsx b/src/pages/Collections/Collections.tsx index 5b811d379..8d41ba11a 100644 --- a/src/pages/Collections/Collections.tsx +++ b/src/pages/Collections/Collections.tsx @@ -34,7 +34,7 @@ export const Collections = () => { const hasGrowthWidgets = useHasGrowthWidgets(); const isMainnet = useIsMainnet(); const activeRoute = useActiveRoute(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { search } = useGetSearch(); const { search: searchLocation, pathname } = useLocation(); const { getCollections, getCollectionsCount } = useAdapter(); @@ -67,6 +67,7 @@ export const Collections = () => { search, page, size, + searchAfter, type, ...(isMainnet ? { sort: CollectionSortEnum.verifiedAndHolderCount } @@ -156,6 +157,7 @@ export const Collections = () => { total={totalCollections} show={collections.length > 0} className='d-flex ms-auto me-auto me-sm-0' + items={collections} /> @@ -222,7 +224,11 @@ export const Collections = () => {
- 0} /> + 0} + items={collections} + />
diff --git a/src/types/adapter.types.ts b/src/types/adapter.types.ts index 784f203db..0b157268b 100644 --- a/src/types/adapter.types.ts +++ b/src/types/adapter.types.ts @@ -17,6 +17,8 @@ export interface BaseApiType extends AxiosParamsApiType { extract?: string; // not on api isCount?: boolean; + // cursor taken from the last item of the previous response + searchAfter?: string; } export interface SortableApiType extends BaseApiType { @@ -249,6 +251,7 @@ export interface AdapterProviderPropsType { withScrCount?: boolean; withIdentityInfo?: boolean; owner?: string; + searchAfter?: string; }; timeout: number; timestamp?: number; diff --git a/src/types/block.types.ts b/src/types/block.types.ts index 79aae8a61..b529cf4f1 100644 --- a/src/types/block.types.ts +++ b/src/types/block.types.ts @@ -27,6 +27,7 @@ export interface BlockType { reserved?: string; lastExecutionResultHash?: string; lastExecutionResultNonce?: number; + searchAfter?: string; } export interface UIBlockType extends BlockType { diff --git a/src/types/collection.types.ts b/src/types/collection.types.ts index a30829101..40d2c11bc 100644 --- a/src/types/collection.types.ts +++ b/src/types/collection.types.ts @@ -30,6 +30,7 @@ export interface CollectionType { isVerified?: boolean; nftCount?: number; holderCount?: number; + searchAfter?: string; } export interface CollectionSliceType extends SliceType { diff --git a/src/types/events.types.ts b/src/types/events.types.ts index fe7521a13..295b69cc7 100644 --- a/src/types/events.types.ts +++ b/src/types/events.types.ts @@ -13,6 +13,7 @@ export interface EventType { txOrder: number; order: number; timestamp: number; + searchAfter?: string; } export interface UIEventType extends EventType { diff --git a/src/types/nft.types.ts b/src/types/nft.types.ts index 5aee0139a..1d89d9590 100644 --- a/src/types/nft.types.ts +++ b/src/types/nft.types.ts @@ -79,6 +79,7 @@ export interface NftType { }; scamInfo?: ScamInfoType; isVerified?: boolean; + searchAfter?: string; } export interface NftAccountType { diff --git a/src/types/transaction.types.ts b/src/types/transaction.types.ts index 1642640ae..5825118fa 100644 --- a/src/types/transaction.types.ts +++ b/src/types/transaction.types.ts @@ -43,6 +43,7 @@ export interface TransactionType { results?: TransactionSCResultType[]; operations?: TransactionOperationType[]; innerTransactions?: TransactionInnerType[]; + searchAfter?: string; } // TRANSACTION SC RESULTS @@ -65,6 +66,7 @@ export interface TransactionSCResultType { senderAssets?: AccountAssetType; receiverAssets?: AccountAssetType; miniBlockHash?: string; + searchAfter?: string; } export interface TransactionSCResultLogType { From 9d36d4cd87f826f76ddcf5a2faaac219dc6755f1 Mon Sep 17 00:00:00 2001 From: Radu Mojic Date: Wed, 22 Jul 2026 14:50:27 +0300 Subject: [PATCH 2/5] added searchAfter pagination --- cypress/e2e/SearchAfter/SearchAfter.cy.ts | 157 ++++++++++++++++++++ src/components/Pager/Pager.tsx | 155 +++++++++++++------ src/components/Pager/helpers/pagerHelper.ts | 11 +- src/helpers/getValue/getLastOffsetPage.ts | 16 ++ src/helpers/isCondition/isCursorPage.ts | 10 ++ src/hooks/urlFilters/index.ts | 1 + src/hooks/urlFilters/useGetCursorHistory.ts | 100 +++++++++++++ src/hooks/urlFilters/useGetPage.ts | 10 +- 8 files changed, 416 insertions(+), 44 deletions(-) create mode 100644 cypress/e2e/SearchAfter/SearchAfter.cy.ts create mode 100644 src/helpers/getValue/getLastOffsetPage.ts create mode 100644 src/helpers/isCondition/isCursorPage.ts create mode 100644 src/hooks/urlFilters/useGetCursorHistory.ts diff --git a/cypress/e2e/SearchAfter/SearchAfter.cy.ts b/cypress/e2e/SearchAfter/SearchAfter.cy.ts new file mode 100644 index 000000000..35b0bef8f --- /dev/null +++ b/cypress/e2e/SearchAfter/SearchAfter.cy.ts @@ -0,0 +1,157 @@ +/// + +// cursor requests take longer +const CURSOR_TIMEOUT = 15000; + +const firstTxHash = () => + cy + .get('[data-testid="transactionLink"]', { timeout: CURSOR_TIMEOUT }) + .first() + .invoke('text'); + +const visitTransactions = (query: string) => { + cy.intercept('GET', 'https://devnet-api.multiversx.com/transactions?*').as( + 'txs' + ); + cy.visit(`/devnet/transactions${query}`); +}; + +describe('searchAfter cursor pagination', () => { + it('keeps offset pagination below the ceiling and sends no cursor', () => { + visitTransactions('?page=399'); + + cy.wait('@txs').then(({ request }) => { + expect(request.url).to.include('from=9950'); + expect(request.url).to.not.include('searchAfter='); + }); + }); + + it('crosses the wall: page 400 Next hands off to a cursor', () => { + visitTransactions('?page=400'); + + // the last offset page: from + size = 10000 + cy.wait('@txs').its('request.url').should('include', 'from=9975'); + cy.get('[data-testid="transactionsTable"] tr').should('have.length.gt', 1); + + firstTxHash().then((hashAtWall) => { + cy.get('[data-testid="nextPageButton"]').first().click(); + + cy.url().should('include', 'page=401'); + cy.url().should('include', 'searchAfter='); + + // a cursor request carrying `from` is a 400 from the api + cy.wait('@txs').then(({ request }) => { + expect(request.url).to.include('searchAfter='); + expect(request.url).to.not.include('from='); + }); + + // page 401 must be new rows, not a repeat of the wall or of page 1 + firstTxHash().should('not.equal', hashAtWall); + }); + }); + + it('walks forward then back across cursor pages', () => { + visitTransactions('?page=400'); + cy.wait('@txs'); + + cy.get('[data-testid="nextPageButton"]').first().click(); + cy.url().should('include', 'page=401'); + cy.wait('@txs'); + + firstTxHash().then((hashAt401) => { + cy.get('[data-testid="nextPageButton"]').first().click(); + cy.url().should('include', 'page=402'); + cy.wait('@txs', { timeout: CURSOR_TIMEOUT }); + firstTxHash().should('not.equal', hashAt401); + + // back to 401 using the cursor remembered on the way out + cy.get('[data-testid="previousPageButton"]').first().click(); + cy.url().should('include', 'page=401'); + cy.wait('@txs', { timeout: CURSOR_TIMEOUT }); + firstTxHash().should('equal', hashAt401); + }); + }); + + it('drops the cursor when stepping back below the ceiling', () => { + visitTransactions('?page=400'); + cy.wait('@txs'); + cy.get('[data-testid="nextPageButton"]').first().click(); + cy.wait('@txs'); + + cy.get('[data-testid="previousPageButton"]').first().click(); + cy.url().should('include', 'page=400'); + cy.url().should('not.include', 'searchAfter'); + + cy.wait('@txs').then(({ request }) => { + expect(request.url).to.include('from=9975'); + expect(request.url).to.not.include('searchAfter='); + }); + }); + + it('snaps back when the api ignores the cursor and serves page 1 anyway', () => { + visitTransactions('?page=400'); + cy.wait('@txs'); + + const cursorlessBody = Array.from({ length: 25 }, (_, index) => ({ + txHash: `${index}`.padStart(64, 'a'), + sender: 'erd1qqqqqqqqqqqqqpgqvg8r5yavkyhu6rmmkgqzgsduzheg2fk7v5ysrypdex', + receiver: + 'erd1qqqqqqqqqqqqqpgqvg8r5yavkyhu6rmmkgqzgsduzheg2fk7v5ysrypdex', + senderShard: 1, + receiverShard: 1, + status: 'success', + value: '0', + timestamp: 1783695296, + round: 1 + })); + + cy.intercept( + 'GET', + 'https://devnet-api.multiversx.com/transactions?*searchAfter*', + { statusCode: 200, body: cursorlessBody } + ).as('blindTxs'); + + cy.get('[data-testid="nextPageButton"]').first().click(); + + cy.wait('@blindTxs'); + cy.url().should('not.include', 'page=401'); + cy.url().should('include', 'page=400'); + cy.url().should('not.include', 'searchAfter'); + }); + + it('crosses the wall on blocks too', () => { + cy.intercept('GET', 'https://devnet-api.multiversx.com/blocks?*').as( + 'blocks' + ); + cy.visit('/devnet/blocks?page=400'); + cy.wait('@blocks').its('request.url').should('include', 'from=9975'); + + cy.get('[data-testid="blockLink0"]') + .invoke('text') + .then((nonceAtWall) => { + cy.get('[data-testid="nextPageButton"]').first().click(); + + cy.url().should('include', 'page=401'); + cy.wait('@blocks').then(({ request }) => { + expect(request.url).to.include('searchAfter='); + expect(request.url).to.not.include('from='); + }); + + cy.get('[data-testid="blockLink0"]') + .invoke('text') + .should('not.equal', nonceAtWall); + }); + }); + + it('ignores a cursor left in the url once page falls back below the ceiling', () => { + // clear cursor + visitTransactions( + '?searchAfter=WzE3ODE4NjM2NzYwMDAsMTc4MTg2MzY3NjAwMCw0MTk4OSwiMktmVW5iMF9RdnVPQVlYaTIyWlRIZz09Il0=' + ); + + cy.wait('@txs').then(({ request }) => { + expect(request.url).to.include('from=0'); + expect(request.url).to.not.include('searchAfter='); + }); + }); +}); diff --git a/src/components/Pager/Pager.tsx b/src/components/Pager/Pager.tsx index 698c1ae5c..25b58ede6 100644 --- a/src/components/Pager/Pager.tsx +++ b/src/components/Pager/Pager.tsx @@ -1,16 +1,17 @@ -import { Fragment } from 'react'; +import { useEffect } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useSearchParams } from 'react-router-dom'; import { ELLIPSIS, PAGE_SIZE, MAX_RESULTS } from 'appConstants'; import { stringIsInteger, formatOrdinals } from 'helpers'; +import { useGetCursorHistory } from 'hooks'; import { faAngleLeft, faAngleRight, faAnglesLeft, faAnglesRight } from 'icons/solid'; -import { pagerHelper } from './helpers/pagerHelper'; +import { generatePaginationArray, pagerHelper } from './helpers/pagerHelper'; export interface PagerUIType { total?: number | typeof ELLIPSIS; @@ -19,6 +20,7 @@ export interface PagerUIType { showFirstAndLast?: boolean; className?: string; hasTestId?: boolean; + items?: { searchAfter?: string }[]; } export const Pager = ({ @@ -27,47 +29,118 @@ export const Pager = ({ itemsPerPage = PAGE_SIZE, showFirstAndLast, className = '', - hasTestId = true + hasTestId = true, + items = [] }: PagerUIType) => { const [searchParams, setSearchParams] = useSearchParams(); const params = Object.fromEntries(searchParams); + const { getCursor, setCursor } = useGetCursorHistory(); - const { page, size, ...rest } = params; + const nextCursor = items[items.length - 1]?.searchAfter; + const itemsCount = items.length; + + const { page, size, searchAfter, ...rest } = params; const processedSize = stringIsInteger(String(size)) ? parseInt(String(size)) : itemsPerPage; const processedTotal = total !== ELLIPSIS ? Math.min(total, MAX_RESULTS) : 0; - const { processedPage, lastPage, end, paginationArray } = pagerHelper({ + const { + processedPage, + lastPage, + end, + lastOffsetPage, + isCursorMode, + paginationArray + } = pagerHelper({ total: processedTotal, itemsPerPage: processedSize, page: Number(page) }); + const hasResultsPastCeiling = total === ELLIPSIS || total > MAX_RESULTS; + const isCursorNext = + processedPage >= lastOffsetPage && + Boolean(nextCursor) && + hasResultsPastCeiling; + const isLastCursorPage = isCursorMode && itemsCount < processedSize; + + const baseUrlParams = { ...rest, ...(size ? { size } : {}) }; + const previousCursor = getCursor(processedPage - 1); + const previousPage = processedPage - 1; + const nextUrlParams = { - ...params, - page: `${processedPage + 1}` + ...baseUrlParams, + page: `${processedPage + 1}`, + ...(isCursorNext && nextCursor ? { searchAfter: nextCursor } : {}) }; const firstUrlParams = { ...rest }; const prevUrlParams = { - ...params, - page: `${processedPage - 1}` + ...baseUrlParams, + page: `${previousPage}`, + ...(previousPage > lastOffsetPage && previousCursor + ? { searchAfter: previousCursor } + : {}) }; + const lastUrlParams = { - ...params, + ...baseUrlParams, page: `${lastPage}` }; - const updatePage = (nextUrlParams: any) => { - setSearchParams(nextUrlParams); + const pages = isCursorMode + ? generatePaginationArray({ + currentPage: processedPage, + totalPages: processedPage + (nextCursor ? 1 : 0) + }) + : paginationArray; + + const getPageUrlParams = (page: number) => { + if (page <= lastOffsetPage) { + return { ...baseUrlParams, page: `${page}` }; + } + if (page === processedPage + 1 && nextCursor) { + return { ...baseUrlParams, page: `${page}`, searchAfter: nextCursor }; + } + const cursor = getCursor(page); + + return cursor + ? { ...baseUrlParams, page: `${page}`, searchAfter: cursor } + : undefined; + }; + + const updatePage = (urlParams: Record) => { + setSearchParams(urlParams); }; - const leftBtnActive = processedPage !== 1; - const rightBtnsActive = end < processedTotal; + useEffect(() => { + if (isCursorMode && searchAfter) { + setCursor(processedPage, searchAfter); + } + }, [isCursorMode, searchAfter, processedPage]); + + useEffect(() => { + if (isCursorMode && itemsCount && !nextCursor) { + setSearchParams( + { ...rest, page: `${lastOffsetPage}` }, + { replace: true } + ); + } + }, [isCursorMode, nextCursor, itemsCount, lastOffsetPage]); + + const canGoPrevious = isCursorMode + ? previousPage <= lastOffsetPage || Boolean(previousCursor) + : processedPage !== 1; + const canGoNext = isCursorMode + ? Boolean(nextCursor) && !isLastCursorPage + : total === ELLIPSIS || end < processedTotal || isCursorNext; + + const leftBtnActive = canGoPrevious; + const rightBtnsActive = canGoNext; return show ? (
@@ -99,7 +172,7 @@ export const Pager = ({ )} - {processedPage === 1 ? ( + {!canGoPrevious ? (
- {paginationArray.map((page, index) => { - const currentUrlParams = { - ...params, - page: String(page) - }; + {pages.map((page, index) => { + if (page === ELLIPSIS) { + return {ELLIPSIS}; + } + + const pageNumber = Number(page); + const isActive = pageNumber === processedPage; + const pageUrlParams = getPageUrlParams(pageNumber); return ( - - {page !== ELLIPSIS ? ( - - ) : ( - ... - )} - + ); })}
@@ -163,7 +234,7 @@ export const Pager = ({ rightBtnsActive ? '' : 'inactive' }`} > - {total === ELLIPSIS || end < processedTotal ? ( + {canGoNext ? (
@@ -147,7 +148,11 @@ export const AccountsTable = ({
- 0} /> + 0} + items={accounts} + />
) : ( diff --git a/src/components/Pager/Pager.tsx b/src/components/Pager/Pager.tsx index 25b58ede6..57c52b048 100644 --- a/src/components/Pager/Pager.tsx +++ b/src/components/Pager/Pager.tsx @@ -92,12 +92,17 @@ export const Pager = ({ page: `${lastPage}` }; + const offsetPages = + isCursorNext && !paginationArray.includes(processedPage + 1) + ? [...paginationArray, processedPage + 1] + : paginationArray; + const pages = isCursorMode ? generatePaginationArray({ currentPage: processedPage, totalPages: processedPage + (nextCursor ? 1 : 0) }) - : paginationArray; + : offsetPages; const getPageUrlParams = (page: number) => { if (page <= lastOffsetPage) { diff --git a/src/hooks/adapter/helpers.ts b/src/hooks/adapter/helpers.ts index ab9f5de22..55b8a8d14 100644 --- a/src/hooks/adapter/helpers.ts +++ b/src/hooks/adapter/helpers.ts @@ -255,6 +255,7 @@ export function getProviderParams({ export function getTokensParams({ page, size, + searchAfter, sort, order, fields, @@ -275,7 +276,7 @@ export function getTokensParams({ ...(isCount ? {} : { - ...getPageParams({ page, size }), + ...getPageParams({ page, size, searchAfter }), ...(sort !== undefined ? { sort } : {}), ...(order !== undefined ? { order } : {}), ...(fields !== undefined ? { fields } : {}), diff --git a/src/hooks/adapter/requests/useAccountRequests.ts b/src/hooks/adapter/requests/useAccountRequests.ts index 44c8de28f..7008346a0 100644 --- a/src/hooks/adapter/requests/useAccountRequests.ts +++ b/src/hooks/adapter/requests/useAccountRequests.ts @@ -41,6 +41,7 @@ export const useAccountRequests = () => { signal, page, size, + searchAfter, isSmartContract, withOwnerAssets = false, withDeployInfo = false, @@ -53,7 +54,7 @@ export const useAccountRequests = () => { timeout, signal, params: { - ...getPageParams({ page, size }), + ...getPageParams({ page, size, searchAfter }), ...(isSmartContract !== undefined ? { isSmartContract } : {}), ...(withOwnerAssets ? { withOwnerAssets } : {}), ...(withDeployInfo ? { withDeployInfo } : {}), diff --git a/src/hooks/fetch/useFetchApiData.ts b/src/hooks/fetch/useFetchApiData.ts index 4568f9ce4..381fded5d 100644 --- a/src/hooks/fetch/useFetchApiData.ts +++ b/src/hooks/fetch/useFetchApiData.ts @@ -41,13 +41,16 @@ export const useFetchApiData = ({ isCustomUpdate, isRefreshPaused = false }: FetchApiDataProps) => { - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const [dataChanged, setDataChanged] = useState(false); let isCalled = false; const hasUrlParams = - Object.keys(urlParams).length > 0 || page !== 1 || size !== PAGE_SIZE; + Object.keys(urlParams).length > 0 || + page !== 1 || + size !== PAGE_SIZE || + searchAfter !== undefined; const isPaused = Boolean(hasUrlParams || isRefreshPaused); diff --git a/src/pages/Accounts/Accounts.tsx b/src/pages/Accounts/Accounts.tsx index 80fe58dde..daaecd672 100644 --- a/src/pages/Accounts/Accounts.tsx +++ b/src/pages/Accounts/Accounts.tsx @@ -36,7 +36,7 @@ export const Accounts = () => { const sort = useGetSort(); const { search } = useGetSearch(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { getAccounts, getAccountsCount } = useAdapter(); const [accounts, setAccounts] = useState([]); @@ -53,6 +53,7 @@ export const Accounts = () => { getAccounts({ page, size, + searchAfter, search, ...sort }), @@ -108,6 +109,7 @@ export const Accounts = () => { total={totalAccounts} show={accounts.length > 0} className='d-flex ms-auto me-auto me-sm-0' + items={accounts} /> @@ -156,7 +158,11 @@ export const Accounts = () => {
- 0} /> + 0} + items={accounts} + />
diff --git a/src/pages/Applications/Applications.tsx b/src/pages/Applications/Applications.tsx index 40f6dab69..eb7ab23f9 100644 --- a/src/pages/Applications/Applications.tsx +++ b/src/pages/Applications/Applications.tsx @@ -56,7 +56,7 @@ export const Applications = () => { const sort = useGetSort(); const { search } = useGetSearch(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { getAccounts, getAccountsCount } = useAdapter(); const [accounts, setAccounts] = useState([]); @@ -82,6 +82,7 @@ export const Applications = () => { Promise.all([ getAccounts({ page, + searchAfter, search, isSmartContract: true, withOwnerAssets: true, @@ -141,6 +142,7 @@ export const Applications = () => { itemsPerPage={is24hCountAvailable ? PAGE_SIZE : minSize} show={accounts.length > 0} className='d-flex ms-auto me-auto me-sm-0' + items={accounts} /> @@ -291,6 +293,7 @@ export const Applications = () => { total={totalAccounts} itemsPerPage={is24hCountAvailable ? PAGE_SIZE : minSize} show={accounts.length > 0} + items={accounts} /> diff --git a/src/pages/NativeToken/NativeTokenAccounts.tsx b/src/pages/NativeToken/NativeTokenAccounts.tsx index 2f2276b6e..cb27a288b 100644 --- a/src/pages/NativeToken/NativeTokenAccounts.tsx +++ b/src/pages/NativeToken/NativeTokenAccounts.tsx @@ -13,7 +13,7 @@ export const NativeTokenAccounts = () => { const { id: activeNetworkId } = useSelector(activeNetworkSelector); const { price, marketCap, supply, decimals } = useGetNativeTokenDetails(); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { getAccounts, getAccountsCount } = useAdapter(); const [accounts, setAccounts] = useState([]); @@ -21,17 +21,18 @@ export const NativeTokenAccounts = () => { const [isDataReady, setIsDataReady] = useState(); const fetchAccounts = () => { - Promise.all([getAccounts({ page, size }), getAccountsCount({})]).then( - ([tokenAccountsData, tokenAccountsCountData]) => { - if (tokenAccountsData.success && tokenAccountsCountData.success) { - setAccounts(tokenAccountsData.data); - setAccountsCount(tokenAccountsCountData.data); - } - setIsDataReady( - tokenAccountsData.success && tokenAccountsCountData.success - ); + Promise.all([ + getAccounts({ page, size, searchAfter }), + getAccountsCount({}) + ]).then(([tokenAccountsData, tokenAccountsCountData]) => { + if (tokenAccountsData.success && tokenAccountsCountData.success) { + setAccounts(tokenAccountsData.data); + setAccountsCount(tokenAccountsCountData.data); } - ); + setIsDataReady( + tokenAccountsData.success && tokenAccountsCountData.success + ); + }); }; useEffect(() => { diff --git a/src/pages/TokenDetails/TokenAccounts.tsx b/src/pages/TokenDetails/TokenAccounts.tsx index e2b9f39f2..fe3a5d28d 100644 --- a/src/pages/TokenDetails/TokenAccounts.tsx +++ b/src/pages/TokenDetails/TokenAccounts.tsx @@ -14,7 +14,7 @@ export const TokenDetailsAccounts = () => { const { token } = useSelector(tokenSelector); const { id: activeNetworkId } = useSelector(activeNetworkSelector); - const { page, size } = useGetPage(); + const { page, size, searchAfter } = useGetPage(); const { getTokenAccounts, getTokenAccountsCount } = useAdapter(); const { @@ -31,7 +31,7 @@ export const TokenDetailsAccounts = () => { const fetchAccounts = () => { Promise.all([ - getTokenAccounts({ token: identifier, page, size }), + getTokenAccounts({ token: identifier, page, size, searchAfter }), getTokenAccountsCount({ token: identifier }) ]).then(([tokenAccountsData, tokenAccountsCountData]) => { if (tokenAccountsData.success && tokenAccountsCountData.success) { diff --git a/src/types/account.types.ts b/src/types/account.types.ts index 2ccabb8e0..299293e72 100644 --- a/src/types/account.types.ts +++ b/src/types/account.types.ts @@ -38,6 +38,7 @@ export interface AccountType { activeGuardianServiceUid?: string; ownerAssets?: AccountAssetType; transfersLast24h?: number; + searchAfter?: string; } export interface AccountSliceType extends SliceType { diff --git a/src/types/token.types.ts b/src/types/token.types.ts index 7f8104f7d..5dfad2a45 100644 --- a/src/types/token.types.ts +++ b/src/types/token.types.ts @@ -76,6 +76,7 @@ export interface TokenLockedAccountType { name: string; balance: string; assets?: AccountAssetType; + searchAfter?: string; } export interface TokenSupplyType { From 35ec00f6d1429372c1fd9c3a61b4e3e3924a235c Mon Sep 17 00:00:00 2001 From: Radu Mojic Date: Tue, 4 Aug 2026 10:22:52 +0300 Subject: [PATCH 4/5] fix copy button padding --- AGENTS.md | 10 ++++++++-- CLAUDE.md | 6 ++++++ src/components/DataDecode/dataDecode.styles.scss | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 4a16412b1..c8367944f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,9 +30,15 @@ npm run build-mainnet npm run lint # eslint src --max-warnings 0 # E2E tests (Cypress, runs against integration-explorer.multiversx.com) -node scripts/cypress.ts # or: npm run cy:run +node scripts/cypress.ts # or: npm run cy:run — full suite + mochawesome report + +# Single spec / single test (bypasses the report wrapper): +npx cypress run --spec cypress/e2e/Search/Search.cy.ts +npx cypress open # interactive runner ``` +Cypress hits the deployed `baseUrl` in `cypress.config.ts`, not your local dev server — no local build is needed to run E2E, but a network connection is. + `src/config/index.ts` **must exist** before starting. The `start-*` scripts create it automatically via the `copy-*-config` step, but if you run `npm run start` directly you need it manually. HTTPS is enabled by default (self-signed cert via `@vitejs/plugin-basic-ssl`). Set `VITE_APP_USE_HTTPS=false` to disable. @@ -45,7 +51,7 @@ HTTPS is enabled by default (self-signed cert via `@vitejs/plugin-basic-ssl`). S `src/index.tsx` → `App.tsx` wraps the app in Redux `` + `` + ``. -Routes are defined in `src/routes/routes.tsx` using React Router v6 `createBrowserRouter`. Every network has its routes prefixed with `/:network/` (e.g. `/devnet/blocks/...`). `generateNetworkRoutes` in `src/routes/helpers/` iterates `networks` from config and wraps routes per network. The `Layout` component (`src/layouts/Layout/`) is the shell that renders the header, hero stats widgets, and footer around page content. +The router itself is created in `src/App.tsx` with React Router v7 `createBrowserRouter`; the route definitions it consumes live in `src/routes/routes.tsx`. Every network has its routes prefixed with `/:network/` (e.g. `/devnet/blocks/...`). `generateNetworkRoutes` in `src/routes/helpers/` iterates `networks` from config and wraps routes per network. The `Layout` component (`src/layouts/Layout/`) is the shell that renders the header, hero stats widgets, and footer around page content. ### Network Configuration diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7066f0b1f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +The guidance for this repository lives in [AGENTS.md](./AGENTS.md) — commands, architecture, and conventions. +Read it before making changes, and record any new learnings there rather than here, so the two never drift apart. diff --git a/src/components/DataDecode/dataDecode.styles.scss b/src/components/DataDecode/dataDecode.styles.scss index 15a3476af..91c0e6eab 100644 --- a/src/components/DataDecode/dataDecode.styles.scss +++ b/src/components/DataDecode/dataDecode.styles.scss @@ -4,7 +4,7 @@ &.has-decode { --data-decode-padding: 0.375rem 9rem 0.375rem 0.75rem; - .copy-button { + .button-holder .copy-button { right: 6.75rem; } } From b1d8b77e54d1ff74bd1b29e4ab6da445631f6092 Mon Sep 17 00:00:00 2001 From: Radu Mojic Date: Fri, 14 Aug 2026 14:45:31 +0300 Subject: [PATCH 5/5] update CHANGELOG, version and test fixes --- CHANGELOG.md | 4 ++++ cypress/constants/enums.ts | 1 + cypress/support/commands.ts | 22 +++++++++++++++++++--- package.json | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c63207ea..062d27307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- ## [[2.3.6](https://github.com/multiversx/mx-explorer-dapp/pull/222)] - 2026-08-14 + +- [searchAAfter feature](https://github.com/multiversx/mx-explorer-dapp/pull/221) + - ## [[2.3.5](https://github.com/multiversx/mx-explorer-dapp/pull/219)] - 2026-05-04 - [600ms updates, avoid cached api/websocket updates](https://github.com/multiversx/mx-explorer-dapp/pull/218) diff --git a/cypress/constants/enums.ts b/cypress/constants/enums.ts index 134fc0754..b90df015d 100644 --- a/cypress/constants/enums.ts +++ b/cypress/constants/enums.ts @@ -1,6 +1,7 @@ export enum AssertionEnum { contain = 'contain', include = 'include', + notInclude = 'not.include', beChecked = 'be.checked', exist = 'exist' } diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 949818fda..2914ba78b 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -46,19 +46,35 @@ Cypress.Commands.add('coveredElementHandler', (selector) => { }); }); +// Clicks a pager page button and waits for the pager to re-render with that +// page active. Without this the next command can click a button still holding +// the previous render's handler, sending the app to the wrong page. +const goToPage = (ordinal: string) => { + cy.get(`[aria-label="${ordinal} Page"]`).first().click(); + cy.get(`[aria-label="${ordinal} Page"]`) + .first() + .should('have.attr', 'aria-current', 'page'); +}; + Cypress.Commands.add('paginationHandler', (route) => { cy.get('header').invoke('css', { display: 'none' }); - cy.get('[aria-label="2nd Page"]').first().click(); + goToPage('2nd'); cy.checkUrl('page=2'); - cy.get('[aria-label="3rd Page"]').first().click(); + goToPage('3rd'); cy.checkUrl('page=3'); - cy.get('[aria-label="1st Page"]').first().click(); + goToPage('1st'); + cy.contains('button', 'Next').last().click(); cy.checkUrl('page=2'); + cy.get('[aria-label="2nd Page"]') + .first() + .should('have.attr', 'aria-current', 'page'); + cy.contains('button', 'Prev').click(); cy.checkUrl(route); + cy.url().should(AssertionEnum.notInclude, 'page='); }); Cypress.Commands.add('checkTableHead', (payload: string[]) => { diff --git a/package.json b/package.json index 5562dca17..ae92293a5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mx-explorer-dapp", "description": "MultiversX Blockchain Explorer", - "version": "2.3.5", + "version": "2.3.6", "author": "MultiversX", "license": "GPL-3.0-or-later", "repository": "multiversx/mx-explorer-dapp",