Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ describe('TablePicker container', () => {
'44922483.5',
undefined,
undefined,
false,
)
})

Expand Down
34 changes: 25 additions & 9 deletions src/containers/Token/MPT/TablePicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export const TablePicker = ({
account: issuer,
tokenId: mptIssuanceId,
limit: 50,
// Skip non-matching pages until a screenful is found or the issuance's
// creation is reached, so older transactions show up
minResults: 20,
useP2PSocket: true,
retry: 2,
})

const [tablePickerState, setTablePickerState] = useState<
Expand All @@ -71,15 +76,26 @@ export const TablePicker = ({
}

// Helper to render transaction table
const renderTransactionTable = () => (
<TransactionTable
transactions={data?.pages?.map((page: any) => page.transactions).flat()}
loading={loading}
emptyMessage={error?.message ? t(error.message as any) : ''}
onLoadMore={() => fetchNextPage()}
hasAdditionalResults={hasNextPage}
/>
)
const renderTransactionTable = () => {
const transactions = data?.pages
?.map((page: any) => page.transactions)
.flat()
// The page budget ran out before any match: more history is left, so
// "No transactions found." would be wrong
const tryLoading = !error && hasNextPage && transactions?.length === 0
const emptyMessage = tryLoading
? 'get_account_transactions_try'
: error?.message
return (
<TransactionTable
transactions={transactions}
loading={loading}
emptyMessage={emptyMessage ? t(emptyMessage as any) : ''}
onLoadMore={() => fetchNextPage()}
hasAdditionalResults={hasNextPage}
/>
)
}

// Transform transfer amount.value using assetScale
const transfersFormatted: LOSTransfer[] = useMemo(() => {
Expand Down
3 changes: 3 additions & 0 deletions src/containers/Token/MPT/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ export const MPT = () => {
{mptIssuanceId && mptokenIssuance && (
<div className="section">
<TablePicker
// Remount per MPT so a cached MPT is reused rather than refetched:
// react-query refetches every cached page at once on a key change
key={mptIssuanceId}
mptIssuanceId={mptIssuanceId}
issuer={mptokenIssuance.issuer}
assetScale={mptokenIssuance.assetScale}
Expand Down
151 changes: 149 additions & 2 deletions src/containers/Token/MPT/test/TablePicker/TablePicker.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { render } from '@testing-library/react'
import { render, waitFor } from '@testing-library/react'
import { I18nextProvider } from 'react-i18next'
import { BrowserRouter as Router } from 'react-router'
import { QueryClientProvider } from 'react-query'
import { QueryClient, QueryClientProvider } from 'react-query'
import moxios from 'moxios'
import i18n from '../../../../../i18n/testConfig'
import { TablePicker } from '../../TablePicker'
Expand Down Expand Up @@ -168,4 +168,151 @@ describe('MPT TablePicker container', () => {
container.querySelectorAll('.token-transaction-table-container'),
).toHaveLength(1)
})

describe('empty transactions message', () => {
beforeEach(() => {
jest.useFakeTimers()
})

afterEach(() => {
jest.useRealTimers()
})

it('asks to load more when the page budget runs out with history left', async () => {
const { container } = renderComponent(async () => ({
transactions: [],
marker: 'more',
}))

await waitFor(
() =>
expect(
container.querySelector('.empty-transactions-message'),
).toHaveTextContent('get_account_transactions_try'),
{ timeout: 20000 },
)
expect(getAccountTransactions).toHaveBeenCalledTimes(50)
})

it('reports no transactions at the end of history', async () => {
const { container } = renderComponent(async () => ({
transactions: [],
marker: undefined,
}))

await waitFor(() =>
expect(
container.querySelector('.empty-transactions-message'),
).toHaveTextContent('no_transactions_message'),
)
})
})

describe('returning to an MPT already viewed', () => {
const MPT_A = '00000001A407AF5856CCF3C42619DAA925813FC955C72983'
const MPT_B = '00000002A407AF5856CCF3C42619DAA925813FC955C72983'

beforeEach(() => {
jest.useFakeTimers()
;(getAccountTransactions as jest.Mock).mockImplementation(
async (_account: string, _tokenId: string, marker: string) => ({
transactions: [],
marker: String((marker ? Number(marker) : 0) + 1),
}),
)
})

afterEach(() => {
jest.useRealTimers()
})

// The app's query defaults, with react-query's default 5-minute cache, so
// MPT A's pages are still cached when the visitor comes back to it
const appQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
},
},
})

const renderPicker = (
queryClient: QueryClient,
id: string,
key: string,
) => (
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<Router>
<TablePicker
key={key}
mptIssuanceId={id}
issuer={TEST_ISSUER}
assetScale={0}
holdersPagination={{
currentPage: 1,
setCurrentPage: jest.fn(),
pageSize: 20,
total: 0,
}}
holdersLoading={false}
transfersPagination={{
currentPage: 1,
setCurrentPage: jest.fn(),
pageSize: 10,
total: 0,
hasMore: false,
hasPrevPage: false,
}}
transfersSorting={{
sortField: 'timestamp',
setSortField: jest.fn(),
sortOrder: 'desc',
setSortOrder: jest.fn(),
}}
transfersLoading={false}
/>
</Router>
</I18nextProvider>
</QueryClientProvider>
)

// View A for a full round, then B, then return to A, keying the table by
// MPT as MPT/index.tsx does. Returns the requests made after returning to A.
const visitAThenBThenA = async () => {
const queryClient = appQueryClient()
const ui = (id: string) => renderPicker(queryClient, id, id)
const { rerender } = render(ui(MPT_A))
await waitFor(
() => expect(getAccountTransactions).toHaveBeenCalledTimes(50),
{
timeout: 60000,
},
)
rerender(ui(MPT_B))
await waitFor(
() => expect(getAccountTransactions).toHaveBeenCalledTimes(100),
{
timeout: 60000,
},
)
rerender(ui(MPT_A))
await waitFor(
() =>
expect(
(getAccountTransactions as jest.Mock).mock.calls.length,
).toBeGreaterThan(149),
{ timeout: 3000 },
).catch(() => {})
return (getAccountTransactions as jest.Mock).mock.calls.length - 100
}

it('reuses the cached pages instead of refetching them', async () => {
expect(await visitAThenBThenA()).toBe(0)
})
})
})
70 changes: 63 additions & 7 deletions src/containers/Token/shared/hooks/useAccountTransactions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useContext, useEffect, useMemo, useState } from 'react'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useInfiniteQuery } from 'react-query'

