Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<Provider>` + `<PersistGate>` + `<Interceptor>`.

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

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions cypress/constants/enums.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum AssertionEnum {
contain = 'contain',
include = 'include',
notInclude = 'not.include',
beChecked = 'be.checked',
exist = 'exist'
}
Expand Down
186 changes: 186 additions & 0 deletions cypress/e2e/SearchAfter/SearchAfter.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/// <reference types="cypress" />

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

// the cursor is already in hand at the wall, so 401 is a live page button
cy.get('[aria-label="401st Page"]').first().should('not.be.disabled');

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('crosses the wall on accounts too', () => {
cy.intercept('GET', 'https://devnet-api.multiversx.com/accounts?*').as(
'accounts'
);
cy.visit('/devnet/accounts?page=400');
cy.wait('@accounts').its('request.url').should('include', 'from=9975');

const firstAddress = () =>
cy
.get('[data-testid="accountsTable"] tr', { timeout: CURSOR_TIMEOUT })
.first()
.invoke('text');

firstAddress().then((addressAtWall) => {
cy.get('[data-testid="nextPageButton"]').first().click();

cy.url().should('include', 'page=401');
cy.wait('@accounts').then(({ request }) => {
expect(request.url).to.include('searchAfter=');
expect(request.url).to.not.include('from=');
});

firstAddress().should('not.equal', addressAtWall);
});
});

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=');
});
});
});
22 changes: 19 additions & 3 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 4 additions & 2 deletions src/appConstants/apiFields.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export const TRANSACTIONS_TABLE_FIELDS = [
'guardianSignature',
'relayer',
'isRelayed',
'relayedVersion'
'relayedVersion',
'searchAfter'
];

export const IDENTITIES_FIELDS = [
Expand Down Expand Up @@ -80,7 +81,8 @@ export const BLOCKS_FIELDS = [
'gasPenalized',
'maxGasLimit',
'proposer',
'proposerIdentity'
'proposerIdentity',
'searchAfter'
];

export const LATEST_BLOCKS_FIELDS = [
Expand Down
4 changes: 4 additions & 0 deletions src/appConstants/general.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
7 changes: 6 additions & 1 deletion src/components/AccountsTable/AccountsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const AccountsTable = ({
total={accountsCount}
show={accounts.length > 0}
className='d-flex ms-auto me-auto me-sm-0'
items={accounts}
/>
</div>
</div>
Expand Down Expand Up @@ -147,7 +148,11 @@ export const AccountsTable = ({
</div>
<div className='card-footer table-footer'>
<PageSize />
<Pager total={accountsCount} show={accounts.length > 0} />
<Pager
total={accountsCount}
show={accounts.length > 0}
items={accounts}
/>
</div>
</>
) : (
Expand Down
8 changes: 6 additions & 2 deletions src/components/BlocksTable/BlocksTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ export const BlocksTable = ({
</span>
)}
</h5>
<Pager total={totalBlocks} show={blocks.length > 0} />
<Pager
total={totalBlocks}
show={blocks.length > 0}
items={blocks}
/>
</div>
</div>

Expand Down Expand Up @@ -195,7 +199,7 @@ export const BlocksTable = ({

<div className='card-footer table-footer'>
<PageSize />
<Pager total={totalBlocks} show={blocks.length > 0} />
<Pager total={totalBlocks} show={blocks.length > 0} items={blocks} />
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/DataDecode/dataDecode.styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/EventsTable/EventsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
</div>
</div>
Expand Down Expand Up @@ -104,7 +105,7 @@ export const EventsTable = ({

<div className='card-footer table-footer'>
<PageSize />
<Pager total={totalEvents} show={events.length > 0} />
<Pager total={totalEvents} show={events.length > 0} items={events} />
</div>
</div>
</div>
Expand Down
Loading
Loading