diff --git a/src/pages/Transfers.css b/src/pages/Transfers.css index fd86757..298167c 100644 --- a/src/pages/Transfers.css +++ b/src/pages/Transfers.css @@ -101,3 +101,12 @@ margin-top: 0; } } + +.transfers-snapshot-notice { + margin: 0 0 1rem; + padding: 0.65rem 0.85rem; + border-radius: 8px; + background: rgba(37, 99, 235, 0.12); + color: var(--color-text); + font-size: 0.9rem; +} diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index def8104..4b9bdfe 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -19,6 +19,11 @@ import { useTransfers } from '../hooks/useTransfers.js'; import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js'; +import { + DEFAULT_PAGE_SIZE, + encodeCursor, + resolveTransferPage, +} from '../utils/transferSnapshot.js'; import './Transfers.css'; // Derived from the contract so a new lifecycle state cannot be filterable in @@ -31,11 +36,13 @@ const STATUS_OPTIONS = [ })), ]; -const PAGE_SIZE = 5; - /** * Transfers page: lists all transfers with their status. * Filter state is synced to the URL query string. + * + * Pagination uses a frozen snapshot so concurrent inserts cannot duplicate or + * omit rows while the user pages. Filter changes reset the snapshot; expired + * cursors recover by rebuilding from the live filtered list. */ export default function Transfers() { const { transfers, loading, error, reload } = useTransfers(); @@ -57,23 +64,24 @@ export default function Transfers() { const [selectedIds, setSelectedIds] = useState(() => new Set()); const [selectAllAcross, setSelectAllAcross] = useState(false); - // Pagination state - const [page, setPage] = useState(1); - - // Reset page and selection when filters change + // Snapshot pagination state + const [snapshot, setSnapshot] = useState(null); + const [cursor, setCursor] = useState(null); + const [snapshotNotice, setSnapshotNotice] = useState(null); + // Reset cursor/selection when filters change; snapshot rebuilds below. useEffect(() => { - setPage(1); + setCursor(null); + setSnapshot(null); setSelectedIds(new Set()); setSelectAllAcross(false); + setSnapshotNotice(null); }, [search, status, range]); // Normalise the query-string status so a legacy or provider spelling in a // shared/bookmarked URL (?status=settled) still selects the right rows. const canonicalStatus = normalizeStatus(status); + // Track connectivity so that a reconnect triggers an automatic reload. - // The reload reconciles the true status of transfers that may have been - // created or settled while the connection was down — without resubmitting - // anything. useEffect(() => { if (isOnline && wasOffline && !loading) { setSyncingAfterReconnect(true); @@ -94,11 +102,78 @@ export default function Transfers() { }); }, [transfers, search, status, canonicalStatus, range]); - // Paginated data - const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1; - const pageTransfers = useMemo(() => { - return filteredTransfers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - }, [filteredTransfers, page]); + const filterState = useMemo( + () => ({ search, status: canonicalStatus || status, range }), + [search, status, canonicalStatus, range], + ); + + // Resolve the current page against a frozen snapshot. Membership stays + // deterministic even if `transfers` grows while the user is on page 2. + const resolved = useMemo(() => { + if (loading) { + return { + snapshot: null, + page: { + ok: true, + items: [], + page: 1, + totalPages: 1, + totalCount: 0, + cursor: null, + nextCursor: null, + prevCursor: null, + recovered: false, + }, + recovered: false, + }; + } + return resolveTransferPage(filteredTransfers, { + filters: filterState, + cursor, + snapshot, + pageSize: DEFAULT_PAGE_SIZE, + }); + }, [filteredTransfers, filterState, cursor, snapshot, loading]); + + const pageTransfers = resolved.page.items; + const totalPages = resolved.page.totalPages; + const page = resolved.page.page; + + // Persist a newly minted snapshot and surface recovery after an expired cursor. + useEffect(() => { + if ( + resolved.snapshot && + resolved.snapshot.id !== snapshot?.id + ) { + setSnapshot(resolved.snapshot); + } + if (resolved.recovered && cursor) { + setCursor(null); + setSnapshotNotice( + 'Transfer list was refreshed to keep paging consistent.', + ); + } + }, [resolved, snapshot, cursor]); + + const handlePageChange = useCallback( + (nextPage) => { + const active = resolved.snapshot; + if (!active) return; + const safePage = Math.min(Math.max(1, nextPage), totalPages); + const priorRef = + active.items[(safePage - 1) * active.pageSize - 1] ?? null; + setCursor( + encodeCursor({ + snapshotId: active.id, + scope: active.scope, + page: safePage, + after: priorRef, + }), + ); + setSnapshotNotice(null); + }, + [resolved.snapshot, totalPages], + ); // Selection state computations const allPageSelected = @@ -265,7 +340,11 @@ export default function Transfers() { /> ))} - + ); }; @@ -283,6 +362,17 @@ export default function Transfers() { )} + {snapshotNotice && ( +
+ {snapshotNotice} +
+ )} +
{ + const aTime = Date.parse(a?.createdAt ?? '') || 0; + const bTime = Date.parse(b?.createdAt ?? '') || 0; + if (bTime !== aTime) return bTime - aTime; + return String(b?.id ?? '').localeCompare(String(a?.id ?? '')); + }); +} + +/** + * Canonical filter scope bound into every cursor. + * @param {{search?: string, status?: string, range?: string}} filters + */ +export function filterScopeKey(filters = {}) { + return JSON.stringify({ + search: String(filters.search ?? ''), + status: String(filters.status ?? ''), + range: String(filters.range ?? ''), + }); +} + +/** + * @param {object[]} transfers - already filtered live rows + * @param {{filters?: object, now?: number|Date, pageSize?: number, snapshotId?: string}} [options] + */ +export function createTransferSnapshot(transfers, options = {}) { + const nowMs = + options.now instanceof Date + ? options.now.getTime() + : (options.now ?? Date.now()); + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const scope = filterScopeKey(options.filters); + const items = stableSortTransfers(transfers).map((t) => ({ + id: t.id, + createdAt: t.createdAt, + record: { ...t }, + })); + + return { + id: options.snapshotId ?? mintSnapshotId(nowMs, scope, items.length), + createdAt: new Date(nowMs).toISOString(), + expiresAt: new Date(nowMs + SNAPSHOT_TTL_MS).toISOString(), + scope, + pageSize, + items, + }; +} + +function mintSnapshotId(nowMs, scope, count) { + const seed = `${nowMs}:${scope}:${count}`; + let hash = 0x811c9dc5; + for (let i = 0; i < seed.length; i += 1) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return `snap_${(hash >>> 0).toString(16).padStart(8, '0')}`; +} + +/** + * Opaque cursor: snapshot id + scope + last-seen key + page index. + * @param {{snapshotId: string, scope: string, after?: {createdAt: string, id: string}|null, page: number}} parts + */ +export function encodeCursor(parts) { + const payload = { + s: parts.snapshotId, + scope: parts.scope, + page: parts.page, + after: parts.after + ? { t: parts.after.createdAt, i: parts.after.id } + : null, + }; + return `c_${toBase64Url(JSON.stringify(payload))}`; +} + +/** + * @param {string} cursor + * @returns {{snapshotId: string, scope: string, page: number, after: {createdAt: string, id: string}|null}|null} + */ +export function decodeCursor(cursor) { + if (!cursor || typeof cursor !== 'string' || !cursor.startsWith('c_')) { + return null; + } + try { + const raw = JSON.parse(fromBase64Url(cursor.slice(2))); + if (!raw?.s || !Number.isSafeInteger(raw.page) || raw.page < 1) return null; + return { + snapshotId: raw.s, + scope: raw.scope ?? '', + page: raw.page, + after: raw.after ? { createdAt: raw.after.t, id: raw.after.i } : null, + }; + } catch { + return null; + } +} + +function toBase64Url(value) { + if (typeof btoa === 'function') { + return btoa(unescape(encodeURIComponent(value))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + } + return Buffer.from(value, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function fromBase64Url(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4)); + const b64 = padded + pad; + if (typeof atob === 'function') { + return decodeURIComponent(escape(atob(b64))); + } + return Buffer.from(b64, 'base64').toString('utf8'); +} + +/** + * Is the cursor still valid for this snapshot and filter scope? + * @param {object|null} decoded + * @param {object} snapshot + * @param {object} filters + * @param {number|Date} [now] + */ +export function isCursorInScope(decoded, snapshot, filters, now = Date.now()) { + if (!decoded || !snapshot) return false; + if (decoded.snapshotId !== snapshot.id) return false; + if (decoded.scope !== snapshot.scope) return false; + if (decoded.scope !== filterScopeKey(filters)) return false; + if (isSnapshotExpired(snapshot, now)) return false; + const totalPages = Math.max(1, Math.ceil(snapshot.items.length / snapshot.pageSize)); + if (decoded.page > totalPages) return false; + const prior = snapshot.items[(decoded.page - 1) * snapshot.pageSize - 1] ?? null; + if (!prior) return decoded.after === null; + return decoded.after?.id === prior.id && decoded.after?.createdAt === prior.createdAt; +} + +export function isSnapshotExpired(snapshot, now = Date.now()) { + if (!snapshot?.expiresAt) return true; + const expiry = Date.parse(snapshot.expiresAt); + if (Number.isNaN(expiry)) return true; + const current = now instanceof Date ? now.getTime() : now; + return current >= expiry; +} + +/** + * Page a snapshot. Returns rows from the live transfer map so callers still + * render full records, but membership is frozen to the snapshot. + * + * @param {object} snapshot + * @param {object[]} liveTransfers - full transfer objects (id-keyed lookup) + * @param {{cursor?: string|null, filters?: object, now?: number|Date}} [options] + */ +export function pageFromSnapshot(snapshot, liveTransfers, options = {}) { + const filters = options.filters ?? {}; + const now = options.now ?? Date.now(); + const pageSize = snapshot?.pageSize ?? DEFAULT_PAGE_SIZE; + const byId = new Map((liveTransfers ?? []).map((t) => [t.id, t])); + + const decoded = options.cursor ? decodeCursor(options.cursor) : null; + const scoped = isCursorInScope(decoded, snapshot, filters, now); + + if (!snapshot || isSnapshotExpired(snapshot, now)) { + return { + ok: false, + code: 'snapshot_expired', + items: [], + page: 1, + totalPages: 1, + totalCount: 0, + cursor: null, + nextCursor: null, + prevCursor: null, + recovered: true, + }; + } + + if (options.cursor && !scoped) { + return { + ok: false, + code: 'cursor_expired', + items: [], + page: 1, + totalPages: Math.max(1, Math.ceil(snapshot.items.length / pageSize)), + totalCount: snapshot.items.length, + cursor: null, + nextCursor: null, + prevCursor: null, + recovered: true, + }; + } + + const page = scoped ? Math.max(1, decoded.page) : 1; + const totalCount = snapshot.items.length; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize) || 1); + const start = (page - 1) * pageSize; + const slice = snapshot.items.slice(start, start + pageSize); + // Keep a complete snapshot even if a row disappears or changes its active + // filter after the snapshot was taken. Fresh fields win while it remains live. + const items = slice.map((ref) => byId.get(ref.id) ?? ref.record); + const priorRef = snapshot.items[start - 1] ?? null; + const lastRef = slice[slice.length - 1] ?? null; + const currentCursor = encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page, + after: priorRef, + }); + const nextCursor = + page < totalPages + ? encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: page + 1, + after: lastRef, + }) + : null; + const prevCursor = + page > 1 + ? encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: page - 1, + after: snapshot.items[(page - 2) * pageSize - 1] ?? null, + }) + : null; + + return { + ok: true, + code: null, + items, + page, + totalPages, + totalCount, + cursor: currentCursor, + nextCursor, + prevCursor, + recovered: false, + }; +} + +/** + * Resolve a page, recovering from expired cursors by minting a fresh snapshot. + * @param {object[]} filteredTransfers + * @param {{filters: object, cursor?: string|null, snapshot?: object|null, pageSize?: number, now?: number|Date}} state + */ +export function resolveTransferPage(filteredTransfers, state = {}) { + const now = state.now ?? Date.now(); + const filters = state.filters ?? {}; + let snapshot = state.snapshot ?? null; + let recovered = false; + + // An empty snapshot created while the list was still loading must not freeze + // out rows that arrive a moment later. Concurrent inserts into a non-empty + // snapshot remain frozen (that is the whole point of snapshot paging). + const needsNew = + !snapshot || + isSnapshotExpired(snapshot, now) || + snapshot.scope !== filterScopeKey(filters) || + (snapshot.items.length === 0 && (filteredTransfers?.length ?? 0) > 0); + + if (needsNew) { + snapshot = createTransferSnapshot(filteredTransfers, { + filters, + now, + pageSize: state.pageSize ?? DEFAULT_PAGE_SIZE, + }); + recovered = Boolean(state.snapshot) || Boolean(state.cursor); + } + + const page = pageFromSnapshot(snapshot, filteredTransfers, { + cursor: needsNew ? null : state.cursor, + filters, + now, + }); + + if (!page.ok) { + snapshot = createTransferSnapshot(filteredTransfers, { + filters, + now, + pageSize: state.pageSize ?? DEFAULT_PAGE_SIZE, + }); + const recoveredPage = pageFromSnapshot(snapshot, filteredTransfers, { + cursor: null, + filters, + now, + }); + return { snapshot, page: { ...recoveredPage, recovered: true }, recovered: true }; + } + + return { snapshot, page: { ...page, recovered }, recovered }; +} diff --git a/test/integration/transfers-snapshot-pagination.test.jsx b/test/integration/transfers-snapshot-pagination.test.jsx new file mode 100644 index 0000000..08dc86b --- /dev/null +++ b/test/integration/transfers-snapshot-pagination.test.jsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it } from 'vitest'; +import App from '../../src/App.jsx'; + +function seedTransfers(count) { + const transfers = Array.from({ length: count }, (_, i) => ({ + id: `tx_${1000 + i}`, + recipient: `person${i}@example.com`, + from: 'USD', + to: 'NGN', + sendAmount: 100 + i, + receiveAmount: (100 + i) * 1400, + status: i % 2 === 0 ? 'completed' : 'pending', + createdAt: `2026-06-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + })); + localStorage.setItem('remitflow.transfers', JSON.stringify(transfers)); + return transfers; +} + +async function gotoTransfers() { + window.history.pushState({}, '', '/transfers'); + render(); + await screen.findByRole('heading', { name: /your transfers/i }); + await screen.findByLabelText(/select all transfers on this page/i); +} + +describe('Transfers snapshot pagination', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('pages through a frozen snapshot without gaps', async () => { + const user = userEvent.setup(); + seedTransfers(12); + await gotoTransfers(); + + expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument(); + expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument(); + + // Newest by createdAt: person11 is June 12. + expect( + screen.getByRole('group', { name: /transfer to person11@e/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('group', { name: /transfer to person6@e/i }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /next/i })); + expect(await screen.findByText(/page 2 of 3/i)).toBeInTheDocument(); + expect( + screen.getByRole('group', { name: /transfer to person6@e/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('group', { name: /transfer to person11@e/i }), + ).not.toBeInTheDocument(); + }); + + it('resets to page 1 when filters change', async () => { + const user = userEvent.setup(); + seedTransfers(12); + await gotoTransfers(); + + await user.click(screen.getByRole('button', { name: /next/i })); + expect(await screen.findByText(/page 2 of /i)).toBeInTheDocument(); + + await user.selectOptions( + screen.getByLabelText(/filter by status/i), + 'pending', + ); + + expect(await screen.findByText(/page 1 of /i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); + }); +}); diff --git a/test/unit/transfer-snapshot.test.js b/test/unit/transfer-snapshot.test.js new file mode 100644 index 0000000..9478477 --- /dev/null +++ b/test/unit/transfer-snapshot.test.js @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest'; +import { + SNAPSHOT_TTL_MS, + createTransferSnapshot, + decodeCursor, + encodeCursor, + filterScopeKey, + isCursorInScope, + pageFromSnapshot, + resolveTransferPage, + stableSortTransfers, +} from '../../src/utils/transferSnapshot.js'; + +const NOW = Date.parse('2026-09-24T19:00:00Z'); + +function tx(id, createdAt, extra = {}) { + return { + id, + recipient: `${id}@example.com`, + from: 'USD', + to: 'NGN', + sendAmount: '10.00', + receiveAmount: '1000.00', + status: 'completed', + createdAt, + ...extra, + }; +} + +describe('transferSnapshot', () => { + it('sorts by createdAt desc with id as tie-breaker', () => { + const rows = [ + tx('tx_a', '2026-09-01T10:00:00Z'), + tx('tx_b', '2026-09-01T10:00:00Z'), + tx('tx_c', '2026-09-02T10:00:00Z'), + ]; + expect(stableSortTransfers(rows).map((r) => r.id)).toEqual([ + 'tx_c', + 'tx_b', + 'tx_a', + ]); + }); + + it('keeps pages deterministic when new transfers arrive', () => { + const initial = Array.from({ length: 12 }, (_, i) => + tx(`tx_${String(i).padStart(2, '0')}`, `2026-09-01T10:${String(i).padStart(2, '0')}:00Z`), + ); + const snapshot = createTransferSnapshot(initial, { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + + const page1 = pageFromSnapshot(snapshot, initial, { + filters: { search: '', status: '', range: '' }, + now: NOW, + }); + expect(page1.ok).toBe(true); + expect(page1.items.map((t) => t.id)).toEqual([ + 'tx_11', + 'tx_10', + 'tx_09', + 'tx_08', + 'tx_07', + ]); + + // Concurrent insert of a newer transfer must not push into page 1 of this snapshot. + const withInsert = [ + tx('tx_new', '2026-09-03T10:00:00Z'), + ...initial, + ]; + const page1Again = pageFromSnapshot(snapshot, withInsert, { + cursor: page1.cursor, + filters: { search: '', status: '', range: '' }, + now: NOW, + }); + expect(page1Again.items.map((t) => t.id)).toEqual(page1.items.map((t) => t.id)); + expect(page1Again.items.map((t) => t.id)).not.toContain('tx_new'); + + const page2 = pageFromSnapshot(snapshot, withInsert, { + cursor: page1.nextCursor, + filters: { search: '', status: '', range: '' }, + now: NOW, + }); + expect(page2.items.map((t) => t.id)).toEqual([ + 'tx_06', + 'tx_05', + 'tx_04', + 'tx_03', + 'tx_02', + ]); + }); + + it('keeps snapshot pages complete after a live row disappears and rejects a forged cursor', () => { + const rows = Array.from({ length: 7 }, (_, i) => + tx(`tx_${i}`, `2026-09-01T10:0${i}:00Z`), + ); + const filters = { search: '', status: '', range: '' }; + const snapshot = createTransferSnapshot(rows, { filters, now: NOW, pageSize: 5 }); + const first = pageFromSnapshot(snapshot, rows, { filters, now: NOW }); + const filtered = rows.filter((row) => row.id !== 'tx_1'); + const second = pageFromSnapshot(snapshot, filtered, { + cursor: first.nextCursor, + filters, + now: NOW, + }); + expect(second.ok).toBe(true); + expect(second.items.map((row) => row.id)).toEqual(['tx_1', 'tx_0']); + + const forged = encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: 2, + after: snapshot.items[0], + }); + expect(pageFromSnapshot(snapshot, filtered, { + cursor: forged, + filters, + now: NOW, + }).code).toBe('cursor_expired'); + }); + + it('rejects cursors outside the filter scope', () => { + const rows = [tx('tx_1', '2026-09-01T10:00:00Z')]; + const snapshot = createTransferSnapshot(rows, { + filters: { search: '', status: 'completed', range: '' }, + now: NOW, + }); + const cursor = encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: 1, + after: null, + }); + const decoded = decodeCursor(cursor); + expect( + isCursorInScope(decoded, snapshot, { search: '', status: 'pending', range: '' }, NOW), + ).toBe(false); + expect( + isCursorInScope(decoded, snapshot, { search: '', status: 'completed', range: '' }, NOW), + ).toBe(true); + }); + + it('recovers from an expired snapshot cursor', () => { + const rows = Array.from({ length: 6 }, (_, i) => + tx(`tx_${i}`, `2026-09-01T10:0${i}:00Z`), + ); + const snapshot = createTransferSnapshot(rows, { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + const staleCursor = encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: 2, + after: snapshot.items[4], + }); + + const resolved = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + cursor: staleCursor, + snapshot, + pageSize: 5, + now: NOW + SNAPSHOT_TTL_MS + 1, + }); + + expect(resolved.recovered).toBe(true); + expect(resolved.page.page).toBe(1); + expect(resolved.snapshot.id).not.toBe(snapshot.id); + expect(resolved.page.items).toHaveLength(5); + }); + + it('resets to a new snapshot when filters change', () => { + const rows = [ + tx('tx_1', '2026-09-01T10:00:00Z', { status: 'completed' }), + tx('tx_2', '2026-09-01T11:00:00Z', { status: 'pending' }), + ]; + const first = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + const second = resolveTransferPage( + rows.filter((t) => t.status === 'pending'), + { + filters: { search: '', status: 'pending', range: '' }, + cursor: first.page.cursor, + snapshot: first.snapshot, + now: NOW, + pageSize: 5, + }, + ); + expect(second.snapshot.scope).toBe( + filterScopeKey({ search: '', status: 'pending', range: '' }), + ); + expect(second.snapshot.id).not.toBe(first.snapshot.id); + expect(second.page.items.map((t) => t.id)).toEqual(['tx_2']); + }); + + it('pages a large fixture without gaps inside the snapshot', () => { + const rows = Array.from({ length: 50 }, (_, i) => + tx(`tx_${String(i).padStart(3, '0')}`, `2026-08-01T${String(i % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`), + ); + let state = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + const seen = new Set(state.page.items.map((t) => t.id)); + while (state.page.nextCursor) { + state = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + cursor: state.page.nextCursor, + snapshot: state.snapshot, + now: NOW, + pageSize: 5, + }); + for (const item of state.page.items) { + expect(seen.has(item.id)).toBe(false); + seen.add(item.id); + } + } + expect(seen.size).toBe(50); + }); + + it('rebuilds when an empty loading snapshot later receives rows', () => { + const empty = resolveTransferPage([], { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + expect(empty.snapshot.items).toHaveLength(0); + + const rows = [tx('tx_1', '2026-09-01T10:00:00Z')]; + const filled = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + snapshot: empty.snapshot, + now: NOW, + pageSize: 5, + }); + expect(filled.snapshot.items).toHaveLength(1); + expect(filled.page.items.map((t) => t.id)).toEqual(['tx_1']); + }); +});