diff --git a/src/frontend/src/eth/services/eth-transactions.services.ts b/src/frontend/src/eth/services/eth-transactions.services.ts index 8fc7d27e21c..e33360987bd 100644 --- a/src/frontend/src/eth/services/eth-transactions.services.ts +++ b/src/frontend/src/eth/services/eth-transactions.services.ts @@ -79,6 +79,9 @@ export const reloadEthereumTransactions = (params: { silent?: boolean; }): Promise => loadEthereumTransactions({ ...params, updateOnly: true }); +const hasStoredEthTransactions = (tokenId: TokenId): boolean => + (get(ethTransactionsStore)?.[tokenId] ?? []).length > 0; + const maxEthNativeBlockNumberInStore = (tokenId: TokenId): number | undefined => { const rows = get(ethTransactionsStore)?.[tokenId]; @@ -183,8 +186,10 @@ const loadEthTransactions = async ({ ? await loadEthUserTransactions({ identity, tokenId: transactionTokenId }) : undefined; - // Left alone on a reload: the timer must not rewind pages the user has already scrolled past. - if (!updateOnly) { + // Only while the list is being built from scratch. The periodic refresh comes through here too, + // so resetting the cursor unconditionally would send the next scroll back over pages the user + // already has. + if (!hasStoredEthTransactions(tokenId)) { setEthBackendPaginationCursor({ tokenId, nextStart: stored?.nextStart }); } @@ -215,7 +220,10 @@ const loadEthTransactions = async ({ ethTransactionsStore.update({ tokenId, transaction }) ); } else { - ethTransactionsStore.set({ tokenId, transactions: certifiedTransactions }); + // Prepended rather than set, because this batch is not the whole history: it is the newest + // stored page plus whatever is newer than it. Replacing the slot would throw away every older + // page the user scrolled in - and the periodic refresh runs through here every 30 seconds. + ethTransactionsStore.prepend({ tokenId, transactions: certifiedTransactions }); } // Save newly finalized transactions to backend (fire-and-forget). diff --git a/src/frontend/src/tests/eth/services/eth-transactions.services.spec.ts b/src/frontend/src/tests/eth/services/eth-transactions.services.spec.ts index fdc96a2c05b..e8c89897166 100644 --- a/src/frontend/src/tests/eth/services/eth-transactions.services.spec.ts +++ b/src/frontend/src/tests/eth/services/eth-transactions.services.spec.ts @@ -557,7 +557,14 @@ describe('eth-transactions.services', () => { }); }); - it('should leave the backend cursor alone on a reload', async () => { + it('should leave the backend cursor alone once the list has been built', async () => { + // The periodic refresh runs the same path, so it must not send the next scroll back over + // pages the user already has. + ethTransactionsStore.set({ + tokenId: mockTokenId, + transactions: createMockEthTransactions(3).map((data) => ({ data, certified: false })) + }); + vi.mocked(loadEthUserTransactions).mockResolvedValue({ transactions: createMockEthTransactions(2), newestBlockIndex: 100n, @@ -574,13 +581,61 @@ describe('eth-transactions.services', () => { networkId: mockNetworkId, tokenId: mockTokenId, chainId: mockChainId, - standard: mockStandard, - updateOnly: true + standard: mockStandard }); expect(setEthBackendPaginationCursor).not.toHaveBeenCalled(); }); + it('should keep pages already scrolled in when it refreshes', async () => { + // Hashes are fixed and disjoint: the store deduplicates by hash, so the assertion below + // would be at the mercy of the mock's random ones colliding. + const pagedIn = createMockEthTransactions(4).map((transaction, index) => ({ + ...transaction, + hash: `0xpagedin${index}` + })); + + ethTransactionsStore.set({ + tokenId: mockTokenId, + transactions: pagedIn.map((data) => ({ data, certified: false })) + }); + + // A refresh only ever returns the newest stored page plus anything newer than it. + const newestPage = createMockEthTransactions(2).map((transaction, index) => ({ + ...transaction, + hash: `0xnewest${index}` + })); + + vi.mocked(loadEthUserTransactions).mockResolvedValue({ + transactions: newestPage, + newestBlockIndex: 100n, + oldestBlockIndex: 50n, + nextStart: 60n, + totalStored: 30n + }); + + infuraMocks.mockInfuraGetBlockNumber.mockResolvedValueOnce(150); + mockEthTransactionsProvider.mockResolvedValueOnce([]); + + await loadEthereumTransactions({ + identity: mockIdentity, + networkId: mockNetworkId, + tokenId: mockTokenId, + chainId: mockChainId, + standard: mockStandard + }); + + const rows = get(ethTransactionsStore)?.[mockTokenId]; + + assertNonNullish(rows); + + expect(rows).toHaveLength(pagedIn.length + newestPage.length); + + expect(rows.map(({ data: { hash } }) => hash)).toEqual( + expect.arrayContaining(pagedIn.map(({ hash }) => hash)) + ); + }); + it('should use update method when updateOnly is true', async () => { vi.mocked(loadEthUserTransactions).mockResolvedValue(undefined);