import { useAnalytics } from '../../../shared/analytics'
Expand All @@ -16,11 +16,23 @@ const MAX_AUTO_FETCH_ATTEMPTS = 10
*/
const AUTO_FETCH_DELAY_MS = 200

/**
* Maximum pages auto-fetched per round (initial load or "Load more") with
* minResults, so a long run of non-matching history can't scan unbounded.
*/
const MAX_PAGES_PER_ROUND = 50

interface UseAccountTransactionsOptions {
account: string
/** Token identifier: currency code for IOU, MPT issuance ID for MPT */
tokenId: string
limit?: number
/** Auto-fetch until this many matches load (or the page budget runs out); "Load more" adds as many again */
minResults?: number
/** Query the P2P (Clio) server, which keeps full history, when configured */
useP2PSocket?: boolean
/** Number of times to retry a failed page fetch */
retry?: number
}

/**
Expand All @@ -29,12 +41,17 @@ interface UseAccountTransactionsOptions {
* Transactions are filtered client-side after fetching from rippled.
* If a fetched page contains no matching transactions but more pages exist,
* automatically fetches additional pages (up to MAX_AUTO_FETCH_ATTEMPTS)
* to find matching transactions.
* to find matching transactions. With minResults, it keeps fetching until
* that many matches are loaded, there is no next page, or
* MAX_PAGES_PER_ROUND pages have been fetched this round.
*/
export function useAccountTransactions({
account,
tokenId,
limit,
minResults = 0,
useP2PSocket = false,
retry = 0,
}: UseAccountTransactionsOptions) {
const { trackException } = useAnalytics()
const rippledSocket = useContext(SocketContext)
Expand All @@ -54,13 +71,15 @@ export function useAccountTransactions({
pageParam,
limit,
rippledSocket,
useP2PSocket,
).catch((errorResponse: Error) => {
const errorLocation = `transactions ${account}.${tokenId} at ${pageParam}`
trackException(`${errorLocation} --- ${JSON.stringify(errorResponse)}`)
throw new Error('get_account_transactions_failed')
}),
{
getNextPageParam: (lastPage) => lastPage.marker,
retry,
},
)

Expand All @@ -74,14 +93,43 @@ export function useAccountTransactions({
return pages[pages.length - 1]?.transactions?.length || 0
}, [data])

const loadedCount = useMemo(
() =>
data?.pages.reduce(
(count, page) => count + (page.transactions?.length || 0),
0,
) ?? 0,
[data],
)
const pageCount = data?.pages.length ?? 0
// Already-cached pages count as a finished round, so showing them again
// (e.g. going back to an MPT) waits for "Load more" instead of fetching
const initialTarget = pageCount > 0 ? loadedCount : minResults
const [targetCount, setTargetCount] = useState(initialTarget)
// Pages loaded when the current round started
const [roundStartPage, setRoundStartPage] = useState(0)
// The hook stays mounted when navigating between tokens
useEffect(() => {
setTargetCount(initialTarget)
setRoundStartPage(0)
// eslint-disable-next-line react-hooks/exhaustive-deps -- only when the token changes, not as pages load
}, [account, tokenId, minResults])
const hasRoundBudget = pageCount - roundStartPage < MAX_PAGES_PER_ROUND

const lastPageWasEmpty = lastPageTransactionCount === 0
const canAutoFetch = hasNextPage === true && !isLoading
// Don't auto-fetch after a failure, or a failing page is re-requested forever
const canAutoFetch = hasNextPage === true && !isLoading && !error
const [autoFetchAttempts, setAutoFetchAttempts] = useState(0)
const hasRemainingAttempts = autoFetchAttempts < MAX_AUTO_FETCH_ATTEMPTS
// Auto-fetch if the last page returned no matching transactions
// Auto-fetch if the last page returned no matching transactions, or until
// minResults matches are loaded when set
const shouldAutoFetch =
lastPageWasEmpty && canAutoFetch && hasRemainingAttempts
canAutoFetch &&
(minResults
? loadedCount < targetCount && hasRoundBudget
: lastPageWasEmpty && hasRemainingAttempts)

// Re-run per loaded page: shouldAutoFetch can stay true across a fetch
useEffect(() => {
if (!shouldAutoFetch) {
return undefined
Expand All @@ -93,17 +141,25 @@ export function useAccountTransactions({
}, AUTO_FETCH_DELAY_MS)

return () => clearTimeout(timer)
}, [shouldAutoFetch, fetchNextPage])
}, [shouldAutoFetch, fetchNextPage, pageCount])

// Keep loading true while auto-fetch is pending to prevent UI flickering
// between "Load More" button and loading spinner
const loading = isLoading || shouldAutoFetch

// With minResults, "Load more" starts a new round: it raises the target so
// empty pages are skipped, and gets a fresh page budget
const loadMore = useCallback(() => {
setTargetCount(loadedCount + minResults)
setRoundStartPage(pageCount)
return fetchNextPage()
}, [loadedCount, minResults, pageCount, fetchNextPage])

return {
data,
error,
loading,
fetchNextPage,
fetchNextPage: minResults ? loadMore : fetchNextPage,
hasNextPage: hasNextPage ?? false,
}
}
Loading
Loading