From 8f41c74937cf4d2702e0522e906bd9f2edcd3637 Mon Sep 17 00:00:00 2001 From: tom Date: Tue, 11 Aug 2026 21:22:33 +0200 Subject: [PATCH] Key token transfer rows by token id to stop rows surviving pagination An ERC-1155 batch transfer reaches the API as a single log, which the API flattens into one item per transferred token id. Those items share a (transaction_hash, block_hash, log_index) triple, so the React keys built from that triple collided. React tracks pending removals in a key -> fiber map where duplicates overwrite each other, leaving the shadowed fibers unmounted forever: their rows stayed in the table above the rows of every page that followed. Co-Authored-By: Claude Opus 5 --- .../MultichainTokenTransfersLocal.tsx | 3 +- .../components/list/TokenTransferList.tsx | 3 +- .../components/list/TokenTransferTable.tsx | 3 +- .../pages/index/TokenTransfersLocal.tsx | 3 +- .../pages/index/TokenTransfersTable.spec.tsx | 32 +++++++++++++++++++ .../pages/index/TokenTransfersTable.tsx | 3 +- .../utils/get-token-transfer-key.spec.ts | 28 ++++++++++++++++ .../utils/get-token-transfer-key.ts | 17 ++++++++++ 8 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 src/slices/token-transfer/pages/index/TokenTransfersTable.spec.tsx create mode 100644 src/slices/token-transfer/utils/get-token-transfer-key.spec.ts create mode 100644 src/slices/token-transfer/utils/get-token-transfer-key.ts diff --git a/src/features/multichain/pages/token-transfers/MultichainTokenTransfersLocal.tsx b/src/features/multichain/pages/token-transfers/MultichainTokenTransfersLocal.tsx index e60082f30d5..59d4a9a3f9c 100644 --- a/src/features/multichain/pages/token-transfers/MultichainTokenTransfersLocal.tsx +++ b/src/features/multichain/pages/token-transfers/MultichainTokenTransfersLocal.tsx @@ -9,6 +9,7 @@ import ActionBar from 'src/shell/page/action-bar/ActionBar'; import TokenTransfersListItem from 'src/slices/token-transfer/pages/index/TokenTransfersListItem'; import TokenTransfersTable from 'src/slices/token-transfer/pages/index/TokenTransfersTable'; +import { getTokenTransferKey } from 'src/slices/token-transfer/utils/get-token-transfer-key'; import TokenTypeFilter from 'src/slices/token/components/TokenTypeFilter'; import { useMultichainContext } from 'src/features/multichain/context'; @@ -63,7 +64,7 @@ const MultichainTokenTransfersLocal = ({ query, typeFilter, onTokenTypesChange } { query.data?.items.slice(0, renderedItemsNum).map((item, index) => ( { data.slice(0, renderedItemsNum).map((item, index) => ( ( { { query.data?.items.slice(0, renderedItemsNum).map((item, index) => ( diff --git a/src/slices/token-transfer/pages/index/TokenTransfersTable.spec.tsx b/src/slices/token-transfer/pages/index/TokenTransfersTable.spec.tsx new file mode 100644 index 00000000000..5d021cd31f1 --- /dev/null +++ b/src/slices/token-transfer/pages/index/TokenTransfersTable.spec.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest/lib'; + +import { erc1155A, erc1155B, erc1155C, erc1155D, erc20, erc721 } from '../../mocks'; +import TokenTransfersTable from './TokenTransfersTable'; + +const BATCH_PAGE = [ erc1155A, erc1155B, erc1155C, erc1155D ]; +const NEXT_PAGE = [ erc20, erc721 ]; + +describe('TokenTransfersTable', () => { + afterEach(cleanup); + + it('renders every item of a batch transfer', () => { + const { container } = render(); + + expect(container.querySelectorAll('tbody tr')).toHaveLength(BATCH_PAGE.length); + }); + + it('drops all rows of the previous page when the next page arrives', () => { + const { container, rerender } = render(); + + rerender(); + + expect(container.querySelectorAll('tbody tr')).toHaveLength(NEXT_PAGE.length); + expect(container.textContent).not.toContain(erc1155A.transaction_hash?.slice(0, 10)); + }); +}); diff --git a/src/slices/token-transfer/pages/index/TokenTransfersTable.tsx b/src/slices/token-transfer/pages/index/TokenTransfersTable.tsx index da48a51930a..afd804fd8db 100644 --- a/src/slices/token-transfer/pages/index/TokenTransfersTable.tsx +++ b/src/slices/token-transfer/pages/index/TokenTransfersTable.tsx @@ -12,6 +12,7 @@ import useLazyRenderedList from 'src/shared/lists/useLazyRenderedList'; import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'src/toolkit/chakra/table'; +import { getTokenTransferKey } from '../../utils/get-token-transfer-key'; import TokenTransferTableItem from './TokenTransfersTableItem'; interface Props { @@ -45,7 +46,7 @@ const TokenTransferTable = ({ items, top, isLoading, chainData, resetKey }: Prop { items?.slice(0, renderedItemsNum).map((item, index) => ( { + it('tells apart the items of a single ERC-1155 batch transfer', () => { + const batch = [ erc1155A, erc1155B, erc1155C, erc1155D ]; + + expect(new Set(batch.map(getTokenTransferKey)).size).toBe(4); + }); + + it('tells apart transfers from the same block that differ only in log index', () => { + const first = getTokenTransferKey(erc1155A); + const second = getTokenTransferKey({ ...erc1155A, log_index: erc1155A.log_index + 1 }); + + expect(first).not.toBe(second); + }); + + it('tells apart transfers that carry no token id', () => { + const fungible = getTokenTransferKey(erc20); + + expect(fungible).not.toBe(getTokenTransferKey({ ...erc20, transaction_hash: '0xdeadbeef' })); + expect(fungible).not.toBe(getTokenTransferKey(erc1155A)); + }); +}); diff --git a/src/slices/token-transfer/utils/get-token-transfer-key.ts b/src/slices/token-transfer/utils/get-token-transfer-key.ts new file mode 100644 index 00000000000..73538aeb85b --- /dev/null +++ b/src/slices/token-transfer/utils/get-token-transfer-key.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; + +// An ERC-1155 batch transfer reaches the API as a single log, which the API flattens into one item +// per transferred token id — so those items share a (transaction_hash, block_hash, log_index) triple +// and only the token id tells them apart. +// +// Equal keys are not merely untidy here: React tracks pending removals in a key -> fiber map, where +// duplicates overwrite each other, and the shadowed fibers are then never unmounted. Their rows stay +// in the DOM through pagination, stacked above the rows of every page that follows. +// https://github.com/blockscout/frontend/issues/3628 +export function getTokenTransferKey(item: schemas['TokenTransfer']): string { + const tokenId = item.total && 'token_id' in item.total ? item.total.token_id : null; + + return [ item.transaction_hash, item.block_hash, item.log_index, tokenId ].join('_'); +}