From 056ae14930ea019870935c7b5632ecdc5a8eb0bd Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 18:03:57 -0400 Subject: [PATCH 01/14] perf(transfers): debounce search and cancel stale filtered queries Make transfer search and filtering deterministic at scale: every list request carries actor scope, obsolete in-flight queries are aborted, stable sort + result caps bound work, and stale responses cannot overwrite the active filter set. --- src/hooks/useTransfers.js | 106 ++++++++++-- src/pages/Transfers.jsx | 109 +++++++----- src/services/api.js | 79 ++++++++- src/services/contracts/transfer.js | 2 + src/services/wallet.js | 2 +- src/utils/transferSearch.js | 153 +++++++++++++++++ test/integration/transfers-filter.test.jsx | 6 +- .../transfers-search-scale.test.jsx | 64 +++++++ test/unit/list-transfers-search.test.js | 139 +++++++++++++++ test/unit/transfer-search.test.js | 160 ++++++++++++++++++ test/unit/useTransfers-search-race.test.jsx | 119 +++++++++++++ 11 files changed, 877 insertions(+), 62 deletions(-) create mode 100644 src/utils/transferSearch.js create mode 100644 test/integration/transfers-search-scale.test.jsx create mode 100644 test/unit/list-transfers-search.test.js create mode 100644 test/unit/transfer-search.test.js create mode 100644 test/unit/useTransfers-search-race.test.jsx diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index dea1e61..9e3448c 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -1,28 +1,88 @@ - -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { listTransfers, createTransfer } from '../services/api.js'; import { ContractViolationError } from '../services/contracts/schema.js'; import { getUserErrorMessage, normalizeError } from '../services/errors.js'; +import { DEMO_PUBLIC_KEY } from '../services/wallet.js'; +import { + DEFAULT_RESULT_CAP, + transferQueryScopeKey, +} from '../utils/transferSearch.js'; + +function isAbortError(err) { + return ( + err?.name === 'AbortError' || + err?.code === 'ERR_CANCELED' || + err?.code === 'ECONNABORTED' + ); +} /** * Hook for loading and creating transfers. + * + * List requests always carry an actor scope, honour AbortSignal cancellation, + * and ignore stale responses so a slow reply for an old filter cannot + * overwrite the current result set. + * + * @param {{ + * actorId?: string, + * filters?: {search?: string, status?: string, range?: string}, + * limit?: number, + * }} [options] * @returns {{transfers: Array, loading: boolean, error: string|null, * retryable: boolean, reload: Function|undefined, addTransfer: Function}} */ -export function useTransfers() { +export function useTransfers(options = {}) { + const actorId = options.actorId || DEMO_PUBLIC_KEY; + const filters = options.filters || {}; + const limit = options.limit ?? DEFAULT_RESULT_CAP; + const [transfers, setTransfers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [retryable, setRetryable] = useState(false); + const requestGen = useRef(0); + const abortRef = useRef(null); + + const scopeKey = transferQueryScopeKey({ + actorId, + search: filters.search, + status: filters.status, + range: filters.range, + limit, + }); + const reload = useCallback(async () => { + const gen = ++requestGen.current; + const expectedScope = scopeKey; + + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); setError(null); setRetryable(false); try { - const data = await listTransfers(); + const data = await listTransfers({ + actorId, + search: filters.search, + status: filters.status, + range: filters.range, + limit, + signal: controller.signal, + }); + // Drop stale replies: a newer filter/request already superseded this one. + if (gen !== requestGen.current || expectedScope !== scopeKey) { + return; + } setTransfers(data); } catch (err) { + if (isAbortError(err) || gen !== requestGen.current) { + return; + } if (err instanceof ContractViolationError) { // A schema change, not a flaky request. Retrying will not help, and // showing an empty list would imply the transfers no longer exist. @@ -37,19 +97,43 @@ export function useTransfers() { setRetryable(normalized.retryable); } } finally { - setLoading(false); + if (gen === requestGen.current) { + setLoading(false); + } } - }, []); + }, [actorId, filters.search, filters.status, filters.range, limit, scopeKey]); useEffect(() => { reload(); + return () => { + if (abortRef.current) { + abortRef.current.abort(); + } + }; }, [reload]); - const addTransfer = useCallback(async (payload) => { - const created = await createTransfer(payload); - setTransfers((prev) => [created, ...prev]); - return created; - }, []); + const addTransfer = useCallback( + async (payload) => { + const created = await createTransfer({ + ...payload, + actorId: payload?.actorId || actorId, + }); + setTransfers((prev) => { + // Prepend only when the new row matches the active filter scope. + const matchesSearch = + !filters.search || + String(created.recipient ?? '') + .toLowerCase() + .includes(String(filters.search).toLowerCase()); + const matchesStatus = + !filters.status || created.status === filters.status; + if (!matchesSearch || !matchesStatus) return prev; + return [created, ...prev].slice(0, limit); + }); + return created; + }, + [actorId, filters.search, filters.status, limit], + ); // Existing consumers use reload for both pull-to-refresh and the error-state // retry action. Withhold it only while a non-retryable error is displayed. diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index def8104..809b1c1 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -2,10 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import Chart from '../components/Chart.jsx'; import { formatMoney, parseDecimal } from '../utils/money.js'; -import { - TRANSFER_STATUSES, - normalizeStatus, -} from '../services/contracts/transfer.js'; +import { TRANSFER_STATUSES } from '../services/contracts/transfer.js'; import TransferRow from '../components/TransferRow.jsx'; import { TRANSFER_STATUS_LABELS } from '../components/StatusBadge.jsx'; import Skeleton from '../components/Skeleton.jsx'; @@ -16,9 +13,15 @@ import Pagination from '../components/Pagination.jsx'; import PullToRefresh from '../components/PullToRefresh.jsx'; import SelectionToolbar from '../components/SelectionToolbar.jsx'; import { useTransfers } from '../hooks/useTransfers.js'; +import { useDebouncedValue } from '../hooks/useDebouncedValue.js'; import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; -import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js'; +import { DATE_RANGE_PRESETS } from '../utils/dateRange.js'; +import { DEMO_PUBLIC_KEY } from '../services/wallet.js'; +import { + DEFAULT_RESULT_CAP, + SEARCH_DEBOUNCE_MS, +} from '../utils/transferSearch.js'; import './Transfers.css'; // Derived from the contract so a new lifecycle state cannot be filterable in @@ -35,11 +38,13 @@ const PAGE_SIZE = 5; /** * Transfers page: lists all transfers with their status. - * Filter state is synced to the URL query string. + * Filter state is synced to the URL query string. Free-text search is + * debounced before it drives the actor-scoped list query so keystrokes do + * not fan out requests; obsolete in-flight queries are aborted by the hook. */ export default function Transfers() { - const { transfers, loading, error, reload } = useTransfers(); - const { locale } = useApp(); + const { locale, wallet } = useApp(); + const actorId = wallet?.publicKey || DEMO_PUBLIC_KEY; const isOnline = useOnlineStatus(); const [searchParams, setSearchParams] = useSearchParams(); @@ -49,9 +54,48 @@ export default function Transfers() { const [wasOffline, setWasOffline] = useState(false); const [syncingAfterReconnect, setSyncingAfterReconnect] = useState(false); - const search = searchParams.get('search') || ''; const status = searchParams.get('status') || ''; const range = searchParams.get('range') || ''; + const urlSearch = searchParams.get('search') || ''; + + // Draft input updates immediately; the debounced value drives URL + query. + const [searchDraft, setSearchDraft] = useState(urlSearch); + const debouncedSearch = useDebouncedValue(searchDraft, SEARCH_DEBOUNCE_MS); + + // Keep draft aligned when the URL changes externally (back/forward, clear). + useEffect(() => { + setSearchDraft(urlSearch); + }, [urlSearch]); + + // Publish debounced search into the URL so shareable links stay accurate. + useEffect(() => { + setSearchParams( + (prev) => { + const current = prev.get('search') || ''; + if (current === debouncedSearch) return prev; + const next = new URLSearchParams(prev); + if (debouncedSearch) next.set('search', debouncedSearch); + else next.delete('search'); + return next; + }, + { replace: true }, + ); + }, [debouncedSearch, setSearchParams]); + + const queryFilters = useMemo( + () => ({ + search: debouncedSearch, + status, + range, + }), + [debouncedSearch, status, range], + ); + + const { transfers, loading, error, reload } = useTransfers({ + actorId, + filters: queryFilters, + limit: DEFAULT_RESULT_CAP, + }); // Selection state const [selectedIds, setSelectedIds] = useState(() => new Set()); @@ -60,39 +104,29 @@ export default function Transfers() { // Pagination state const [page, setPage] = useState(1); - // Reset page and selection when filters change + // Reset page and selection when the committed filters change useEffect(() => { setPage(1); setSelectedIds(new Set()); setSelectAllAcross(false); - }, [search, status, range]); + }, [debouncedSearch, 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) { + if (isOnline && wasOffline && !loading && reload) { setSyncingAfterReconnect(true); - reload().finally(() => { + Promise.resolve(reload()).finally(() => { setSyncingAfterReconnect(false); }); } setWasOffline(!isOnline); }, [isOnline, wasOffline, loading, reload]); - const filteredTransfers = useMemo(() => { - return transfers.filter((t) => { - if (status && normalizeStatus(t.status) !== canonicalStatus) return false; - if (search && !t.recipient.toLowerCase().includes(search.toLowerCase())) - return false; - if (!isWithinDateRange(t.createdAt, range)) return false; - return true; - }); - }, [transfers, search, status, canonicalStatus, range]); + // API already applies actor scope, filters, stable sort, and the result cap. + const filteredTransfers = transfers; // Paginated data const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1; @@ -112,17 +146,9 @@ export default function Transfers() { : selectedIds.size; const hasMorePages = totalPages > 1; - const handleSearchChange = useCallback( - (e) => { - const value = e.target.value; - setSearchParams((prev) => { - if (value) prev.set('search', value); - else prev.delete('search'); - return prev; - }); - }, - [setSearchParams], - ); + const handleSearchChange = useCallback((e) => { + setSearchDraft(e.target.value); + }, []); const handleStatusChange = useCallback( (e) => { @@ -148,7 +174,7 @@ export default function Transfers() { [setSearchParams], ); - const hasActiveFilters = Boolean(search || status || range); + const hasActiveFilters = Boolean(debouncedSearch || status || range); // Selection handlers const handleToggleSelect = useCallback((id) => { @@ -188,6 +214,11 @@ export default function Transfers() { setSelectAllAcross(false); }, []); + const handleClearFilters = useCallback(() => { + setSearchDraft(''); + setSearchParams({}); + }, [setSearchParams]); + const renderContent = () => { if (loading) { return ( @@ -215,7 +246,7 @@ export default function Transfers() { } action={ hasActiveFilters ? ( - + ) : ( @@ -288,7 +319,7 @@ export default function Transfers() { type="search" className="transfers-filters-search" placeholder="Search by recipient…" - value={search} + value={searchDraft} onChange={handleSearchChange} aria-label="Search transfers by recipient" /> diff --git a/src/services/api.js b/src/services/api.js index d8d2797..ce71f6e 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -11,6 +11,13 @@ import { parseTransferList, transferContract, } from './contracts/transfer.js'; +import { DEMO_PUBLIC_KEY } from './wallet.js'; +import { + DEFAULT_RESULT_CAP, + applyTransferSearch, + normalizeTransferQuery, + requireActorId, +} from '../utils/transferSearch.js'; const STORAGE_KEY = 'remitflow.transfers'; @@ -25,6 +32,7 @@ const SEED_TRANSFERS = [ receiveAmount: 294620, status: 'completed', createdAt: '2026-05-28T10:15:00Z', + actorId: DEMO_PUBLIC_KEY, }, { id: 'tx_1002', @@ -35,6 +43,7 @@ const SEED_TRANSFERS = [ receiveAmount: 9920, status: 'pending', createdAt: '2026-06-02T08:42:00Z', + actorId: DEMO_PUBLIC_KEY, }, ]; @@ -67,18 +76,57 @@ function reportRejected(rejected) { } } +function abortedError() { + if (typeof DOMException === 'function') { + return new DOMException( + 'The transfer list request was aborted.', + 'AbortError', + ); + } + const err = new Error('The transfer list request was aborted.'); + err.name = 'AbortError'; + return err; +} + /** - * List all transfers, newest first. + * List transfers for one actor, newest first, with optional filters. + * + * Every call must carry `actorId` so results cannot cross wallet scope. + * Pass `signal` to cancel an obsolete in-flight query when filters change. + * Results are stably sorted and capped so large histories stay responsive. * * One malformed record is dropped and logged so the rest of the list still * renders. A response where *every* record fails is a schema change, not bad * data, and is raised so the UI can say so instead of showing "no transfers". * - * @returns {Promise} contract-normalised transfers + * @param {{ + * actorId?: string, + * search?: string, + * status?: string, + * range?: string, + * limit?: number, + * signal?: AbortSignal, + * }} [options] + * @returns {Promise} contract-normalised transfers */ -export function listTransfers() { +export function listTransfers(options = {}) { + const actorId = requireActorId(options.actorId ?? DEMO_PUBLIC_KEY); + const query = normalizeTransferQuery({ + actorId, + search: options.search, + status: options.status, + range: options.range, + limit: options.limit ?? DEFAULT_RESULT_CAP, + }); + const signal = options.signal; + return new Promise((resolve, reject) => { - setTimeout(() => { + if (signal?.aborted) { + reject(abortedError()); + return; + } + + const timer = setTimeout(() => { try { const { transfers, rejected, breaking } = parseTransferList(read(), { source: 'listTransfers', @@ -94,15 +142,23 @@ export function listTransfers() { ); } if (rejected.length) reportRejected(rejected); - resolve( - transfers - .slice() - .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), - ); + + const { items } = applyTransferSearch(transfers, query, { + legacyActorId: DEMO_PUBLIC_KEY, + }); + resolve(items); } catch (error) { reject(error); } }, 400); + + if (signal) { + const onAbort = () => { + clearTimeout(timer); + reject(abortedError()); + }; + signal.addEventListener('abort', onAbort, { once: true }); + } }); } @@ -120,12 +176,17 @@ export function createTransfer(payload) { return new Promise((resolve, reject) => { setTimeout(() => { try { + const actorId = + typeof payload?.actorId === 'string' && payload.actorId.trim() + ? payload.actorId.trim() + : DEMO_PUBLIC_KEY; const transfer = parseTransfer( { id: 'tx_' + Date.now(), status: 'pending', createdAt: new Date().toISOString(), ...payload, + actorId, }, { source: 'createTransfer' }, ); diff --git a/src/services/contracts/transfer.js b/src/services/contracts/transfer.js index ce4e901..e95c76f 100644 --- a/src/services/contracts/transfer.js +++ b/src/services/contracts/transfer.js @@ -68,6 +68,8 @@ export const transferContract = defineContract({ rate: { type: 'decimal', required: false, min: 0 }, expiresAt: { type: 'timestamp', required: false }, failureReason: { type: 'string', required: false, nullable: true }, + // Sender scope. Absent on legacy records; list queries always supply actorId. + actorId: { type: 'string', required: false, minLength: 1 }, }, }); diff --git a/src/services/wallet.js b/src/services/wallet.js index 73e5929..34ae28a 100644 --- a/src/services/wallet.js +++ b/src/services/wallet.js @@ -5,7 +5,7 @@ const STORAGE_KEY = 'remitflow.wallet'; // A deterministic fake Stellar public key for demo purposes. -const DEMO_PUBLIC_KEY = +export const DEMO_PUBLIC_KEY = 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF'; /** diff --git a/src/utils/transferSearch.js b/src/utils/transferSearch.js new file mode 100644 index 0000000..8580bad --- /dev/null +++ b/src/utils/transferSearch.js @@ -0,0 +1,153 @@ +/** + * Deterministic transfer search, filter, sort, and result capping. + * + * Client-side filter fan-out races and unbounded scans get slow as history + * grows. These helpers define one canonical filter/sort/scope semantics used + * by the list API and the Transfers page so stale responses, missing actor + * scope, and uncapped result sets cannot silently corrupt the UI. + */ + +import { normalizeStatus } from '../services/contracts/transfer.js'; +import { isWithinDateRange } from './dateRange.js'; + +/** Debounce window for free-text search queries (ms). */ +export const SEARCH_DEBOUNCE_MS = 300; + +/** Default maximum rows a single list query may return. */ +export const DEFAULT_RESULT_CAP = 100; + +/** Hard ceiling — callers may request less, never more. */ +export const MAX_RESULT_CAP = 500; + +/** + * @param {unknown} actorId + * @returns {string} + */ +export function requireActorId(actorId) { + if (typeof actorId !== 'string' || actorId.trim() === '') { + throw new Error('actorId is required on every transfer list request'); + } + return actorId.trim(); +} + +/** + * Canonical filter bag bound into every request and stale-response check. + * @param {{search?: string, status?: string, range?: string, actorId?: string, limit?: number}} input + */ +export function normalizeTransferQuery(input = {}) { + const actorId = requireActorId(input.actorId); + const search = String(input.search ?? '').trim(); + const statusRaw = String(input.status ?? '').trim(); + const status = statusRaw ? (normalizeStatus(statusRaw) ?? statusRaw) : ''; + const range = String(input.range ?? '').trim(); + const requested = + Number.isFinite(input.limit) && input.limit > 0 + ? Math.floor(input.limit) + : DEFAULT_RESULT_CAP; + const limit = Math.min(Math.max(requested, 1), MAX_RESULT_CAP); + + return { actorId, search, status, range, limit }; +} + +/** + * Stable string identity for a query — used to ignore stale responses. + * @param {{search?: string, status?: string, range?: string, actorId?: string, limit?: number}} query + */ +export function transferQueryScopeKey(query) { + const normalized = normalizeTransferQuery(query); + return JSON.stringify(normalized); +} + +/** + * Whether a transfer belongs to the requesting actor. + * Legacy rows without actorId are only visible to the supplied legacyActor + * (the historical demo owner) so migration does not leak across wallets. + * + * @param {object} transfer + * @param {string} actorId + * @param {{legacyActorId?: string}} [options] + */ +export function isVisibleToActor(transfer, actorId, options = {}) { + if (!actorId) return false; + const owner = transfer?.actorId; + if (typeof owner === 'string' && owner.length > 0) { + return owner === actorId; + } + const legacy = options.legacyActorId; + return Boolean(legacy) && actorId === legacy; +} + +/** + * Apply status / search / date-range predicates (actor scope is separate). + * @param {object} transfer + * @param {{search?: string, status?: string, range?: string}} filters + * @param {Date} [now] + */ +export function matchesTransferFilters( + transfer, + filters = {}, + now = new Date(), +) { + const status = filters.status + ? (normalizeStatus(filters.status) ?? filters.status) + : ''; + if (status && normalizeStatus(transfer.status) !== status) return false; + + const search = String(filters.search ?? '') + .trim() + .toLowerCase(); + if (search) { + const recipient = String(transfer.recipient ?? '').toLowerCase(); + if (!recipient.includes(search)) return false; + } + + if (!isWithinDateRange(transfer.createdAt, filters.range ?? '', now)) { + return false; + } + + return true; +} + +/** + * Newest-first ordering with id as the deterministic tie-breaker. + * @param {object[]} transfers + * @returns {object[]} + */ +export function stableSortTransfers(transfers) { + return (transfers ?? []).slice().sort((a, b) => { + 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 ?? '')); + }); +} + +/** + * Filter by actor + predicates, stable-sort, and cap. + * + * @param {object[]} transfers + * @param {{search?: string, status?: string, range?: string, actorId: string, limit?: number}} query + * @param {{now?: Date, legacyActorId?: string}} [options] + * @returns {{items: object[], totalMatched: number, capped: boolean, scopeKey: string}} + */ +export function applyTransferSearch(transfers, query, options = {}) { + const normalized = normalizeTransferQuery(query); + const now = options.now ?? new Date(); + const scoped = (transfers ?? []).filter((t) => + isVisibleToActor(t, normalized.actorId, { + legacyActorId: options.legacyActorId, + }), + ); + const matched = scoped.filter((t) => + matchesTransferFilters(t, normalized, now), + ); + const sorted = stableSortTransfers(matched); + const items = sorted.slice(0, normalized.limit); + + return { + items, + totalMatched: matched.length, + capped: matched.length > normalized.limit, + scopeKey: transferQueryScopeKey(normalized), + }; +} diff --git a/test/integration/transfers-filter.test.jsx b/test/integration/transfers-filter.test.jsx index fb9dead..8f15a12 100644 --- a/test/integration/transfers-filter.test.jsx +++ b/test/integration/transfers-filter.test.jsx @@ -112,8 +112,10 @@ describe('Transfers page filter sync', () => { await waitFor(() => { expect(window.location.search).toBe(''); }); - expect(screen.getByText(/amina@exam/)).toBeInTheDocument(); - expect(screen.getByText(/GBQAZ7Z3X7/)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(/amina@exam/)).toBeInTheDocument(); + expect(screen.getByText(/GBQAZ7Z3X7/)).toBeInTheDocument(); + }); }); it('filters by date-range preset and syncs to URL', async () => { diff --git a/test/integration/transfers-search-scale.test.jsx b/test/integration/transfers-search-scale.test.jsx new file mode 100644 index 0000000..0265197 --- /dev/null +++ b/test/integration/transfers-search-scale.test.jsx @@ -0,0 +1,64 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../src/App.jsx'; +import { DEMO_PUBLIC_KEY } from '../../src/services/wallet.js'; + +function buildHistory(count) { + return Array.from({ length: count }, (_, i) => ({ + id: `tx_${String(i).padStart(4, '0')}`, + recipient: + i === 7 + ? 'amina@example.com' + : `user${String(i).padStart(4, '0')}@example.com`, + from: 'USD', + to: 'NGN', + sendAmount: 10 + i, + receiveAmount: 1000 + i, + status: i % 2 === 0 ? 'completed' : 'pending', + createdAt: new Date(Date.UTC(2026, 5, 1, 12, 0, i)).toISOString(), + actorId: DEMO_PUBLIC_KEY, + })); +} + +describe('Transfers search at scale', () => { + beforeEach(() => { + window.history.pushState({}, '', '/transfers'); + localStorage.setItem( + 'remitflow.transfers', + JSON.stringify(buildHistory(120)), + ); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('keeps pagination responsive on a capped large history', async () => { + render(); + await screen.findByRole('heading', { name: /your transfers/i }); + await waitFor(() => { + expect(screen.getByLabelText(/search transfers/i)).toBeInTheDocument(); + }); + + // Page size is 5; capped list still exposes next page. + const next = await screen.findByRole('button', { name: /next/i }); + expect(next).toBeEnabled(); + }); + + it('debounces search into the URL and finds the matching recipient', async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole('heading', { name: /your transfers/i }); + + await user.type(screen.getByLabelText(/search transfers/i), 'amina'); + + await waitFor( + () => { + expect(window.location.search).toContain('search=amina'); + expect(screen.getByText(/amina@exam/)).toBeInTheDocument(); + }, + { timeout: 4000 }, + ); + }); +}); diff --git a/test/unit/list-transfers-search.test.js b/test/unit/list-transfers-search.test.js new file mode 100644 index 0000000..77ff81e --- /dev/null +++ b/test/unit/list-transfers-search.test.js @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as api from '../../src/services/api.js'; +import { DEMO_PUBLIC_KEY } from '../../src/services/wallet.js'; + +const STORAGE_KEY = 'remitflow.transfers'; + +function seed(rows) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); +} + +describe('listTransfers search / cancellation / scope', () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + }); + + it('requires actor scope and hides other actors', async () => { + seed([ + { + id: 'tx_mine', + recipient: 'me@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + { + id: 'tx_theirs', + recipient: 'them@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-02T00:00:00Z', + actorId: 'GOTHERWALLET', + }, + ]); + + const pending = api.listTransfers({ actorId: DEMO_PUBLIC_KEY }); + await vi.advanceTimersByTimeAsync(400); + const rows = await pending; + expect(rows.map((r) => r.id)).toEqual(['tx_mine']); + }); + + it('rejects when actorId is missing and no fallback is provided via empty string', async () => { + expect(() => api.listTransfers({ actorId: '' })).toThrow(/actorId/i); + }); + + it('cancels an obsolete in-flight query via AbortSignal', async () => { + seed([ + { + id: 'tx_1', + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + ]); + + const controller = new AbortController(); + const pending = api.listTransfers({ + actorId: DEMO_PUBLIC_KEY, + signal: controller.signal, + }); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('caps large result sets', async () => { + const rows = Array.from({ length: 250 }, (_, i) => ({ + id: `tx_${i}`, + recipient: `user${i}@example.com`, + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: new Date(Date.UTC(2026, 5, 1, 0, 0, i)).toISOString(), + actorId: DEMO_PUBLIC_KEY, + })); + seed(rows); + + const pending = api.listTransfers({ + actorId: DEMO_PUBLIC_KEY, + limit: 40, + }); + await vi.advanceTimersByTimeAsync(400); + const result = await pending; + expect(result).toHaveLength(40); + }); + + it('applies search filter server-side', async () => { + seed([ + { + id: 'tx_1', + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + { + id: 'tx_2', + recipient: 'bola@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'pending', + createdAt: '2026-06-02T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + ]); + + const pending = api.listTransfers({ + actorId: DEMO_PUBLIC_KEY, + search: 'bola', + }); + await vi.advanceTimersByTimeAsync(400); + const result = await pending; + expect(result.map((r) => r.id)).toEqual(['tx_2']); + }); +}); diff --git a/test/unit/transfer-search.test.js b/test/unit/transfer-search.test.js new file mode 100644 index 0000000..b51b3c1 --- /dev/null +++ b/test/unit/transfer-search.test.js @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_RESULT_CAP, + MAX_RESULT_CAP, + applyTransferSearch, + isVisibleToActor, + matchesTransferFilters, + normalizeTransferQuery, + requireActorId, + stableSortTransfers, + transferQueryScopeKey, +} from '../../src/utils/transferSearch.js'; + +const ACTOR = 'GACTOR_A'; +const OTHER = 'GACTOR_B'; +const LEGACY = 'GLEGACY'; + +function tx(overrides) { + return { + id: 'tx_1', + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: '100', + receiveAmount: '1000', + status: 'completed', + createdAt: '2026-06-01T12:00:00Z', + actorId: ACTOR, + ...overrides, + }; +} + +describe('transferSearch scope and filters', () => { + it('requires a non-empty actorId on every query', () => { + expect(() => requireActorId('')).toThrow(/actorId is required/i); + expect(() => requireActorId(null)).toThrow(/actorId is required/i); + expect(requireActorId(' GXYZ ')).toBe('GXYZ'); + }); + + it('hides another actor’s transfers and migrates legacy rows safely', () => { + expect(isVisibleToActor(tx({ actorId: ACTOR }), ACTOR)).toBe(true); + expect(isVisibleToActor(tx({ actorId: OTHER }), ACTOR)).toBe(false); + expect( + isVisibleToActor(tx({ actorId: undefined }), LEGACY, { + legacyActorId: LEGACY, + }), + ).toBe(true); + expect( + isVisibleToActor(tx({ actorId: undefined }), ACTOR, { + legacyActorId: LEGACY, + }), + ).toBe(false); + }); + + it('matches status aliases, recipient search, and date range', () => { + const row = tx({ status: 'settled', createdAt: '2026-06-04T00:00:00Z' }); + const now = new Date('2026-06-05T12:00:00Z'); + expect(matchesTransferFilters(row, { status: 'completed' }, now)).toBe( + true, + ); + expect(matchesTransferFilters(row, { search: 'AMINA' }, now)).toBe(true); + expect(matchesTransferFilters(row, { search: 'nope' }, now)).toBe(false); + expect(matchesTransferFilters(row, { range: '7d' }, now)).toBe(true); + expect( + matchesTransferFilters( + tx({ createdAt: '2026-01-01T00:00:00Z' }), + { range: '7d' }, + now, + ), + ).toBe(false); + }); + + it('stable-sorts by createdAt desc with id as tie-breaker', () => { + const rows = [ + tx({ id: 'tx_a', createdAt: '2026-06-01T00:00:00Z' }), + tx({ id: 'tx_c', createdAt: '2026-06-02T00:00:00Z' }), + tx({ id: 'tx_b', createdAt: '2026-06-02T00:00:00Z' }), + ]; + expect(stableSortTransfers(rows).map((r) => r.id)).toEqual([ + 'tx_c', + 'tx_b', + 'tx_a', + ]); + }); + + it('caps results and reports totalMatched for large histories', () => { + const rows = Array.from({ length: 250 }, (_, i) => + tx({ + id: `tx_${String(i).padStart(4, '0')}`, + createdAt: new Date(Date.UTC(2026, 5, 1, 0, 0, i)).toISOString(), + }), + ); + const result = applyTransferSearch( + rows, + { actorId: ACTOR, limit: 50 }, + { legacyActorId: LEGACY }, + ); + expect(result.items).toHaveLength(50); + expect(result.totalMatched).toBe(250); + expect(result.capped).toBe(true); + expect(result.items[0].id > result.items[49].id).toBe(true); + }); + + it('normalizes and clamps the query limit', () => { + expect(normalizeTransferQuery({ actorId: ACTOR, limit: 9999 }).limit).toBe( + MAX_RESULT_CAP, + ); + expect(normalizeTransferQuery({ actorId: ACTOR }).limit).toBe( + DEFAULT_RESULT_CAP, + ); + }); + + it('builds a stable scope key so stale responses can be detected', () => { + const a = transferQueryScopeKey({ + actorId: ACTOR, + search: 'amina', + status: 'completed', + range: '7d', + limit: 100, + }); + const b = transferQueryScopeKey({ + actorId: ACTOR, + search: 'amina', + status: 'completed', + range: '7d', + limit: 100, + }); + const c = transferQueryScopeKey({ + actorId: ACTOR, + search: 'other', + status: 'completed', + range: '7d', + limit: 100, + }); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); + + it('filters at scale within a tight budget', () => { + const rows = Array.from({ length: 5000 }, (_, i) => + tx({ + id: `tx_${i}`, + actorId: i % 3 === 0 ? OTHER : ACTOR, + recipient: i % 10 === 0 ? 'amina@example.com' : `user${i}@example.com`, + status: i % 2 === 0 ? 'completed' : 'pending', + createdAt: new Date(Date.UTC(2026, 0, 1 + (i % 150))).toISOString(), + }), + ); + const started = performance.now(); + const result = applyTransferSearch( + rows, + { actorId: ACTOR, search: 'amina', status: 'completed', limit: 100 }, + { now: new Date('2026-06-05T12:00:00Z'), legacyActorId: LEGACY }, + ); + const elapsed = performance.now() - started; + expect(result.items.length).toBeLessThanOrEqual(100); + expect(result.items.every((r) => r.actorId === ACTOR)).toBe(true); + expect(elapsed).toBeLessThan(100); + }); +}); diff --git a/test/unit/useTransfers-search-race.test.jsx b/test/unit/useTransfers-search-race.test.jsx new file mode 100644 index 0000000..b8ab290 --- /dev/null +++ b/test/unit/useTransfers-search-race.test.jsx @@ -0,0 +1,119 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTransfers } from '../../src/hooks/useTransfers.js'; +import * as api from '../../src/services/api.js'; +import { DEMO_PUBLIC_KEY } from '../../src/services/wallet.js'; + +describe('useTransfers race and cancellation', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('does not let a stale slower response overwrite newer filters', async () => { + let resolveSlow; + const slow = new Promise((resolve) => { + resolveSlow = resolve; + }); + + const listSpy = vi + .spyOn(api, 'listTransfers') + .mockImplementation((opts) => { + if (opts.search === 'old') { + return slow.then(() => [ + { + id: 'tx_old', + recipient: 'old@example.com', + from: 'USD', + to: 'NGN', + sendAmount: '1', + receiveAmount: '2', + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + ]); + } + return Promise.resolve([ + { + id: 'tx_new', + recipient: 'new@example.com', + from: 'USD', + to: 'NGN', + sendAmount: '1', + receiveAmount: '2', + status: 'completed', + createdAt: '2026-06-02T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + ]); + }); + + const { result, rerender } = renderHook( + ({ search }) => + useTransfers({ + actorId: DEMO_PUBLIC_KEY, + filters: { search }, + }), + { initialProps: { search: 'old' } }, + ); + + await waitFor(() => { + expect(listSpy).toHaveBeenCalled(); + }); + + rerender({ search: 'new' }); + + await waitFor(() => { + expect(result.current.transfers.map((t) => t.id)).toEqual(['tx_new']); + }); + + await act(async () => { + resolveSlow(); + await slow; + }); + + // Stale "old" payload must not clobber the current filter results. + expect(result.current.transfers.map((t) => t.id)).toEqual(['tx_new']); + }); + + it('aborts the previous request when filters change', async () => { + const signals = []; + vi.spyOn(api, 'listTransfers').mockImplementation((opts) => { + signals.push(opts.signal); + return new Promise(() => { + /* never resolves — cancellation is the assertion */ + }); + }); + + const { rerender } = renderHook( + ({ search }) => + useTransfers({ + actorId: DEMO_PUBLIC_KEY, + filters: { search }, + }), + { initialProps: { search: 'a' } }, + ); + + await waitFor(() => expect(signals.length).toBe(1)); + rerender({ search: 'ab' }); + await waitFor(() => expect(signals.length).toBe(2)); + expect(signals[0].aborted).toBe(true); + }); + + it('passes actorId on every list request', async () => { + const listSpy = vi.spyOn(api, 'listTransfers').mockResolvedValue([]); + renderHook(() => + useTransfers({ actorId: 'GCUSTOM', filters: { status: 'pending' } }), + ); + await waitFor(() => expect(listSpy).toHaveBeenCalled()); + expect(listSpy.mock.calls[0][0]).toMatchObject({ + actorId: 'GCUSTOM', + status: 'pending', + }); + }); +}); From da227b6dfd95dca144fe7bf59c1c24c2fbd01480 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 26 Sep 2026 01:17:07 -0400 Subject: [PATCH 02/14] fix(transfers): record sends under connected wallet actor --- src/pages/SendMoney.jsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index c1e03f0..57b3e26 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -36,7 +36,7 @@ import './SendMoney.css'; export default function SendMoney() { const navigate = useNavigate(); const { wallet, isConnected, connect } = useWallet(); - const { addTransfer } = useTransfers(); + const { addTransfer } = useTransfers({ actorId: wallet?.publicKey }); const { locale } = useApp(); const isOnline = useOnlineStatus(); @@ -187,8 +187,9 @@ export default function SendMoney() { submissionLock.current = true; setSubmitting(true); try { - if (!isConnected) { - await connect(); + const account = isConnected ? wallet : await connect(); + if (!account?.publicKey) { + throw new Error('Connect a wallet before sending.'); } // Rebuild at confirmation time so the committed amounts match the note: @@ -210,6 +211,7 @@ export default function SendMoney() { // can reproduce exactly what was quoted rather than re-deriving it from // a rate that may since have moved. const created = await addTransfer({ + actorId: account.publicKey, recipient, from, to, From a34be089ae405920afc7cee854630ade24f76349 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 02:54:16 -0400 Subject: [PATCH 03/14] fix(transfers): keep async results within committed query scope Retire list and create completions when the committed wallet or filter lifecycle changes, including A-to-B-to-A transitions. Obsolete reload callbacks cannot cancel the current query. Same-scope refresh keeps valid creates visible. Reuse the canonical actor, date, status, sort and cap rules when merging a create, with ID deduplication after ownership validation. A different actor's colliding ID cannot replace the active actor's row. Validation: 38 focused tests pass across the seven transfer-search suites, including the real delayed localStorage create path; production build and hook lint pass. The full suite still has failures in send-money-form, send-money-precision, offline-reconnect and quote tests; all 12 failed cases also fail on unchanged parent da227b6. No tests were weakened. This remains the existing localStorage demo API, not a provider integration. --- src/hooks/useTransfers.js | 72 ++++++-- test/unit/useTransfers-search-race.test.jsx | 192 ++++++++++++++++++++ 2 files changed, 248 insertions(+), 16 deletions(-) diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index 9e3448c..4819116 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -1,10 +1,19 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { listTransfers, createTransfer } from '../services/api.js'; import { ContractViolationError } from '../services/contracts/schema.js'; import { getUserErrorMessage, normalizeError } from '../services/errors.js'; import { DEMO_PUBLIC_KEY } from '../services/wallet.js'; import { DEFAULT_RESULT_CAP, + applyTransferSearch, + isVisibleToActor, + normalizeTransferQuery, transferQueryScopeKey, } from '../utils/transferSearch.js'; @@ -43,6 +52,7 @@ export function useTransfers(options = {}) { const requestGen = useRef(0); const abortRef = useRef(null); + const activeScopeRef = useRef(null); const scopeKey = transferQueryScopeKey({ actorId, @@ -52,15 +62,31 @@ export function useTransfers(options = {}) { limit, }); + useLayoutEffect(() => { + // A new committed lifecycle stays distinct even after A -> B -> A. + activeScopeRef.current = { key: scopeKey }; + return () => { + activeScopeRef.current = null; + requestGen.current += 1; + abortRef.current?.abort(); + }; + }, [scopeKey]); + const reload = useCallback(async () => { + const expectedScope = activeScopeRef.current; + // A retained callback for an old query must not cancel the active one. + if (!expectedScope || expectedScope.key !== scopeKey) return; const gen = ++requestGen.current; - const expectedScope = scopeKey; if (abortRef.current) { abortRef.current.abort(); } const controller = new AbortController(); abortRef.current = controller; + const isCurrent = () => + activeScopeRef.current === expectedScope && + gen === requestGen.current && + !controller.signal.aborted; setLoading(true); setError(null); @@ -75,12 +101,12 @@ export function useTransfers(options = {}) { signal: controller.signal, }); // Drop stale replies: a newer filter/request already superseded this one. - if (gen !== requestGen.current || expectedScope !== scopeKey) { + if (!isCurrent()) { return; } setTransfers(data); } catch (err) { - if (isAbortError(err) || gen !== requestGen.current) { + if (isAbortError(err) || !isCurrent()) { return; } if (err instanceof ContractViolationError) { @@ -97,7 +123,7 @@ export function useTransfers(options = {}) { setRetryable(normalized.retryable); } } finally { - if (gen === requestGen.current) { + if (isCurrent()) { setLoading(false); } } @@ -114,25 +140,39 @@ export function useTransfers(options = {}) { const addTransfer = useCallback( async (payload) => { + const expectedScope = activeScopeRef.current; + const isCurrent = () => + expectedScope && + expectedScope.key === scopeKey && + activeScopeRef.current === expectedScope; const created = await createTransfer({ ...payload, actorId: payload?.actorId || actorId, }); + // Creation still succeeds for its caller when its old view has retired. + if (!isCurrent()) return created; setTransfers((prev) => { - // Prepend only when the new row matches the active filter scope. - const matchesSearch = - !filters.search || - String(created.recipient ?? '') - .toLowerCase() - .includes(String(filters.search).toLowerCase()); - const matchesStatus = - !filters.status || created.status === filters.status; - if (!matchesSearch || !matchesStatus) return prev; - return [created, ...prev].slice(0, limit); + if (!isCurrent()) return prev; + const query = normalizeTransferQuery({ + actorId, + search: filters.search, + status: filters.status, + range: filters.range, + limit, + }); + const visibility = { legacyActorId: DEMO_PUBLIC_KEY }; + // Another actor's colliding ID must not replace this actor's row. + if (!isVisibleToActor(created, query.actorId, visibility)) return prev; + // Match the list API's actor, date, status, sort and cap semantics. + return applyTransferSearch( + [created, ...prev.filter((row) => row.id !== created.id)], + query, + visibility, + ).items; }); return created; }, - [actorId, filters.search, filters.status, limit], + [actorId, filters.search, filters.status, filters.range, limit, scopeKey], ); // Existing consumers use reload for both pull-to-refresh and the error-state diff --git a/test/unit/useTransfers-search-race.test.jsx b/test/unit/useTransfers-search-race.test.jsx index b8ab290..2a2e29c 100644 --- a/test/unit/useTransfers-search-race.test.jsx +++ b/test/unit/useTransfers-search-race.test.jsx @@ -4,6 +4,21 @@ import { useTransfers } from '../../src/hooks/useTransfers.js'; import * as api from '../../src/services/api.js'; import { DEMO_PUBLIC_KEY } from '../../src/services/wallet.js'; +function transfer(overrides = {}) { + return { + id: 'tx_created', + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: '1', + receiveAmount: '2', + status: 'completed', + createdAt: new Date().toISOString(), + actorId: 'GACTOR_A', + ...overrides, + }; +} + describe('useTransfers race and cancellation', () => { beforeEach(() => { localStorage.clear(); @@ -11,6 +26,7 @@ describe('useTransfers race and cancellation', () => { afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); localStorage.clear(); }); @@ -116,4 +132,180 @@ describe('useTransfers race and cancellation', () => { status: 'pending', }); }); + + it('keeps a real delayed create in its original wallet after switching', async () => { + vi.useFakeTimers(); + localStorage.setItem('remitflow.transfers', '[]'); + const { result, rerender } = renderHook( + ({ actorId }) => useTransfers({ actorId }), + { initialProps: { actorId: 'GACTOR_A' } }, + ); + await act(() => vi.advanceTimersByTimeAsync(400)); + + let pending; + act(() => { + pending = result.current.addTransfer(transfer()); + }); + rerender({ actorId: 'GACTOR_B' }); + await act(() => vi.advanceTimersByTimeAsync(400)); + expect(result.current.loading).toBe(false); + await act(() => vi.advanceTimersByTimeAsync(300)); + + const created = await pending; + expect(created.actorId).toBe('GACTOR_A'); + expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual([ + created, + ]); + expect(result.current.transfers).toEqual([]); + }); + + it.each(['new filters', 'return to the original scope'])( + 'ignores an old create completion after %s', + async (change) => { + let finishCreate; + vi.spyOn(api, 'createTransfer').mockImplementation( + () => + new Promise((resolve) => { + finishCreate = resolve; + }), + ); + const current = transfer({ id: 'tx_current' }); + vi.spyOn(api, 'listTransfers').mockResolvedValue([current]); + const { result, rerender } = renderHook( + ({ search }) => + useTransfers({ actorId: 'GACTOR_A', filters: { search } }), + { initialProps: { search: '' } }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + let pending; + act(() => { + pending = result.current.addTransfer(transfer()); + }); + + rerender({ search: 'other' }); + await waitFor(() => expect(result.current.loading).toBe(false)); + if (change === 'return to the original scope') { + rerender({ search: '' }); + await waitFor(() => expect(result.current.loading).toBe(false)); + } + const created = transfer(); + await act(async () => { + finishCreate(created); + expect(await pending).toBe(created); + }); + expect(result.current.transfers).toEqual([current]); + }, + ); + + it('uses canonical date and actor filters for create completions', async () => { + vi.spyOn(api, 'listTransfers').mockResolvedValue([]); + vi.spyOn(api, 'createTransfer').mockImplementation( + async (payload) => payload, + ); + const { result } = renderHook(() => + useTransfers({ actorId: 'GACTOR_A', filters: { range: '7d' } }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.addTransfer( + transfer({ + createdAt: new Date(Date.now() - 30 * 86400000).toISOString(), + }), + ); + await result.current.addTransfer(transfer({ actorId: 'GACTOR_B' })); + }); + expect(result.current.transfers).toEqual([]); + }); + + it('merges matching creates once in canonical order after same-scope reload', async () => { + const latest = transfer({ id: 'tx_z' }); + const duplicate = transfer({ + id: 'tx_b', + status: 'settled', + createdAt: latest.createdAt, + }); + vi.spyOn(api, 'listTransfers').mockResolvedValue([latest, duplicate]); + let finishCreate; + vi.spyOn(api, 'createTransfer').mockImplementation( + () => + new Promise((resolve) => { + finishCreate = resolve; + }), + ); + const { result } = renderHook(() => + useTransfers({ + actorId: 'GACTOR_A', + filters: { search: ' AMINA ', status: 'settled' }, + limit: 2, + }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + let pending; + act(() => { + pending = result.current.addTransfer(transfer()); + }); + await act(async () => { + await result.current.reload(); + }); + const updated = { ...duplicate, status: 'completed', receiveAmount: '3' }; + await act(async () => { + finishCreate(updated); + await pending; + }); + expect(result.current.transfers).toEqual([latest, updated]); + }); + + it("does not replace a current actor row with another actor's colliding ID", async () => { + const current = transfer({ id: 'tx_shared' }); + vi.spyOn(api, 'listTransfers').mockResolvedValue([current]); + vi.spyOn(api, 'createTransfer').mockImplementation( + async (payload) => payload, + ); + const { result } = renderHook(() => + useTransfers({ actorId: ' GACTOR_A ' }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.addTransfer( + transfer({ + id: 'tx_shared', + actorId: 'GACTOR_B', + }), + ); + }); + expect(result.current.transfers).toEqual([current]); + }); + + it('does not let an obsolete reload abort the current wallet request', async () => { + const requests = []; + const listSpy = vi.spyOn(api, 'listTransfers').mockImplementation( + (options) => + new Promise((resolve) => { + requests.push({ ...options, resolve }); + }), + ); + const { result, rerender } = renderHook( + ({ actorId }) => useTransfers({ actorId }), + { initialProps: { actorId: 'GACTOR_A' } }, + ); + const oldReload = result.current.reload; + rerender({ actorId: 'GACTOR_B' }); + act(() => { + oldReload(); + }); + expect(listSpy).toHaveBeenCalledTimes(2); + expect(requests[1].signal.aborted).toBe(false); + + const current = transfer({ actorId: 'GACTOR_B' }); + await act(async () => { + requests[1].resolve([current]); + }); + await act(async () => { + requests[0].resolve([transfer()]); + }); + expect(result.current.transfers).toEqual([current]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); }); From 7ac632a83ca6af7a01028111894e3cf4532ec45d Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 03:12:46 -0400 Subject: [PATCH 04/14] fix(transfers): reset page and selection when the actor changes The Transfers page remains mounted across wallet changes. Leaving page two of a seven-transfer wallet for the three-row demo actor previously produced an empty list and no pagination recovery control. Individual selection and selection across all pages also leaked across the actor boundary. Include actorId in the existing reset effect. Keep the committed filter URL and existing query lifecycle behavior intact. Extend the existing mounted-App integration file with both selection modes and the A-to-demo-to-A transition. Validation: - Both new wallet cases fail on a34be089; affected suites pass 40/40 after. - Production build and edited-file formatting pass. - Critical npm audit gate passes; existing lower-severity findings remain. - Configured production-home Lighthouse collection/assertions pass locally (performance 100, accessibility 93); this is not a wallet latency benchmark. - Required full run: 473 pass, 13 fail. Twelve reproduce in the four existing send-money/reconnect/quote files on unchanged a34be089. The extra full-run valid-after-invalid timeout passes in its focused candidate rerun. - Nine repository-wide formatting failures are unchanged baseline files. No unrelated assertions were changed or disabled. Continues RemitFlow/RemitFlow-Frontend#298 and issue #281 on the original contribution branch. Existing localStorage demo scope is unchanged. --- src/pages/Transfers.jsx | 4 +- test/integration/transfers-filter.test.jsx | 89 ++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index 809b1c1..741a94f 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -104,12 +104,12 @@ export default function Transfers() { // Pagination state const [page, setPage] = useState(1); - // Reset page and selection when the committed filters change + // Reset page and selection when the actor or committed filters change. useEffect(() => { setPage(1); setSelectedIds(new Set()); setSelectAllAcross(false); - }, [debouncedSearch, status, range]); + }, [actorId, debouncedSearch, status, range]); // Track connectivity so that a reconnect triggers an automatic reload. // The reload reconciles the true status of transfers that may have been diff --git a/test/integration/transfers-filter.test.jsx b/test/integration/transfers-filter.test.jsx index 8f15a12..fd7bcbd 100644 --- a/test/integration/transfers-filter.test.jsx +++ b/test/integration/transfers-filter.test.jsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '../../src/App.jsx'; +import * as walletService from '../../src/services/wallet.js'; const TRANSFERS = [ { @@ -143,3 +144,91 @@ describe('Transfers page filter sync', () => { expect(screen.queryByText(/amina@exam/)).not.toBeInTheDocument(); }); }); + +describe('Transfers wallet scope', () => { + beforeEach(() => { + localStorage.clear(); + window.history.pushState({}, '', '/transfers?status=completed'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it.each(['individual', 'across pages'])( + 'resets pagination and %s selection when the wallet changes', + async (selection) => { + const user = userEvent.setup(); + const wallet = { publicKey: 'GWALLET_A', balance: 1000 }; + const makeTransfers = (actorId, count, prefix) => + Array.from({ length: count }, (_, i) => ({ + ...TRANSFERS[0], + id: `${prefix}_${i}`, + recipient: `${prefix}${i}@example.com`, + actorId, + })); + localStorage.setItem('remitflow.wallet', JSON.stringify(wallet)); + localStorage.setItem( + 'remitflow.transfers', + JSON.stringify([ + ...makeTransfers(wallet.publicKey, 7, 'walletA'), + ...makeTransfers(walletService.DEMO_PUBLIC_KEY, 3, 'demo'), + ]), + ); + vi.spyOn(walletService, 'connectWallet').mockResolvedValue(wallet); + + render(); + const next = await screen.findByRole('button', { name: /next/i }); + await user.click(next); + expect(screen.getByText(/page 2 of 2/i)).toBeInTheDocument(); + + if (selection === 'across pages') { + await user.click( + screen.getByLabelText(/select all transfers on this page/i), + ); + await user.click( + screen.getByRole('button', { name: /select all 7 transfers/i }), + ); + expect(screen.getByText('7 transfers selected')).toBeInTheDocument(); + } else { + await user.click( + screen.getAllByRole('checkbox', { name: /select transfer to/i })[0], + ); + expect(screen.getByText('1 transfer selected')).toBeInTheDocument(); + } + + // Keep the page mounted while the real context and list API switch + // from the connected wallet to the smaller demo-actor history. + await user.click( + screen.getAllByRole('button', { name: /disconnect/i })[0], + ); + await waitFor(() => { + const rows = screen.getAllByRole('checkbox', { + name: /select transfer to demo/i, + }); + expect(rows).toHaveLength(3); + rows.forEach((row) => expect(row).not.toBeChecked()); + }); + expect(screen.getByText('Select all')).toBeInTheDocument(); + expect( + screen.queryByRole('navigation', { name: /pagination/i }), + ).not.toBeInTheDocument(); + expect(window.location.search).toBe('?status=completed'); + + // Returning to the original wallet must not resurrect its old page + // or selection. Only the wallet adapter is mocked; view/data are real. + await user.click( + screen.getAllByRole('button', { name: /connect wallet/i })[0], + ); + await screen.findByText(/page 1 of 2/i); + const rows = screen.getAllByRole('checkbox', { + name: /select transfer to walletA/i, + }); + expect(rows).toHaveLength(5); + rows.forEach((row) => expect(row).not.toBeChecked()); + expect(screen.getByText('Select all')).toBeInTheDocument(); + expect(window.location.search).toBe('?status=completed'); + }, + ); +}); From 09cad8dff6d55a35b39d6836d31d045cdc1b938b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 21:19:31 -0400 Subject: [PATCH 05/14] fix(search): give capped transfer ties exact ID ordering Locale collation equates distinct composed/decomposed Unicode IDs. With identical timestamps, reversing a valid transfer set could therefore choose a different cap-one member; mixed-case ID order also differed by locale. Compare the exact ID strings in descending order after the existing timestamp key. Preserve opaque IDs, filtering, scope, caps and the earlier hook/page repairs. Add the regression to the existing transfer-search unit file without changing the eight existing cases. Actual validation on these source bytes: - Full production parseTransfer and search imports under English and Turkish now give identical sorted/capped members, with six controls per locale. - The new selected regression fails on parent 7ac632a83ca6af7a01028111894e3cf4532ec45d. - Existing transfer-search unit file passes 9/9 on the fix with Vitest 4.1.10, including the unchanged 5,000-row performance budget. - Ten retained BEFORE files and four named donor files stayed byte-identical. This is the existing PR #298 contribution for #281. Full application suite, build, lint, formatting and hosted CI have not been rerun on this continuation. Earlier full-suite failures remain documented in the cumulative handoff. No upstream merge or new PR. --- src/utils/transferSearch.js | 7 +++++-- test/unit/transfer-search.test.js | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/utils/transferSearch.js b/src/utils/transferSearch.js index 8580bad..be8db65 100644 --- a/src/utils/transferSearch.js +++ b/src/utils/transferSearch.js @@ -109,7 +109,7 @@ export function matchesTransferFilters( } /** - * Newest-first ordering with id as the deterministic tie-breaker. + * Newest-first ordering with descending exact ID strings as the tie-breaker. * @param {object[]} transfers * @returns {object[]} */ @@ -118,7 +118,10 @@ export function stableSortTransfers(transfers) { 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 ?? '')); + // Locale collation can equate distinct IDs; compare their exact strings. + const aId = String(a?.id ?? ''); + const bId = String(b?.id ?? ''); + return aId < bId ? 1 : aId > bId ? -1 : 0; }); } diff --git a/test/unit/transfer-search.test.js b/test/unit/transfer-search.test.js index b51b3c1..76ca0bc 100644 --- a/test/unit/transfer-search.test.js +++ b/test/unit/transfer-search.test.js @@ -83,6 +83,20 @@ describe('transferSearch scope and filters', () => { ]); }); + it('stabilizes capped membership for canonically equivalent IDs', () => { + const rows = [tx({ id: 'tx_\u00e9' }), tx({ id: 'tx_e\u0301' })]; + const query = { actorId: ACTOR, limit: 1 }; + const forward = applyTransferSearch(rows, query); + const reversed = applyTransferSearch([...rows].reverse(), query); + + expect(reversed.items).toEqual(forward.items); + expect(forward.items.map((row) => row.id)).toEqual(['tx_\u00e9']); + expect(forward.totalMatched).toBe(2); + expect(reversed.totalMatched).toBe(2); + expect(forward.capped).toBe(true); + expect(reversed.capped).toBe(true); + }); + it('caps results and reports totalMatched for large histories', () => { const rows = Array.from({ length: 250 }, (_, i) => tx({ From 17a3268138d598a9cdef76aabccac5b781adce08 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:22:47 -0400 Subject: [PATCH 06/14] fix(search): preserve history navigation and clear against stale drafts Only user edits schedule debounced URL writes. Query parameters are the committed search authority; history navigation retires an unfinished edit, including same-search status/range changes. Local filter controls preserve an unfinished edit. Clear cancels it before resetting filters. The existing actor scope, result cap, Unicode ordering and wallet-selection fixes are unchanged. Actual mounted App/BrowserRouter/localStorage API execution: original09cad8 source fails four of six new cases (settled navigation, pending navigation, same-search POP, clear); final source passes6/6 in run37191725198 on Node24.21.0/npm11.19.0/Vitest4.1.10 with locked dependencies. The final burst-of-edits case records exactly one real list call. Earlier candidate additionally passed all10 unchanged transfers-filter cases in run37191485175; its remaining same-search POP failure prompted the location.search dependency correction. No full suite or build result is claimed for this continuation. Reproduce: npx vitest run test/integration/transfers-search-navigation.test.jsx --maxWorkers=1 Source blob7f5ecbd6d50f92c184e6cc0ccd4e718d34fc0830; regression422ec661894335716f0775f2eae8a99595dfae86. Runner workflow remains isolated outside the original contribution. Continues PR298 for issue281; no provider integration or payment claim. --- src/pages/Transfers.jsx | 100 +++++++++---- .../transfers-search-navigation.test.jsx | 136 ++++++++++++++++++ 2 files changed, 206 insertions(+), 30 deletions(-) create mode 100644 test/integration/transfers-search-navigation.test.jsx diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index 741a94f..7f5ecbd 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -1,5 +1,17 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Link, + useLocation, + useNavigationType, + useSearchParams, +} from 'react-router-dom'; import Chart from '../components/Chart.jsx'; import { formatMoney, parseDecimal } from '../utils/money.js'; import { TRANSFER_STATUSES } from '../services/contracts/transfer.js'; @@ -13,7 +25,6 @@ import Pagination from '../components/Pagination.jsx'; import PullToRefresh from '../components/PullToRefresh.jsx'; import SelectionToolbar from '../components/SelectionToolbar.jsx'; import { useTransfers } from '../hooks/useTransfers.js'; -import { useDebouncedValue } from '../hooks/useDebouncedValue.js'; import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; import { DATE_RANGE_PRESETS } from '../utils/dateRange.js'; @@ -47,6 +58,8 @@ export default function Transfers() { const actorId = wallet?.publicKey || DEMO_PUBLIC_KEY; const isOnline = useOnlineStatus(); const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + const navigationType = useNavigationType(); // Becomes true while the browser is offline, then flips back to false the // moment connectivity returns so we can reconcile transfers against the @@ -58,37 +71,57 @@ export default function Transfers() { const range = searchParams.get('range') || ''; const urlSearch = searchParams.get('search') || ''; - // Draft input updates immediately; the debounced value drives URL + query. + // Only a user edit may schedule a URL write. Navigation is authoritative. const [searchDraft, setSearchDraft] = useState(urlSearch); - const debouncedSearch = useDebouncedValue(searchDraft, SEARCH_DEBOUNCE_MS); - - // Keep draft aligned when the URL changes externally (back/forward, clear). - useEffect(() => { - setSearchDraft(urlSearch); - }, [urlSearch]); + const pendingSearch = useRef(null); + + useLayoutEffect(() => { + const pending = pendingSearch.current; + // POP also retires a draft when history changes only status/range. + // Local filter controls use PUSH and may preserve an unfinished edit. + if ( + navigationType === 'POP' || + !pending || + pending.baseSearch !== urlSearch + ) { + pendingSearch.current = null; + setSearchDraft(urlSearch); + } + }, [urlSearch, location.key, location.search, navigationType]); - // Publish debounced search into the URL so shareable links stay accurate. useEffect(() => { - setSearchParams( - (prev) => { - const current = prev.get('search') || ''; - if (current === debouncedSearch) return prev; - const next = new URLSearchParams(prev); - if (debouncedSearch) next.set('search', debouncedSearch); - else next.delete('search'); - return next; - }, - { replace: true }, - ); - }, [debouncedSearch, setSearchParams]); + const pending = pendingSearch.current; + if ( + !pending || + pending.value !== searchDraft || + pending.baseSearch !== urlSearch + ) { + return; + } + const timer = setTimeout(() => { + if (pendingSearch.current !== pending) return; + pendingSearch.current = null; + setSearchParams( + (prev) => { + if ((prev.get('search') || '') !== pending.baseSearch) return prev; + const next = new URLSearchParams(prev); + if (pending.value) next.set('search', pending.value); + else next.delete('search'); + return next; + }, + { replace: true }, + ); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [searchDraft, urlSearch, setSearchParams]); const queryFilters = useMemo( () => ({ - search: debouncedSearch, + search: urlSearch, status, range, }), - [debouncedSearch, status, range], + [urlSearch, status, range], ); const { transfers, loading, error, reload } = useTransfers({ @@ -109,7 +142,7 @@ export default function Transfers() { setPage(1); setSelectedIds(new Set()); setSelectAllAcross(false); - }, [actorId, debouncedSearch, status, range]); + }, [actorId, urlSearch, status, range]); // Track connectivity so that a reconnect triggers an automatic reload. // The reload reconciles the true status of transfers that may have been @@ -146,9 +179,15 @@ export default function Transfers() { : selectedIds.size; const hasMorePages = totalPages > 1; - const handleSearchChange = useCallback((e) => { - setSearchDraft(e.target.value); - }, []); + const handleSearchChange = useCallback( + (e) => { + const value = e.target.value; + pendingSearch.current = + value === urlSearch ? null : { value, baseSearch: urlSearch }; + setSearchDraft(value); + }, + [urlSearch], + ); const handleStatusChange = useCallback( (e) => { @@ -174,7 +213,7 @@ export default function Transfers() { [setSearchParams], ); - const hasActiveFilters = Boolean(debouncedSearch || status || range); + const hasActiveFilters = Boolean(urlSearch || status || range); // Selection handlers const handleToggleSelect = useCallback((id) => { @@ -215,6 +254,7 @@ export default function Transfers() { }, []); const handleClearFilters = useCallback(() => { + pendingSearch.current = null; setSearchDraft(''); setSearchParams({}); }, [setSearchParams]); diff --git a/test/integration/transfers-search-navigation.test.jsx b/test/integration/transfers-search-navigation.test.jsx new file mode 100644 index 0000000..422ec66 --- /dev/null +++ b/test/integration/transfers-search-navigation.test.jsx @@ -0,0 +1,136 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../src/App.jsx'; +import * as api from '../../src/services/api.js'; + +const ROWS = [ + { + id: 'nav_a', + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 200, + receiveAmount: 294620, + status: 'completed', + createdAt: '2026-06-02T10:15:00Z', + }, + { + id: 'nav_b', + recipient: 'ben@example.com', + from: 'USD', + to: 'INR', + sendAmount: 120, + receiveAmount: 9920, + status: 'pending', + createdAt: '2026-06-02T08:42:00Z', + }, +]; +const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 650)); + }); +const searchInput = () => screen.getByLabelText(/search transfers/i); +function popTo(query) { + act(() => { + window.history.pushState({}, '', `/transfers${query}`); + window.dispatchEvent(new PopStateEvent('popstate')); + }); +} +async function open(query = '') { + window.history.pushState({}, '', `/transfers${query}`); + render(); + await screen.findByRole('heading', { name: /your transfers/i }); + await waitFor(() => + expect(document.querySelector('.skeleton')).not.toBeInTheDocument(), + ); + await settle(); +} + +describe('Transfer search URL navigation', () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem('remitflow.transfers', JSON.stringify(ROWS)); + }); + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('keeps a navigated search instead of restoring the settled old query', async () => { + await open('?search=amina'); + popTo('?search=ben'); + await settle(); + expect(window.location.search).toBe('?search=ben'); + expect(searchInput()).toHaveValue('ben'); + await screen.findByRole('checkbox', { name: /select transfer to ben/i }); + expect( + screen.queryByRole('checkbox', { name: /select transfer to amina/i }), + ).not.toBeInTheDocument(); + }); + + it('retires an unfinished search when history changes the search', async () => { + await open('?search=amina'); + fireEvent.change(searchInput(), { target: { value: 'cancelled-draft' } }); + popTo('?search=ben'); + await settle(); + expect(window.location.search).toBe('?search=ben'); + expect(searchInput()).toHaveValue('ben'); + await screen.findByRole('checkbox', { name: /select transfer to ben/i }); + }); + + it('retires an unfinished search on POP even when only another filter changes', async () => { + await open('?search=amina'); + fireEvent.change(searchInput(), { target: { value: 'cancelled-draft' } }); + popTo('?search=amina&status=completed'); + await settle(); + expect(window.location.search).toBe('?search=amina&status=completed'); + expect(searchInput()).toHaveValue('amina'); + await screen.findByRole('checkbox', { name: /select transfer to amina/i }); + }); + + it('clears a committed search without resurrecting it after the debounce', async () => { + await open('?search=missing&status=failed'); + fireEvent.click( + await screen.findByRole('button', { name: /clear filters/i }), + ); + await settle(); + expect(window.location.search).toBe(''); + expect(searchInput()).toHaveValue(''); + await screen.findByRole('checkbox', { name: /select transfer to amina/i }); + await screen.findByRole('checkbox', { name: /select transfer to ben/i }); + }); + + it('preserves an unfinished edit when a local status control changes', async () => { + await open(); + fireEvent.change(searchInput(), { target: { value: 'amina' } }); + fireEvent.change(screen.getByLabelText(/filter by status/i), { + target: { value: 'completed' }, + }); + await settle(); + const params = new URLSearchParams(window.location.search); + expect(params.get('search')).toBe('amina'); + expect(params.get('status')).toBe('completed'); + expect(searchInput()).toHaveValue('amina'); + await screen.findByRole('checkbox', { name: /select transfer to amina/i }); + }); + + it('issues one real list query for a burst of edits and uses the final term', async () => { + const calls = vi.spyOn(api, 'listTransfers'); + await open(); + calls.mockClear(); + fireEvent.change(searchInput(), { target: { value: 'am' } }); + fireEvent.change(searchInput(), { target: { value: 'ami' } }); + fireEvent.change(searchInput(), { target: { value: 'amina' } }); + await settle(); + expect(calls).toHaveBeenCalledTimes(1); + expect(calls.mock.calls[0][0]).toMatchObject({ search: 'amina' }); + expect(window.location.search).toBe('?search=amina'); + await screen.findByRole('checkbox', { name: /select transfer to amina/i }); + }); +}); From 21fccd57636a96d6c844289157e9c4693419a478 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:25:11 -0400 Subject: [PATCH 07/14] perf(transfers): bound capped search selection at scale Keep the best normalized-limit rows in a bounded heap, parse each matching timestamp once, and sort only retained results. Preserve exact timestamp/ID order, stable duplicate membership, sparse inputs, scope and total counts. Original PR298 continues on its existing branch. Parent17a32681 preserves the separate navigation repair. Public sort and query dependencies unchanged. Validation: maintained transfer-search file11/11 with Vitest4.1.10; independent source review. Five alternating paired samples on Node24.19: 100k shuffled/cap100 median776.075 ->26.912ms (28.84x); 5k22.827 ->2.484ms. Both ordered100k/cap500 cases improve; complete returned values and object references match. Repro and raw samples in docs/performance/transfer-search-20261004. Selector-only result; localStorage parsing, simulated delay, React rendering, full suite/build/hosted CI and sponsor acceptance are not claimed. [skip ci] --- .../transfer-search-20261004/README.md | 88 ++++++++ .../transfer-search-20261004/results.json | 194 ++++++++++++++++++ scripts/bench-transfer-search.mjs | 120 +++++++++++ src/utils/transferSearch.js | 80 ++++++-- test/unit/transfer-search.test.js | 64 ++++++ 5 files changed, 533 insertions(+), 13 deletions(-) create mode 100644 docs/performance/transfer-search-20261004/README.md create mode 100644 docs/performance/transfer-search-20261004/results.json create mode 100644 scripts/bench-transfer-search.mjs diff --git a/docs/performance/transfer-search-20261004/README.md b/docs/performance/transfer-search-20261004/README.md new file mode 100644 index 0000000..9fda02b --- /dev/null +++ b/docs/performance/transfer-search-20261004/README.md @@ -0,0 +1,88 @@ +# Capped transfer search performance + +Issue: [RemitFlow Frontend #281](https://github.com/RemitFlow/RemitFlow-Frontend/issues/281) +Existing submission: [PR #298](https://github.com/RemitFlow/RemitFlow-Frontend/pull/298) + +`applyTransferSearch` previously built two full arrays, sorted every matching +transfer and then returned at most 500 rows. Its comparator reparsed each +timestamp on every comparison. Large unsorted histories therefore spent most +of their query time sorting rows that would never be returned. + +The selector now scans the history once and retains only the best requested +rows in a bounded heap. It parses each matching timestamp once, sorts the +retained rows, and still counts every match. The public `stableSortTransfers` +helper and the actor, status, search and date predicates are unchanged. + +## Observed production-module timings + +Node **24.19.0**, Linux x64. Each row below is the median of five paired +measurements, alternating execution order after one warmup per version. +Fixtures, garbage collection and output assertions were outside the timed +call. The timed function is the actual imported `applyTransferSearch`. + +| History | Result cap | Before, ms | After, ms | Speedup | +| --- | ---: | ---: | ---: | ---: | +| 5,000 shuffled transfers | 100 | 22.827 | 2.484 | 9.19x | +| 100,000 shuffled transfers | 100 | 776.075 | 26.912 | 28.84x | +| 100,000 oldest-first transfers | 500 | 44.631 | 31.982 | 1.40x | +| 100,000 newest-first transfers | 500 | 46.296 | 25.439 | 1.82x | + +Every timed result matched the baseline's complete return value and original +row references. The 100,000-row shuffled case reduced `Date.parse` calls from +3,031,310 to 100,000 and sorted 100 rows instead of 100,000. Operation counts +were measured in separate, untimed calls. Full paired samples and source +hashes are retained in [results.json](results.json). + +These measurements cover the repository's localStorage demo query selector. +They exclude storage parsing, transfer-schema validation, the API's simulated +400 ms delay, React rendering and external providers. They establish neither +end-to-end page latency nor live-provider throughput. Heap retention is bounded +by the normalized result limit; no measured peak-memory result is claimed. + +## Compatibility and focused validation + +The selector preserves descending creation time, descending exact ID-string +order and original input order for identical time/ID keys. It returns the same +`items`, `totalMatched`, `capped` and `scopeKey`, and preserves row references. +It does not mutate inputs or change actor visibility, filters, query caps, +navigation, debounce or request cancellation. + +The maintained `test/unit/transfer-search.test.js` file passed **11/11** cases +under Vitest **4.1.10** and Node **24.19.0**: all nine existing cases plus two +focused regressions. New coverage compares against the unchanged public sort +across caps and filters, duplicate keys at the cap boundary, frozen inputs, +row identity, sparse legacy histories, invalid/epoch/pre-1970 timestamps and +distinct Unicode IDs. Independent source review found no blockers. + +The run used the installed Vitest package and a Node-only configuration for +this pure utility file. No dependency installation or assertion substitution +was used. The repository-wide test suite, production build and hosted CI were +not rerun for this utility continuation; earlier PR evidence retains its own +source revision and limitations. No full-suite or sponsor-acceptance claim is +made here. + +## Reproduce + +From a checkout containing this change and the baseline commit, stage the old +selector beside the current one so both use the same unchanged dependencies: + +```bash +git show 09cad8dff6d55a35b39d6836d31d045cdc1b938b:src/utils/transferSearch.js > src/utils/transferSearch.baseline.js +node --expose-gc scripts/bench-transfer-search.mjs src/utils/transferSearch.baseline.js +npx vitest run test/unit/transfer-search.test.js +``` + +The benchmark needs Node only and writes JSON to stdout. An optional second +path selects a different candidate module. It asserts complete baseline +equivalence for every sample and reports both input source hashes. The +baseline staging file is temporary and is not part of the contribution. + +| Executed source | Git blob | +| --- | --- | +| Baseline selector | `be8db651d5c2457323d6f8373a170a41d8b99b87` | +| Updated selector | `a75e2e4fe88e16cb0b03ed97aa0a1c18dbba1274` | +| Maintained unit file | `22be7a01462fd6a612d40cdc9e34f0c9f1f443ea` | + +The original contribution and claim remain unchanged. This is performance +follow-through on the existing submission; it does not establish a new award +or payment. diff --git a/docs/performance/transfer-search-20261004/results.json b/docs/performance/transfer-search-20261004/results.json new file mode 100644 index 0000000..a8dad99 --- /dev/null +++ b/docs/performance/transfer-search-20261004/results.json @@ -0,0 +1,194 @@ +{ + "runtime": { + "node": "v24.19.0", + "platform": "linux", + "arch": "x64" + }, + "sources": { + "baselineBlob": "be8db651d5c2457323d6f8373a170a41d8b99b87", + "candidateBlob": "a75e2e4fe88e16cb0b03ed97aa0a1c18dbba1274" + }, + "method": { + "rounds": 5, + "warmupsPerVersion": 1, + "order": "alternating before/after", + "gcBeforeEachSample": true, + "timingIncludes": "actual applyTransferSearch only; fixtures/assertions outside timing", + "operationCounts": "separate untimed production calls", + "providerRequests": 0 + }, + "scenarios": [ + { + "count": 5000, + "order": "shuffled", + "limit": 100, + "samples": [ + { + "before": 33.71209999999999, + "after": 2.137278000000009 + }, + { + "after": 2.483734999999996, + "before": 27.878434999999996 + }, + { + "before": 20.951795000000004, + "after": 3.445317999999986 + }, + { + "after": 2.557780999999977, + "before": 22.238653 + }, + { + "before": 22.827020000000005, + "after": 1.8687130000000138 + } + ], + "baselineMedianMs": 22.827020000000005, + "candidateMedianMs": 2.483734999999996, + "speedup": 9.190602056982748, + "baselineOperations": { + "dateParseCalls": 110108, + "sortedLengths": [ + 5000 + ] + }, + "candidateOperations": { + "dateParseCalls": 5000, + "sortedLengths": [ + 100 + ] + }, + "identicalResults": true + }, + { + "count": 100000, + "order": "shuffled", + "limit": 100, + "samples": [ + { + "before": 768.0208469999998, + "after": 26.91173000000026 + }, + { + "after": 29.546558000000005, + "before": 922.863077 + }, + { + "before": 776.0745080000002, + "after": 25.353613999999652 + }, + { + "after": 27.637462000000596, + "before": 679.0042330000006 + }, + { + "before": 855.1001800000004, + "after": 23.976690999999846 + } + ], + "baselineMedianMs": 776.0745080000002, + "candidateMedianMs": 26.91173000000026, + "speedup": 28.837778470577426, + "baselineOperations": { + "dateParseCalls": 3031310, + "sortedLengths": [ + 100000 + ] + }, + "candidateOperations": { + "dateParseCalls": 100000, + "sortedLengths": [ + 100 + ] + }, + "identicalResults": true + }, + { + "count": 100000, + "order": "ascending", + "limit": 500, + "samples": [ + { + "before": 44.33002699999997, + "after": 33.507582000000184 + }, + { + "after": 31.485877000000073, + "before": 44.50824499999999 + }, + { + "before": 46.68953899999997, + "after": 31.618967999999768 + }, + { + "after": 32.851283999999396, + "before": 44.630928999999924 + }, + { + "before": 45.76722100000006, + "after": 31.98176499999954 + } + ], + "baselineMedianMs": 44.630928999999924, + "candidateMedianMs": 31.98176499999954, + "speedup": 1.3955117549015998, + "baselineOperations": { + "dateParseCalls": 199998, + "sortedLengths": [ + 100000 + ] + }, + "candidateOperations": { + "dateParseCalls": 100000, + "sortedLengths": [ + 500 + ] + }, + "identicalResults": true + }, + { + "count": 100000, + "order": "descending", + "limit": 500, + "samples": [ + { + "before": 51.15443300000061, + "after": 26.590165999999954 + }, + { + "after": 25.439112999999452, + "before": 46.29600699999992 + }, + { + "before": 65.55661699999928, + "after": 36.23268499999995 + }, + { + "after": 24.899862000000212, + "before": 44.26636099999996 + }, + { + "before": 43.51811400000042, + "after": 22.59087400000044 + } + ], + "baselineMedianMs": 46.29600699999992, + "candidateMedianMs": 25.439112999999452, + "speedup": 1.8198750483163826, + "baselineOperations": { + "dateParseCalls": 199998, + "sortedLengths": [ + 100000 + ] + }, + "candidateOperations": { + "dateParseCalls": 100000, + "sortedLengths": [ + 500 + ] + }, + "identicalResults": true + } + ] +} diff --git a/scripts/bench-transfer-search.mjs b/scripts/bench-transfer-search.mjs new file mode 100644 index 0000000..99da4a4 --- /dev/null +++ b/scripts/bench-transfer-search.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; + +const args = process.argv.slice(2); +const baselinePath = args[0]; +const candidatePath = args[1] ?? 'src/utils/transferSearch.js'; +if (!baselinePath) { + throw new Error('Usage: node --expose-gc bench-transfer-search.mjs BASELINE [CANDIDATE]'); +} +const before = await import(pathToFileURL(resolve(baselinePath)).href); +const after = await import(pathToFileURL(resolve(candidatePath)).href); +const now = new Date('2026-10-04T00:00:00Z'); +const rounds = 5; + +function blob(path) { + const data = readFileSync(path); + return createHash('sha1') + .update(`blob ${data.length}\0`) + .update(data) + .digest('hex'); +} + +function fixture(count, order) { + return Array.from({ length: count }, (_, i) => { + const rank = order === 'shuffled' ? (i * 7919) % count : + order === 'ascending' ? i : count - i - 1; + return { + id: `tx_${i}`, + actorId: 'GACTOR_A', + recipient: 'amina@example.com', + status: 'completed', + createdAt: new Date(1700000000000 + rank * 1000).toISOString(), + }; + }); +} + +function measure(fn, rows, query) { + if (global.gc) global.gc(); + const start = performance.now(); + const result = fn(rows, query, { now }); + return { ms: performance.now() - start, result }; +} + +function countOperations(fn, rows, query) { + const originalParse = Date.parse; + const originalSort = Array.prototype.sort; + let dateParseCalls = 0; + const sortedLengths = []; + Date.parse = (...values) => { + dateParseCalls += 1; + return originalParse(...values); + }; + Array.prototype.sort = function (...values) { + sortedLengths.push(this.length); + return originalSort.apply(this, values); + }; + try { + fn(rows, query, { now }); + return { dateParseCalls, sortedLengths }; + } finally { + Date.parse = originalParse; + Array.prototype.sort = originalSort; + } +} + +function median(values) { + return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; +} + +const report = { + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + sources: { baselineBlob: blob(baselinePath), candidateBlob: blob(candidatePath) }, + method: { + rounds, warmupsPerVersion: 1, order: 'alternating before/after', + gcBeforeEachSample: Boolean(global.gc), + timingIncludes: 'actual applyTransferSearch only; fixtures/assertions outside timing', + operationCounts: 'separate untimed production calls', + providerRequests: 0, + }, + scenarios: [], +}; +for (const [count, order, limit] of [ + [5000, 'shuffled', 100], + [100000, 'shuffled', 100], + [100000, 'ascending', 500], + [100000, 'descending', 500], +]) { + const rows = fixture(count, order); + const query = { actorId: 'GACTOR_A', limit }; + const expected = before.applyTransferSearch(rows, query, { now }); + assert.deepEqual(after.applyTransferSearch(rows, query, { now }), expected); + const samples = []; + for (let round = 0; round < rounds; round += 1) { + const calls = round % 2 === 0 ? ['before', 'after'] : ['after', 'before']; + const pair = {}; + for (const name of calls) { + const fn = name === 'before' ? before.applyTransferSearch : after.applyTransferSearch; + const sample = measure(fn, rows, query); + assert.deepEqual(sample.result, expected); + assert.equal(sample.result.totalMatched, count); + sample.result.items.forEach((row, i) => assert.equal(row, expected.items[i])); + pair[name] = sample.ms; + } + samples.push(pair); + } + const baselineMedianMs = median(samples.map((pair) => pair.before)); + const candidateMedianMs = median(samples.map((pair) => pair.after)); + report.scenarios.push({ + count, order, limit, samples, baselineMedianMs, candidateMedianMs, + speedup: baselineMedianMs / candidateMedianMs, + baselineOperations: countOperations(before.applyTransferSearch, rows, query), + candidateOperations: countOperations(after.applyTransferSearch, rows, query), + identicalResults: true, + }); +} +console.log(JSON.stringify(report, null, 2)); diff --git a/src/utils/transferSearch.js b/src/utils/transferSearch.js index be8db65..a75e2e4 100644 --- a/src/utils/transferSearch.js +++ b/src/utils/transferSearch.js @@ -125,8 +125,48 @@ export function stableSortTransfers(transfers) { }); } +function compareSearchEntries(a, b) { + if (b.time !== a.time) return b.time - a.time; + if (a.id !== b.id) return a.id < b.id ? 1 : -1; + // Array.sort is stable: identical keys retain their original input order. + return a.index - b.index; +} + +/** Keep only the best `limit` rows, with the worst retained row at the root. */ +function retainSearchEntry(heap, entry, limit) { + if (heap.length < limit) { + let child = heap.length; + heap.push(entry); + while (child > 0) { + const parent = Math.floor((child - 1) / 2); + if (compareSearchEntries(heap[parent], entry) >= 0) break; + heap[child] = heap[parent]; + child = parent; + } + heap[child] = entry; + return; + } + + if (compareSearchEntries(entry, heap[0]) >= 0) return; + let parent = 0; + while (parent * 2 + 1 < heap.length) { + let child = parent * 2 + 1; + if ( + child + 1 < heap.length && + compareSearchEntries(heap[child + 1], heap[child]) > 0 + ) { + child += 1; + } + if (compareSearchEntries(entry, heap[child]) >= 0) break; + heap[parent] = heap[child]; + parent = child; + } + heap[parent] = entry; +} + /** - * Filter by actor + predicates, stable-sort, and cap. + * Filter by actor + predicates and select a stable, capped result. + * Only the retained rows are sorted; each matching timestamp is parsed once. * * @param {object[]} transfers * @param {{search?: string, status?: string, range?: string, actorId: string, limit?: number}} query @@ -136,21 +176,35 @@ export function stableSortTransfers(transfers) { export function applyTransferSearch(transfers, query, options = {}) { const normalized = normalizeTransferQuery(query); const now = options.now ?? new Date(); - const scoped = (transfers ?? []).filter((t) => - isVisibleToActor(t, normalized.actorId, { - legacyActorId: options.legacyActorId, - }), - ); - const matched = scoped.filter((t) => - matchesTransferFilters(t, normalized, now), - ); - const sorted = stableSortTransfers(matched); - const items = sorted.slice(0, normalized.limit); + const selected = []; + let totalMatched = 0; + const visibility = { legacyActorId: options.legacyActorId }; + // forEach, like filter, skips sparse-array holes. + (transfers ?? []).forEach((transfer, index) => { + if ( + !isVisibleToActor(transfer, normalized.actorId, visibility) || + !matchesTransferFilters(transfer, normalized, now) + ) { + return; + } + totalMatched += 1; + retainSearchEntry( + selected, + { + transfer, + time: Date.parse(transfer?.createdAt ?? '') || 0, + id: String(transfer?.id ?? ''), + index, + }, + normalized.limit, + ); + }); + const items = selected.sort(compareSearchEntries).map((entry) => entry.transfer); return { items, - totalMatched: matched.length, - capped: matched.length > normalized.limit, + totalMatched, + capped: totalMatched > normalized.limit, scopeKey: transferQueryScopeKey(normalized), }; } diff --git a/test/unit/transfer-search.test.js b/test/unit/transfer-search.test.js index 76ca0bc..22be7a0 100644 --- a/test/unit/transfer-search.test.js +++ b/test/unit/transfer-search.test.js @@ -115,6 +115,70 @@ describe('transferSearch scope and filters', () => { expect(result.items[0].id > result.items[49].id).toBe(true); }); + it('keeps the same capped rows as stable sorting across filters and duplicate keys', () => { + const rows = Object.freeze( + Array.from({ length: 850 }, (_, i) => + Object.freeze( + tx({ + id: `tx_${i % 11}`, + actorId: i % 4 === 0 ? OTHER : ACTOR, + recipient: i % 3 === 0 ? 'amina@example.com' : 'other@example.com', + status: i % 2 === 0 ? 'settled' : 'pending', + createdAt: new Date(Date.UTC(2026, 5, 1 + (i % 7))).toISOString(), + originalRow: i, + }), + ), + ), + ); + const now = new Date('2026-06-08T12:00:00Z'); + const queries = [ + ...[1, 7, 100, 500, 9999].map((limit) => ({ actorId: ACTOR, limit })), + { actorId: ACTOR, search: ' AMINA ', status: 'completed', range: '7d', limit: 7 }, + { actorId: OTHER, status: 'pending', limit: 1 }, + { actorId: ACTOR, search: 'absent', limit: 100 }, + ]; + for (const query of queries) { + const normalized = normalizeTransferQuery(query); + const matched = rows.filter( + (row) => + isVisibleToActor(row, normalized.actorId) && + matchesTransferFilters(row, normalized, now), + ); + const expected = stableSortTransfers(matched).slice(0, normalized.limit); + const result = applyTransferSearch(rows, query, { now }); + expect(result).toEqual({ + items: expected, + totalMatched: matched.length, + capped: matched.length > normalized.limit, + scopeKey: transferQueryScopeKey(normalized), + }); + result.items.forEach((row, i) => expect(row).toBe(expected[i])); + } + }); + + it('preserves zero-time ordering, Unicode ties and sparse legacy histories', () => { + const rows = new Array(12); + rows[1] = tx({ id: 'tx_\u00e9', actorId: undefined, createdAt: 'invalid' }); + rows[3] = tx({ id: 'tx_e\u0301', actorId: undefined, createdAt: undefined }); + rows[4] = tx({ id: 'tx_z', actorId: undefined, createdAt: '1970-01-01T00:00:00Z' }); + rows[5] = tx({ id: 'tx_z', actorId: undefined, createdAt: 'invalid', sendAmount: '200' }); + rows[7] = tx({ id: 'tx_z', actorId: undefined, createdAt: '1969-12-31T00:00:00Z' }); + rows[9] = tx({ id: 'tx_new', actorId: OTHER }); + for (const limit of [1, 3, 4, 100]) { + const query = { actorId: LEGACY, limit }; + const options = { legacyActorId: LEGACY }; + const matched = rows.filter((row) => isVisibleToActor(row, LEGACY, options)); + const expected = stableSortTransfers(matched).slice(0, limit); + const result = applyTransferSearch(rows, query, options); + expect(result.items).toEqual(expected); + expect(result.totalMatched).toBe(5); + expect(result.capped).toBe(5 > limit); + result.items.forEach((row, i) => expect(row).toBe(expected[i])); + } + expect(0 in rows).toBe(false); + expect(rows).toHaveLength(12); + }); + it('normalizes and clamps the query limit', () => { expect(normalizeTransferQuery({ actorId: ACTOR, limit: 9999 }).limit).toBe( MAX_RESULT_CAP, From 935caeb37e3932f85c2555f9086bde2c771c20bd Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:34:23 -0400 Subject: [PATCH 08/14] fix(transfers): validate records within the requesting actor scope --- src/services/api.js | 16 +++-- test/unit/list-transfers-search.test.js | 94 +++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/services/api.js b/src/services/api.js index ce71f6e..1ec5dcd 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -15,6 +15,7 @@ import { DEMO_PUBLIC_KEY } from './wallet.js'; import { DEFAULT_RESULT_CAP, applyTransferSearch, + isVisibleToActor, normalizeTransferQuery, requireActorId, } from '../utils/transferSearch.js'; @@ -128,7 +129,16 @@ export function listTransfers(options = {}) { const timer = setTimeout(() => { try { - const { transfers, rejected, breaking } = parseTransferList(read(), { + const raw = read(); + const visibility = { legacyActorId: DEMO_PUBLIC_KEY }; + // Validate this actor's response, not every wallet in shared storage. + // Foreign records must neither cause nor mask our schema failures. + const visible = Array.isArray(raw) + ? raw.filter((transfer) => + isVisibleToActor(transfer, query.actorId, visibility), + ) + : raw; + const { transfers, rejected, breaking } = parseTransferList(visible, { source: 'listTransfers', }); if (breaking) { @@ -143,9 +153,7 @@ export function listTransfers(options = {}) { } if (rejected.length) reportRejected(rejected); - const { items } = applyTransferSearch(transfers, query, { - legacyActorId: DEMO_PUBLIC_KEY, - }); + const { items } = applyTransferSearch(transfers, query, visibility); resolve(items); } catch (error) { reject(error); diff --git a/test/unit/list-transfers-search.test.js b/test/unit/list-transfers-search.test.js index 77ff81e..2d2c475 100644 --- a/test/unit/list-transfers-search.test.js +++ b/test/unit/list-transfers-search.test.js @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as api from '../../src/services/api.js'; +import { ContractViolationError } from '../../src/services/contracts/schema.js'; import { DEMO_PUBLIC_KEY } from '../../src/services/wallet.js'; const STORAGE_KEY = 'remitflow.transfers'; @@ -8,6 +9,21 @@ function seed(rows) { localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); } +function transfer(overrides = {}) { + return { + id: 'tx_mine', + recipient: 'me@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + ...overrides, + }; +} + describe('listTransfers search / cancellation / scope', () => { beforeEach(() => { vi.useFakeTimers(); @@ -15,6 +31,7 @@ describe('listTransfers search / cancellation / scope', () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.useRealTimers(); localStorage.clear(); }); @@ -55,6 +72,83 @@ describe('listTransfers search / cancellation / scope', () => { expect(() => api.listTransfers({ actorId: '' })).toThrow(/actorId/i); }); + it.each([ + { name: 'an empty actor history', ownRows: [], expectedIds: [] }, + { + name: 'a valid actor history', + ownRows: [transfer()], + expectedIds: ['tx_mine'], + }, + ])( + 'ignores foreign corrupt rows for $name', + async ({ ownRows, expectedIds }) => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + seed([ + ...ownRows, + transfer({ id: 'tx_foreign', actorId: 'GOTHERWALLET', recipient: null }), + ]); + + const pending = api.listTransfers({ actorId: DEMO_PUBLIC_KEY }); + await vi.advanceTimersByTimeAsync(400); + const rows = await pending; + + expect(rows.map((row) => row.id)).toEqual(expectedIds); + expect(errorLog).not.toHaveBeenCalled(); + }, + ); + + it('does not let a foreign valid row mask an actor-wide schema failure', async () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + seed([ + transfer({ recipient: null }), + transfer({ id: 'tx_foreign', actorId: 'GOTHERWALLET' }), + ]); + + const pending = api.listTransfers({ actorId: DEMO_PUBLIC_KEY }); + const rejection = expect(pending).rejects.toBeInstanceOf( + ContractViolationError, + ); + await vi.advanceTimersByTimeAsync(400); + await rejection; + expect(errorLog).not.toHaveBeenCalled(); + }); + + it('still rejects a non-array response before interpreting actor visibility', async () => { + seed({ error: 'unexpected response' }); + + const pending = api.listTransfers({ actorId: DEMO_PUBLIC_KEY }); + const rejection = expect(pending).rejects.toBeInstanceOf( + ContractViolationError, + ); + await vi.advanceTimersByTimeAsync(400); + await rejection; + }); + + it.each([ + { actorId: DEMO_PUBLIC_KEY, expectedIds: ['tx_legacy'] }, + { actorId: 'GOTHERWALLET', expectedIds: [] }, + ])( + 'retains legacy visibility for $actorId', + async ({ actorId, expectedIds }) => { + seed([transfer({ id: 'tx_legacy', actorId: undefined })]); + + const pending = api.listTransfers({ actorId }); + await vi.advanceTimersByTimeAsync(400); + expect((await pending).map((row) => row.id)).toEqual(expectedIds); + }, + ); + + it('normalizes the query actor without changing stored actor strings', async () => { + seed([ + transfer(), + transfer({ id: 'tx_padded', actorId: ` ${DEMO_PUBLIC_KEY} ` }), + ]); + + const pending = api.listTransfers({ actorId: ` ${DEMO_PUBLIC_KEY} ` }); + await vi.advanceTimersByTimeAsync(400); + expect((await pending).map((row) => row.id)).toEqual(['tx_mine']); + }); + it('cancels an obsolete in-flight query via AbortSignal', async () => { seed([ { From ecefe06d68ca6838434b755525cfc3cc6c701d4f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:42:07 -0400 Subject: [PATCH 09/14] docs(transfers): record focused actor-validation execution --- .../actor-scoped-transfer-validation.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/validation/actor-scoped-transfer-validation.md diff --git a/docs/validation/actor-scoped-transfer-validation.md b/docs/validation/actor-scoped-transfer-validation.md new file mode 100644 index 0000000..8e41c7a --- /dev/null +++ b/docs/validation/actor-scoped-transfer-validation.md @@ -0,0 +1,76 @@ +# Actor-scoped transfer validation + +Issue: [RemitFlow Frontend #281](https://github.com/RemitFlow/RemitFlow-Frontend/issues/281) +Existing submission: [PR #298](https://github.com/RemitFlow/RemitFlow-Frontend/pull/298) + +## Problem and repair + +The simulated localStorage API shares a transfer collection across wallets. +Previously, `listTransfers` validated that entire collection before selecting +the requesting actor's rows. As a result, the validity of a different wallet's +records could change this wallet's error behavior. + +| Stored rows | Before | Repaired behavior | +| --- | --- | --- | +| Only a corrupt foreign-wallet row | Schema error for an empty current-wallet history | Empty list | +| Valid current-wallet row and corrupt foreign-wallet row | Correct list, but foreign schema diagnostics were logged | Correct list without foreign diagnostics | +| Corrupt current-wallet row and valid foreign-wallet row | Empty list concealed the current wallet's schema failure | `ContractViolationError` | + +The API now uses the existing `isVisibleToActor` predicate on raw rows before +validating individual transfers. It still passes a non-array top-level response +to the schema validator, so malformed response structure remains an error. +Visible corrupt records retain the existing validation policy. + +The same visibility options are reused for the final search. Legacy records +without an actor remain visible only to the demo actor. The query actor is +normalized as before, while stored actor strings retain exact matching. +Cancellation, filtering, stable order and result caps keep their existing paths. + +This changes the localStorage demonstration API; it does not establish a +server-side authorization boundary for an external service. + +## Source and execution evidence + +The repaired source commit is +[`935caeb37e3932f85c2555f9086bde2c771c20bd`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/935caeb37e3932f85c2555f9086bde2c771c20bd), +a direct child of `21fccd57636a96d6c844289157e9c4693419a478`. +The preceding navigation and capped-selector changes are preserved. + +| Executed file | Git blob | +| --- | --- | +| `src/services/api.js` | `1ec5dcd1f163d536c2f2cb6059fa6218c92f7d41` | +| `test/unit/list-transfers-search.test.js` | `2d2c4753103589a1c53421a7e8a66734f566e0ef` | + +Two focused executions completed on 2026-10-04: + +- **Direct production API replay: 9 passed, 0 failed.** Node 24.19.0 imported + the changed API and actual contract modules, using synthetic JSON through an + in-memory localStorage interface and the API's real 400 ms timers. It covered + the three reproduced failures, current-wallet corruption alone, valid mixed + actors, demo and non-demo legacy visibility, a non-array response, and query + trimming with exact stored actor strings. +- **Maintained unit file: 12 passed, 0 failed, 1.86 seconds.** Node 24.19.0 and + Vitest 4.1.10 ran all five existing cases and seven added regressions together + at the source commit above. No test cases were removed or weakened. The + retained installed runtime's package manifest, lockfile, Vite configuration + and test setup matched this checkout by Git blob; no source changes or new + dependency installation were needed for that run. + +Run the maintained selection from an installed checkout: + +```bash +node node_modules/vitest/vitest.mjs run test/unit/list-transfers-search.test.js --maxWorkers=1 --fileParallelism=false +``` + +The observed run set `TMPDIR` to a writable task-owned tmpfs directory because +the shared cloud disk was full. An earlier dependency-install attempt in a +different checkout stopped with `ENOSPC`; it did not produce a test result or +change dependencies or lockfiles. Reusing the compatible installed runtime +closed that execution gap. + +Both edited JavaScript files also passed syntax checks, and the source diff +passed whitespace checks. No repository-wide suite, production build, browser, +wallet or external-provider run was repeated for this repair. Earlier +[selector performance evidence](../performance/transfer-search-20261004/README.md) +retains its own source revision and measurement scope. These results establish +neither sponsor acceptance nor an award or payment. From b3af1368de7e2d043a79ae49fe912584375eadfa Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 07:54:15 -0400 Subject: [PATCH 10/14] perf(search): prepare query-invariant transfer filters once --- scripts/bench-transfer-filter-preparation.mjs | 158 ++++++++++++++++++ src/utils/transferSearch.js | 41 +++-- test/unit/transfer-filter-preparation.test.js | 55 ++++++ 3 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 scripts/bench-transfer-filter-preparation.mjs create mode 100644 test/unit/transfer-filter-preparation.test.js diff --git a/scripts/bench-transfer-filter-preparation.mjs b/scripts/bench-transfer-filter-preparation.mjs new file mode 100644 index 0000000..257c1f7 --- /dev/null +++ b/scripts/bench-transfer-filter-preparation.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// Compare complete production selectors; no benchmark replacement of product code. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { performance } from 'node:perf_hooks'; +import os from 'node:os'; + +const [baselineArg, candidateArg = 'src/utils/transferSearch.js'] = process.argv.slice(2); +if (!baselineArg) { + console.error('Usage: node --expose-gc scripts/bench-transfer-filter-preparation.mjs BASELINE [CANDIDATE]'); + process.exit(2); +} +const baselinePath = resolve(baselineArg); +const candidatePath = resolve(candidateArg); +const baseline = await import(pathToFileURL(baselinePath)); +const candidate = await import(pathToFileURL(candidatePath)); +const gitBlob = (path) => { + const bytes = readFileSync(path); + return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); +}; +const now = new Date('2026-10-04T12:00:00.000Z'); +const options = { now, legacyActorId: 'demo' }; +let seed = 20261004; +const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 2 ** 32); +function makeHistory(count) { + const statuses = ['pending', 'submitted', 'completed', 'settled', 'failed']; + const rows = Array.from({ length: count }, (_, index) => ({ + id: String(index).padStart(8, '0'), + actorId: index % 7 === 0 ? 'other' : 'demo', + recipient: index % 3 === 0 ? 'BOB' : 'Alice Smith', + status: statuses[index % statuses.length], + createdAt: new Date(now.getTime() - (index % 120) * 86400000 - (index % 1000)).toISOString(), + })); + for (let i = rows.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)); + [rows[i], rows[j]] = [rows[j], rows[i]]; + } + return Object.freeze(rows.map(Object.freeze)); +} +const large = makeHistory(100_000); +const small = large.slice(0, 5_000); +const cases = [ + ['5k all time', small, { actorId: 'demo' }], + ['100k all time', large, { actorId: 'demo' }], + ['100k date window', large, { actorId: 'demo', range: '30d' }], + ['100k status and search', large, { actorId: 'demo', status: 'submitted', search: ' ALICE ' }], + ['100k combined filters', large, { actorId: 'demo', status: 'settled', search: ' ALICE ', range: '90d', limit: 500 }], +]; +function compareResults(expected, actual) { + assert.deepEqual(actual, expected); + assert.equal(actual.items.length, expected.items.length); + actual.items.forEach((item, index) => assert.strictEqual(item, expected.items[index])); +} +const median = (samples) => [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]; +function measure(selector, rows, query) { + globalThis.gc?.(); + const start = performance.now(); + const result = selector.applyTransferSearch(rows, query, options); + return { result, ms: performance.now() - start }; +} +function operationCounts(selector, rows, query) { + // Instrumentation is separate from all timing calls and restored in finally. + const NativeDate = globalThis.Date; + const originalLower = String.prototype.toLowerCase; + const originalMatch = String.prototype.match; + const counts = { date_constructions: 0, lowercase_calls: 0, string_match_calls: 0 }; + globalThis.Date = new Proxy(NativeDate, { + construct(target, args, newTarget) { + counts.date_constructions += 1; + return Reflect.construct(target, args, newTarget); + }, + }); + String.prototype.toLowerCase = function (...args) { + counts.lowercase_calls += 1; + return Reflect.apply(originalLower, this, args); + }; + String.prototype.match = function (...args) { + counts.string_match_calls += 1; + return Reflect.apply(originalMatch, this, args); + }; + try { + return { counts, result: selector.applyTransferSearch(rows, query, options) }; + } finally { + globalThis.Date = NativeDate; + String.prototype.toLowerCase = originalLower; + String.prototype.match = originalMatch; + } +} +const observations = []; +for (const [name, rows, query] of cases) { + const expected = baseline.applyTransferSearch(rows, query, options); + for (let warmup = 0; warmup < 3; warmup += 1) { + compareResults(expected, baseline.applyTransferSearch(rows, query, options)); + compareResults(expected, candidate.applyTransferSearch(rows, query, options)); + } + const pairs = []; + for (let repeat = 0; repeat < 9; repeat += 1) { + const order = repeat % 2 === 0 ? ['baseline', 'candidate'] : ['candidate', 'baseline']; + const pair = { order }; + for (const key of order) { + const measured = measure(key === 'baseline' ? baseline : candidate, rows, query); + compareResults(expected, measured.result); + pair[key] = measured.ms; + } + pairs.push(pair); + } + const oldCounts = operationCounts(baseline, rows, query); + const newCounts = operationCounts(candidate, rows, query); + compareResults(expected, oldCounts.result); + compareResults(expected, newCounts.result); + const baselineMedian = median(pairs.map((pair) => pair.baseline)); + const candidateMedian = median(pairs.map((pair) => pair.candidate)); + observations.push({ name, rows: rows.length, query, total_matched: expected.totalMatched, + returned: expected.items.length, baseline_median_ms: baselineMedian, + candidate_median_ms: candidateMedian, reduction_percent: 100 * (1 - candidateMedian / baselineMedian), + operation_counts: { baseline: oldCounts.counts, candidate: newCounts.counts }, + result_sha256: createHash('sha256').update(JSON.stringify(expected)).digest('hex'), pairs }); +} +// Differential compatibility checks are untimed and use unchanged row identities. +let compatibility = 0; +const dates = [undefined, null, '', 'bad', 0, -1, '0', '1970-01-01T00:00:00Z', + '1969-12-31T23:59:59Z', '2026-10-04T12:00:00Z', '2026-09-27T12:00:00Z', + '2026-09-27T11:59:59.999Z', new Date('2026-09-27T12:00:00Z'), new Date(NaN), + '2027-01-01T00:00:00Z']; +const ranges = ['', '0d', '0007d', '7d', '30d', '90d', 'unknown', '99999999999999999d', '9'.repeat(400) + 'd']; +const edgeRows = Object.freeze(dates.flatMap((createdAt, index) => [ + { id: `a${index}`, createdAt, recipient: 'Alice', status: 'settled', actorId: 'demo' }, + { id: `b${index}`, createdAt, recipient: 'BOB', status: 'submitted' }, + { id: `c${index}`, createdAt, recipient: 'Älice', status: 'failed', actorId: 'other' }, +]).map(Object.freeze)); +for (const instant of [now, new Date(NaN)]) { + for (const range of ranges) { + for (const status of ['', 'settled', 'submitted', 'invalid']) { + for (const search of ['', ' ALICE ', 'nobody']) { + const filters = { range, status, search }; + for (const row of edgeRows) { + assert.equal(candidate.matchesTransferFilters(row, filters, instant), baseline.matchesTransferFilters(row, filters, instant)); + compatibility += 1; + } + for (const limit of [1, 10, 500]) { + const query = { actorId: 'demo', ...filters, limit }; + const opt = { ...options, now: instant }; + compareResults(baseline.applyTransferSearch(edgeRows, query, opt), candidate.applyTransferSearch(edgeRows, query, opt)); + compatibility += 1; + } + } + } + } +} +console.log(JSON.stringify({ schema: 'transfer-filter-preparation-benchmark/v1', + executed_at: new Date().toISOString(), node: process.version, platform: `${process.platform}/${process.arch}`, + cpu: os.cpus()[0]?.model, source: { baseline_blob: gitBlob(baselinePath), candidate_blob: gitBlob(candidatePath) }, + method: '3 warmups; 9 paired samples, alternating order; synthetic fixed records; setup/assertions/GC/counting outside timed calls', + scope: 'Complete production applyTransferSearch and its actual dependency modules, not storage/API/React/browser/provider latency.', + compatibility_checks: compatibility, observations }, null, 2)); diff --git a/src/utils/transferSearch.js b/src/utils/transferSearch.js index a75e2e4..504be82 100644 --- a/src/utils/transferSearch.js +++ b/src/utils/transferSearch.js @@ -8,7 +8,7 @@ */ import { normalizeStatus } from '../services/contracts/transfer.js'; -import { isWithinDateRange } from './dateRange.js'; +import { getDateRangeDays } from './dateRange.js'; /** Debounce window for free-text search queries (ms). */ export const SEARCH_DEBOUNCE_MS = 300; @@ -88,24 +88,34 @@ export function matchesTransferFilters( filters = {}, now = new Date(), ) { + const matches = prepareTransferFilters(filters, now); + return matches ? matches(transfer) : true; +} + +/** Prepare only query-owned values; each invocation still reads fresh rows. */ +function prepareTransferFilters(filters, now) { const status = filters.status ? (normalizeStatus(filters.status) ?? filters.status) : ''; - if (status && normalizeStatus(transfer.status) !== status) return false; - - const search = String(filters.search ?? '') - .trim() - .toLowerCase(); - if (search) { - const recipient = String(transfer.recipient ?? '').toLowerCase(); - if (!recipient.includes(search)) return false; - } + const search = String(filters.search ?? '').trim().toLowerCase(); + const days = getDateRangeDays(filters.range ?? ''); + // Keep Date's clipping/invalid-window semantics, including oversized presets. + const cutoff = days + ? new Date(now.getTime() - days * 86_400_000).getTime() + : null; - if (!isWithinDateRange(transfer.createdAt, filters.range ?? '', now)) { - return false; - } + if (!status && !search && cutoff === null) return null; - return true; + return (transfer) => { + if (status && normalizeStatus(transfer.status) !== status) return false; + if ( + search && + !String(transfer.recipient ?? '').toLowerCase().includes(search) + ) { + return false; + } + return cutoff === null || new Date(transfer.createdAt).getTime() >= cutoff; + }; } /** @@ -176,6 +186,7 @@ function retainSearchEntry(heap, entry, limit) { export function applyTransferSearch(transfers, query, options = {}) { const normalized = normalizeTransferQuery(query); const now = options.now ?? new Date(); + const matchesFilters = prepareTransferFilters(normalized, now); const selected = []; let totalMatched = 0; const visibility = { legacyActorId: options.legacyActorId }; @@ -183,7 +194,7 @@ export function applyTransferSearch(transfers, query, options = {}) { (transfers ?? []).forEach((transfer, index) => { if ( !isVisibleToActor(transfer, normalized.actorId, visibility) || - !matchesTransferFilters(transfer, normalized, now) + (matchesFilters && !matchesFilters(transfer)) ) { return; } diff --git a/test/unit/transfer-filter-preparation.test.js b/test/unit/transfer-filter-preparation.test.js new file mode 100644 index 0000000..cea8346 --- /dev/null +++ b/test/unit/transfer-filter-preparation.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { applyTransferSearch } from '../../src/utils/transferSearch.js'; + +const rows = Object.freeze( + Array.from({ length: 40 }, (_, index) => + Object.freeze({ + id: String(index), + actorId: 'demo', + recipient: 'Alice', + status: 'settled', + createdAt: '2026-09-27T12:00:00.000Z', + }), + ), +); + +describe('transfer filter preparation', () => { + it('prepares the date window once per query instead of once per row', () => { + const now = new Date('2026-10-04T12:00:00.000Z'); + const instant = now.getTime(); + let clockReads = 0; + now.getTime = () => { + clockReads += 1; + return instant; + }; + const result = applyTransferSearch( + rows, + { actorId: 'demo', search: ' ALICE ', status: 'success', range: '7d' }, + { now }, + ); + expect(result.totalMatched).toBe(40); + expect(result.items).toHaveLength(40); + expect(clockReads).toBe(1); + }); + + it('refreshes the window for each query and preserves empty and invalid windows', () => { + const now = new Date('2026-10-04T12:00:00.000Z'); + const query = { actorId: 'demo', range: '7d' }; + expect(applyTransferSearch(rows, query, { now }).totalMatched).toBe(40); + now.setTime(now.getTime() + 1); + expect(applyTransferSearch(rows, query, { now }).totalMatched).toBe(0); + for (const range of ['', '0d', 'unknown']) { + expect( + applyTransferSearch(rows, { ...query, range }, { now }).totalMatched, + ).toBe(40); + } + for (const range of ['99999999999999999d', `${'9'.repeat(400)}d`]) { + expect( + applyTransferSearch(rows, { ...query, range }, { now }).totalMatched, + ).toBe(0); + } + expect( + applyTransferSearch(rows, query, { now: new Date(NaN) }).totalMatched, + ).toBe(0); + }); +}); From 71746fa5552f2dbb0ad3727637b52b9f64747410 Mon Sep 17 00:00:00 2001 From: woahwhattheheck <293286387+woahwhattheheck@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:56:10 +0000 Subject: [PATCH 11/14] perf(search): record focused filter-preparation results --- .../README.md | 40 ++ .../results.json | 518 ++++++++++++++++++ scripts/bench-transfer-filter-preparation.mjs | 195 +++++-- src/utils/transferSearch.js | 12 +- 4 files changed, 724 insertions(+), 41 deletions(-) create mode 100644 docs/performance/transfer-filter-preparation-20261004/README.md create mode 100644 docs/performance/transfer-filter-preparation-20261004/results.json diff --git a/docs/performance/transfer-filter-preparation-20261004/README.md b/docs/performance/transfer-filter-preparation-20261004/README.md new file mode 100644 index 0000000..6ac1618 --- /dev/null +++ b/docs/performance/transfer-filter-preparation-20261004/README.md @@ -0,0 +1,40 @@ +# Query-invariant transfer filter preparation + +Continuation of RemitFlow Frontend #281 on the existing PR #298. The earlier bounded-heap selector is retained; this comparison starts from that already-optimized implementation, not the old full sort. + +The status alias, folded search term and date-window cutoff are prepared once per invocation. Rows are still read and matched on every query; no result cache or stale-window reuse is introduced. Empty filters bypass the matcher. Actor visibility, returned row identity, stable top-k membership, totals and scope keys are retained. + +## Focused native execution + +Node v22.23.3; Vitest 4.1.10; Prettier 3.9.6; linux/x64; AMD EPYC 7763 64-Core Processor. +The existing 11 utility cases and two new maintained regressions passed together: **13 passed, zero failures or skipped tests**. The new cases cover one clock read per query, inclusive date boundaries, fresh time on subsequent calls, unbounded presets and invalid/oversized windows. +The original repository Vite configuration, jsdom setup and locked dependencies were used. Only these two utility test files ran; no full application suite, build, browser or provider execution is claimed. + +[Execution run](https://github.com/woahwhattheheck/RemitFlow-Frontend/actions/runs/37200367081). Candidate before formatting: b3af1368de7e2d043a79ae49fe912584375eadfa. Tests and timings below executed the formatted files committed with this report. + +## Complete-selector timings + +Synthetic histories; three warmups followed by nine paired samples with alternating order. Fixture creation, assertions, garbage collection and operation counting are outside the timed selector call. All paired outputs and row references matched; a further 10,368 untimed compatibility comparisons passed. + +| Workload | Baseline ms | Prepared ms | Time reduction | +| --- | ---: | ---: | ---: | +| 5k all time | 3.753 | 3.100 | 17.40% | +| 100k all time | 38.884 | 30.025 | 22.78% | +| 100k date window | 77.677 | 41.512 | 46.56% | +| 100k status and search | 23.599 | 20.468 | 13.27% | +| 100k combined filters | 37.811 | 26.264 | 30.54% | + +All raw paired samples, output digests, source Git blobs and separately measured operation counts are in [results.json](results.json). Negative reductions, if present, are regressions, not speedups. These measurements exclude localStorage decoding, schema validation, the simulated API delay, React rendering and network/provider work; they are not whole-page or fleet-throughput results. + +An earlier local prototype without the empty-filter bypass regressed the 5,000-row all-time case by 15.93% in one paired measurement. That prototype was rejected. The revised local pass measured 1.536 versus 1.542 ms for that small workload (effectively unchanged), 69.875 versus 40.020 ms for the 100,000-row date window and 32.628 versus 22.354 ms for combined filters. Those exploratory Node 22.16.0 observations are separate from the source-bound execution above. + +## Reproduce + +```bash +git show ecefe06d68ca6838434b755525cfc3cc6c701d4f:src/utils/transferSearch.js > src/utils/transferSearch.baseline.js +node --expose-gc scripts/bench-transfer-filter-preparation.mjs src/utils/transferSearch.baseline.js +npx vitest run test/unit/transfer-search.test.js test/unit/transfer-filter-preparation.test.js +rm src/utils/transferSearch.baseline.js +``` + +No existing test, package manifest, lockfile, UI or API path was changed. The temporary execution workflow is not part of the product result. Original contribution and claim custody remain unchanged; this report establishes neither maintainer acceptance nor an award/payment. diff --git a/docs/performance/transfer-filter-preparation-20261004/results.json b/docs/performance/transfer-filter-preparation-20261004/results.json new file mode 100644 index 0000000..4581191 --- /dev/null +++ b/docs/performance/transfer-filter-preparation-20261004/results.json @@ -0,0 +1,518 @@ +{ + "schema": "transfer-filter-preparation-benchmark/v1", + "executed_at": "2026-10-04T11:56:10.496Z", + "node": "v22.23.3", + "platform": "linux/x64", + "cpu": "AMD EPYC 7763 64-Core Processor", + "source": { + "baseline_blob": "a75e2e4fe88e16cb0b03ed97aa0a1c18dbba1274", + "candidate_blob": "f072d7879d4bceb74d3635f6bfda0797e7f549a0" + }, + "method": "3 warmups; 9 paired samples, alternating order; synthetic fixed records; setup/assertions/GC/counting outside timed calls", + "scope": "Complete production applyTransferSearch and its actual dependency modules, not storage/API/React/browser/provider latency.", + "compatibility_checks": 10368, + "observations": [ + { + "name": "5k all time", + "rows": 5000, + "query": { + "actorId": "demo" + }, + "total_matched": 4288, + "returned": 100, + "baseline_median_ms": 3.7526440000000036, + "candidate_median_ms": 3.0997669999999857, + "reduction_percent": 17.397786733833996, + "operation_counts": { + "baseline": { + "date_constructions": 0, + "lowercase_calls": 4288, + "string_match_calls": 4288 + }, + "candidate": { + "date_constructions": 0, + "lowercase_calls": 1, + "string_match_calls": 1 + } + }, + "result_sha256": "af8fc4354239ad98cc6421adcdd67840ffcfd6f57d63e1397062a3115b9113b6", + "pairs": [ + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 3.7526440000000036, + "candidate": 5.348464000000007 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 3.5111739999999827, + "baseline": 3.810030999999981 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 3.834937000000025, + "candidate": 3.110306000000037 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 3.0997669999999857, + "baseline": 3.839615999999978 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 2.1969440000000304, + "candidate": 1.7528870000000438 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 2.3584249999999543, + "baseline": 3.0319599999999696 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 2.705592000000024, + "candidate": 3.094547000000034 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 3.2347990000000095, + "baseline": 3.7058159999999702 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 3.8358690000000024, + "candidate": 3.0322309999999675 + } + ] + }, + { + "name": "100k all time", + "rows": 100000, + "query": { + "actorId": "demo" + }, + "total_matched": 85714, + "returned": 100, + "baseline_median_ms": 38.88385699999981, + "candidate_median_ms": 30.024726999999984, + "reduction_percent": 22.783567998410923, + "operation_counts": { + "baseline": { + "date_constructions": 0, + "lowercase_calls": 85714, + "string_match_calls": 85714 + }, + "candidate": { + "date_constructions": 0, + "lowercase_calls": 1, + "string_match_calls": 1 + } + }, + "result_sha256": "e70cbd4515bd5b7a648f095dde799467c191ef6591ba8d60c8b88b342409cb90", + "pairs": [ + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 41.91509599999995, + "candidate": 33.09540900000002 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 31.61123299999997, + "baseline": 43.19093299999997 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 36.904979000000026, + "candidate": 29.873694999999998 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 29.382939999999962, + "baseline": 37.878693999999996 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 37.82792900000004, + "candidate": 29.612397999999985 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 32.902851000000055, + "baseline": 41.05498299999999 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 36.653589999999895, + "candidate": 29.378081000000066 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 30.024726999999984, + "baseline": 38.88385699999981 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 40.884575999999925, + "candidate": 32.89763100000005 + } + ] + }, + { + "name": "100k date window", + "rows": 100000, + "query": { + "actorId": "demo", + "range": "30d" + }, + "total_matched": 21445, + "returned": 100, + "baseline_median_ms": 77.67733499999986, + "candidate_median_ms": 41.512255000000096, + "reduction_percent": 46.55808544410004, + "operation_counts": { + "baseline": { + "date_constructions": 171428, + "lowercase_calls": 85714, + "string_match_calls": 85714 + }, + "candidate": { + "date_constructions": 85715, + "lowercase_calls": 1, + "string_match_calls": 1 + } + }, + "result_sha256": "74024fb5b745a035677c5a899ffca5d562db9816715203d1d2aacd66db985ad0", + "pairs": [ + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 77.11932500000012, + "candidate": 42.58652699999993 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 39.15601400000014, + "baseline": 76.42492099999981 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 80.41769300000033, + "candidate": 43.91076500000008 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 38.1670610000001, + "baseline": 74.1527659999997 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 80.388508, + "candidate": 41.512255000000096 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 41.521818999999596, + "baseline": 77.67733499999986 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 78.02949099999978, + "candidate": 39.358372000000145 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 42.156524999999874, + "baseline": 74.95974699999988 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 77.83227399999987, + "candidate": 39.81588000000011 + } + ] + }, + { + "name": "100k status and search", + "rows": 100000, + "query": { + "actorId": "demo", + "status": "submitted", + "search": " ALICE " + }, + "total_matched": 22857, + "returned": 100, + "baseline_median_ms": 23.599294999999984, + "candidate_median_ms": 20.46779400000014, + "reduction_percent": 13.269468431153753, + "operation_counts": { + "baseline": { + "date_constructions": 0, + "lowercase_calls": 68570, + "string_match_calls": 22857 + }, + "candidate": { + "date_constructions": 0, + "lowercase_calls": 34286, + "string_match_calls": 1 + } + }, + "result_sha256": "274de23d2629ae95bc92c3234c680e046caff8acb836956fe2f17e30c96736a7", + "pairs": [ + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 29.14542900000015, + "candidate": 22.70290299999988 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 19.512673000000177, + "baseline": 29.16711899999973 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 22.40157100000033, + "candidate": 19.63978999999972 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 20.533166000000165, + "baseline": 23.599294999999984 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 22.065374000000247, + "candidate": 20.46779400000014 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 19.539984000000004, + "baseline": 21.87729300000001 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 28.005221999999776, + "candidate": 20.249667000000045 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 23.231468999999834, + "baseline": 24.938012000000526 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 21.595057000000452, + "candidate": 21.847137000000657 + } + ] + }, + { + "name": "100k combined filters", + "rows": 100000, + "query": { + "actorId": "demo", + "status": "settled", + "search": " ALICE ", + "range": "90d", + "limit": 500 + }, + "total_matched": 17145, + "returned": 500, + "baseline_median_ms": 37.81098099999963, + "candidate_median_ms": 26.264164999999593, + "reduction_percent": 30.53826082957263, + "operation_counts": { + "baseline": { + "date_constructions": 45714, + "lowercase_calls": 68572, + "string_match_calls": 22857 + }, + "candidate": { + "date_constructions": 22858, + "lowercase_calls": 34287, + "string_match_calls": 1 + } + }, + "result_sha256": "c18859b9b94bbbef72690fbdd332764c4fb3c1ace6e617a2c15e4e60118cce45", + "pairs": [ + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 36.961607000000186, + "candidate": 25.90255100000013 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 25.181555000000117, + "baseline": 37.81098099999963 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 40.81570499999998, + "candidate": 30.698475999999573 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 26.264164999999593, + "baseline": 40.82644600000003 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 43.52538200000072, + "candidate": 28.478516000000127 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 24.604769999999917, + "baseline": 37.18445199999951 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 36.857842999999775, + "candidate": 25.36314500000026 + }, + { + "order": [ + "candidate", + "baseline" + ], + "candidate": 35.77864199999931, + "baseline": 37.269722 + }, + { + "order": [ + "baseline", + "candidate" + ], + "baseline": 43.32392400000026, + "candidate": 28.913426000000072 + } + ] + } + ] +} diff --git a/scripts/bench-transfer-filter-preparation.mjs b/scripts/bench-transfer-filter-preparation.mjs index 257c1f7..1bcf4b3 100644 --- a/scripts/bench-transfer-filter-preparation.mjs +++ b/scripts/bench-transfer-filter-preparation.mjs @@ -8,9 +8,12 @@ import { pathToFileURL } from 'node:url'; import { performance } from 'node:perf_hooks'; import os from 'node:os'; -const [baselineArg, candidateArg = 'src/utils/transferSearch.js'] = process.argv.slice(2); +const [baselineArg, candidateArg = 'src/utils/transferSearch.js'] = + process.argv.slice(2); if (!baselineArg) { - console.error('Usage: node --expose-gc scripts/bench-transfer-filter-preparation.mjs BASELINE [CANDIDATE]'); + console.error( + 'Usage: node --expose-gc scripts/bench-transfer-filter-preparation.mjs BASELINE [CANDIDATE]', + ); process.exit(2); } const baselinePath = resolve(baselineArg); @@ -19,12 +22,16 @@ const baseline = await import(pathToFileURL(baselinePath)); const candidate = await import(pathToFileURL(candidatePath)); const gitBlob = (path) => { const bytes = readFileSync(path); - return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); + return createHash('sha1') + .update(`blob ${bytes.length}\0`) + .update(bytes) + .digest('hex'); }; const now = new Date('2026-10-04T12:00:00.000Z'); const options = { now, legacyActorId: 'demo' }; let seed = 20261004; -const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 2 ** 32); +const random = () => + (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 2 ** 32; function makeHistory(count) { const statuses = ['pending', 'submitted', 'completed', 'settled', 'failed']; const rows = Array.from({ length: count }, (_, index) => ({ @@ -32,7 +39,9 @@ function makeHistory(count) { actorId: index % 7 === 0 ? 'other' : 'demo', recipient: index % 3 === 0 ? 'BOB' : 'Alice Smith', status: statuses[index % statuses.length], - createdAt: new Date(now.getTime() - (index % 120) * 86400000 - (index % 1000)).toISOString(), + createdAt: new Date( + now.getTime() - (index % 120) * 86400000 - (index % 1000), + ).toISOString(), })); for (let i = rows.length - 1; i > 0; i -= 1) { const j = Math.floor(random() * (i + 1)); @@ -46,15 +55,32 @@ const cases = [ ['5k all time', small, { actorId: 'demo' }], ['100k all time', large, { actorId: 'demo' }], ['100k date window', large, { actorId: 'demo', range: '30d' }], - ['100k status and search', large, { actorId: 'demo', status: 'submitted', search: ' ALICE ' }], - ['100k combined filters', large, { actorId: 'demo', status: 'settled', search: ' ALICE ', range: '90d', limit: 500 }], + [ + '100k status and search', + large, + { actorId: 'demo', status: 'submitted', search: ' ALICE ' }, + ], + [ + '100k combined filters', + large, + { + actorId: 'demo', + status: 'settled', + search: ' ALICE ', + range: '90d', + limit: 500, + }, + ], ]; function compareResults(expected, actual) { assert.deepEqual(actual, expected); assert.equal(actual.items.length, expected.items.length); - actual.items.forEach((item, index) => assert.strictEqual(item, expected.items[index])); + actual.items.forEach((item, index) => + assert.strictEqual(item, expected.items[index]), + ); } -const median = (samples) => [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]; +const median = (samples) => + [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]; function measure(selector, rows, query) { globalThis.gc?.(); const start = performance.now(); @@ -66,7 +92,11 @@ function operationCounts(selector, rows, query) { const NativeDate = globalThis.Date; const originalLower = String.prototype.toLowerCase; const originalMatch = String.prototype.match; - const counts = { date_constructions: 0, lowercase_calls: 0, string_match_calls: 0 }; + const counts = { + date_constructions: 0, + lowercase_calls: 0, + string_match_calls: 0, + }; globalThis.Date = new Proxy(NativeDate, { construct(target, args, newTarget) { counts.date_constructions += 1; @@ -82,7 +112,10 @@ function operationCounts(selector, rows, query) { return Reflect.apply(originalMatch, this, args); }; try { - return { counts, result: selector.applyTransferSearch(rows, query, options) }; + return { + counts, + result: selector.applyTransferSearch(rows, query, options), + }; } finally { globalThis.Date = NativeDate; String.prototype.toLowerCase = originalLower; @@ -93,15 +126,26 @@ const observations = []; for (const [name, rows, query] of cases) { const expected = baseline.applyTransferSearch(rows, query, options); for (let warmup = 0; warmup < 3; warmup += 1) { - compareResults(expected, baseline.applyTransferSearch(rows, query, options)); - compareResults(expected, candidate.applyTransferSearch(rows, query, options)); + compareResults( + expected, + baseline.applyTransferSearch(rows, query, options), + ); + compareResults( + expected, + candidate.applyTransferSearch(rows, query, options), + ); } const pairs = []; for (let repeat = 0; repeat < 9; repeat += 1) { - const order = repeat % 2 === 0 ? ['baseline', 'candidate'] : ['candidate', 'baseline']; + const order = + repeat % 2 === 0 ? ['baseline', 'candidate'] : ['candidate', 'baseline']; const pair = { order }; for (const key of order) { - const measured = measure(key === 'baseline' ? baseline : candidate, rows, query); + const measured = measure( + key === 'baseline' ? baseline : candidate, + rows, + query, + ); compareResults(expected, measured.result); pair[key] = measured.ms; } @@ -113,46 +157,121 @@ for (const [name, rows, query] of cases) { compareResults(expected, newCounts.result); const baselineMedian = median(pairs.map((pair) => pair.baseline)); const candidateMedian = median(pairs.map((pair) => pair.candidate)); - observations.push({ name, rows: rows.length, query, total_matched: expected.totalMatched, - returned: expected.items.length, baseline_median_ms: baselineMedian, - candidate_median_ms: candidateMedian, reduction_percent: 100 * (1 - candidateMedian / baselineMedian), - operation_counts: { baseline: oldCounts.counts, candidate: newCounts.counts }, - result_sha256: createHash('sha256').update(JSON.stringify(expected)).digest('hex'), pairs }); + observations.push({ + name, + rows: rows.length, + query, + total_matched: expected.totalMatched, + returned: expected.items.length, + baseline_median_ms: baselineMedian, + candidate_median_ms: candidateMedian, + reduction_percent: 100 * (1 - candidateMedian / baselineMedian), + operation_counts: { + baseline: oldCounts.counts, + candidate: newCounts.counts, + }, + result_sha256: createHash('sha256') + .update(JSON.stringify(expected)) + .digest('hex'), + pairs, + }); } // Differential compatibility checks are untimed and use unchanged row identities. let compatibility = 0; -const dates = [undefined, null, '', 'bad', 0, -1, '0', '1970-01-01T00:00:00Z', - '1969-12-31T23:59:59Z', '2026-10-04T12:00:00Z', '2026-09-27T12:00:00Z', - '2026-09-27T11:59:59.999Z', new Date('2026-09-27T12:00:00Z'), new Date(NaN), - '2027-01-01T00:00:00Z']; -const ranges = ['', '0d', '0007d', '7d', '30d', '90d', 'unknown', '99999999999999999d', '9'.repeat(400) + 'd']; -const edgeRows = Object.freeze(dates.flatMap((createdAt, index) => [ - { id: `a${index}`, createdAt, recipient: 'Alice', status: 'settled', actorId: 'demo' }, - { id: `b${index}`, createdAt, recipient: 'BOB', status: 'submitted' }, - { id: `c${index}`, createdAt, recipient: 'Älice', status: 'failed', actorId: 'other' }, -]).map(Object.freeze)); +const dates = [ + undefined, + null, + '', + 'bad', + 0, + -1, + '0', + '1970-01-01T00:00:00Z', + '1969-12-31T23:59:59Z', + '2026-10-04T12:00:00Z', + '2026-09-27T12:00:00Z', + '2026-09-27T11:59:59.999Z', + new Date('2026-09-27T12:00:00Z'), + new Date(NaN), + '2027-01-01T00:00:00Z', +]; +const ranges = [ + '', + '0d', + '0007d', + '7d', + '30d', + '90d', + 'unknown', + '99999999999999999d', + '9'.repeat(400) + 'd', +]; +const edgeRows = Object.freeze( + dates + .flatMap((createdAt, index) => [ + { + id: `a${index}`, + createdAt, + recipient: 'Alice', + status: 'settled', + actorId: 'demo', + }, + { id: `b${index}`, createdAt, recipient: 'BOB', status: 'submitted' }, + { + id: `c${index}`, + createdAt, + recipient: 'Älice', + status: 'failed', + actorId: 'other', + }, + ]) + .map(Object.freeze), +); for (const instant of [now, new Date(NaN)]) { for (const range of ranges) { for (const status of ['', 'settled', 'submitted', 'invalid']) { for (const search of ['', ' ALICE ', 'nobody']) { const filters = { range, status, search }; for (const row of edgeRows) { - assert.equal(candidate.matchesTransferFilters(row, filters, instant), baseline.matchesTransferFilters(row, filters, instant)); + assert.equal( + candidate.matchesTransferFilters(row, filters, instant), + baseline.matchesTransferFilters(row, filters, instant), + ); compatibility += 1; } for (const limit of [1, 10, 500]) { const query = { actorId: 'demo', ...filters, limit }; const opt = { ...options, now: instant }; - compareResults(baseline.applyTransferSearch(edgeRows, query, opt), candidate.applyTransferSearch(edgeRows, query, opt)); + compareResults( + baseline.applyTransferSearch(edgeRows, query, opt), + candidate.applyTransferSearch(edgeRows, query, opt), + ); compatibility += 1; } } } } } -console.log(JSON.stringify({ schema: 'transfer-filter-preparation-benchmark/v1', - executed_at: new Date().toISOString(), node: process.version, platform: `${process.platform}/${process.arch}`, - cpu: os.cpus()[0]?.model, source: { baseline_blob: gitBlob(baselinePath), candidate_blob: gitBlob(candidatePath) }, - method: '3 warmups; 9 paired samples, alternating order; synthetic fixed records; setup/assertions/GC/counting outside timed calls', - scope: 'Complete production applyTransferSearch and its actual dependency modules, not storage/API/React/browser/provider latency.', - compatibility_checks: compatibility, observations }, null, 2)); +console.log( + JSON.stringify( + { + schema: 'transfer-filter-preparation-benchmark/v1', + executed_at: new Date().toISOString(), + node: process.version, + platform: `${process.platform}/${process.arch}`, + cpu: os.cpus()[0]?.model, + source: { + baseline_blob: gitBlob(baselinePath), + candidate_blob: gitBlob(candidatePath), + }, + method: + '3 warmups; 9 paired samples, alternating order; synthetic fixed records; setup/assertions/GC/counting outside timed calls', + scope: + 'Complete production applyTransferSearch and its actual dependency modules, not storage/API/React/browser/provider latency.', + compatibility_checks: compatibility, + observations, + }, + null, + 2, + ), +); diff --git a/src/utils/transferSearch.js b/src/utils/transferSearch.js index 504be82..f072d78 100644 --- a/src/utils/transferSearch.js +++ b/src/utils/transferSearch.js @@ -97,7 +97,9 @@ function prepareTransferFilters(filters, now) { const status = filters.status ? (normalizeStatus(filters.status) ?? filters.status) : ''; - const search = String(filters.search ?? '').trim().toLowerCase(); + const search = String(filters.search ?? '') + .trim() + .toLowerCase(); const days = getDateRangeDays(filters.range ?? ''); // Keep Date's clipping/invalid-window semantics, including oversized presets. const cutoff = days @@ -110,7 +112,9 @@ function prepareTransferFilters(filters, now) { if (status && normalizeStatus(transfer.status) !== status) return false; if ( search && - !String(transfer.recipient ?? '').toLowerCase().includes(search) + !String(transfer.recipient ?? '') + .toLowerCase() + .includes(search) ) { return false; } @@ -210,7 +214,9 @@ export function applyTransferSearch(transfers, query, options = {}) { normalized.limit, ); }); - const items = selected.sort(compareSearchEntries).map((entry) => entry.transfer); + const items = selected + .sort(compareSearchEntries) + .map((entry) => entry.transfer); return { items, From 1e702e6437deee1bc1b0d75f5a3189abd178a226 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 08:09:23 -0400 Subject: [PATCH 12/14] fix(ci): finish existing checks on the composed transfer-search source Issue281 requires existing tests and CI passing without weakening unrelated cases. Reuse the four compatible confirmation/result and canonical-decimal fixtures, preserving all22 existing case titles and exact money, receipt, retry and no-create assertions. Give a valid amount with no available quote the existing pricing explanation instead of an incorrect positive-amount error. Apply only the existing formatter to remaining flagged source/evidence files; configuration and dependency files stay unchanged. Compose on KESTREL-104's71746fa5 successor. Preserve the exact new selector f072d787, benchmark script1bcf4b3f and maintained testcea83462, plus every historical benchmark value and source pin. The earlier502/52 composition was not published after its non-force update correctly rejected a concurrent source advance; its receipt is retained as an intermediate result. Final complete package selection on native tree50b2704a:504 passed,0 failed/pending/todo/skipped,53/53files. One run of the final composition:70.096s process, Node24.19.0/Vitest4.1.10. Production build passed:1.75s Vite,2.078s command. Formatting passed. Reuse the11:56:56Z critical-audit result on identical package/lock:0critical,20high,12moderate,2low. No benchmark or audit repeat, install, retry, exclusion, snapshot or timeout change. All355 tracked source blobs remained unchanged after execution. Exactly19 paths change from71746;336 are unchanged. Original PR298 and all prior contributions/claim custody remain intact. Hosted approval and sponsor acceptance remain separate. Full raw execution evidence follows in a documentation-only child. --- .../README.md | 14 +- .../results.json | 225 ++++-------------- .../transfer-search-20261004/README.md | 20 +- .../transfer-search-20261004/results.json | 32 +-- .../actor-scoped-transfer-validation.md | 14 +- scripts/bench-transfer-search.mjs | 58 ++++- src/context/AppContext.jsx | 1 - src/pages/SendMoney.jsx | 47 ++-- src/services/errors.js | 4 +- test/components/WalletButton.test.jsx | 5 +- test/integration/offline-reconnect.test.jsx | 64 ++++- test/integration/send-money-form.test.jsx | 11 +- .../integration/send-money-precision.test.jsx | 63 +++-- test/unit/AppContext.wallet.test.jsx | 1 - test/unit/list-transfers-search.test.js | 6 +- test/unit/quote.test.js | 20 +- test/unit/transfer-search.test.js | 37 ++- test/unit/useTransfers.errors.test.jsx | 4 +- vite-plugin-security-headers.js | 7 +- 19 files changed, 313 insertions(+), 320 deletions(-) diff --git a/docs/performance/transfer-filter-preparation-20261004/README.md b/docs/performance/transfer-filter-preparation-20261004/README.md index 6ac1618..b2cba58 100644 --- a/docs/performance/transfer-filter-preparation-20261004/README.md +++ b/docs/performance/transfer-filter-preparation-20261004/README.md @@ -16,13 +16,13 @@ The original repository Vite configuration, jsdom setup and locked dependencies Synthetic histories; three warmups followed by nine paired samples with alternating order. Fixture creation, assertions, garbage collection and operation counting are outside the timed selector call. All paired outputs and row references matched; a further 10,368 untimed compatibility comparisons passed. -| Workload | Baseline ms | Prepared ms | Time reduction | -| --- | ---: | ---: | ---: | -| 5k all time | 3.753 | 3.100 | 17.40% | -| 100k all time | 38.884 | 30.025 | 22.78% | -| 100k date window | 77.677 | 41.512 | 46.56% | -| 100k status and search | 23.599 | 20.468 | 13.27% | -| 100k combined filters | 37.811 | 26.264 | 30.54% | +| Workload | Baseline ms | Prepared ms | Time reduction | +| ---------------------- | ----------: | ----------: | -------------: | +| 5k all time | 3.753 | 3.100 | 17.40% | +| 100k all time | 38.884 | 30.025 | 22.78% | +| 100k date window | 77.677 | 41.512 | 46.56% | +| 100k status and search | 23.599 | 20.468 | 13.27% | +| 100k combined filters | 37.811 | 26.264 | 30.54% | All raw paired samples, output digests, source Git blobs and separately measured operation counts are in [results.json](results.json). Negative reductions, if present, are regressions, not speedups. These measurements exclude localStorage decoding, schema validation, the simulated API delay, React rendering and network/provider work; they are not whole-page or fleet-throughput results. diff --git a/docs/performance/transfer-filter-preparation-20261004/results.json b/docs/performance/transfer-filter-preparation-20261004/results.json index 4581191..ab2568c 100644 --- a/docs/performance/transfer-filter-preparation-20261004/results.json +++ b/docs/performance/transfer-filter-preparation-20261004/results.json @@ -38,74 +38,47 @@ "result_sha256": "af8fc4354239ad98cc6421adcdd67840ffcfd6f57d63e1397062a3115b9113b6", "pairs": [ { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 3.7526440000000036, "candidate": 5.348464000000007 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 3.5111739999999827, "baseline": 3.810030999999981 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 3.834937000000025, "candidate": 3.110306000000037 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 3.0997669999999857, "baseline": 3.839615999999978 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 2.1969440000000304, "candidate": 1.7528870000000438 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 2.3584249999999543, "baseline": 3.0319599999999696 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 2.705592000000024, "candidate": 3.094547000000034 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 3.2347990000000095, "baseline": 3.7058159999999702 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 3.8358690000000024, "candidate": 3.0322309999999675 } @@ -137,74 +110,47 @@ "result_sha256": "e70cbd4515bd5b7a648f095dde799467c191ef6591ba8d60c8b88b342409cb90", "pairs": [ { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 41.91509599999995, "candidate": 33.09540900000002 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 31.61123299999997, "baseline": 43.19093299999997 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 36.904979000000026, "candidate": 29.873694999999998 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 29.382939999999962, "baseline": 37.878693999999996 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 37.82792900000004, "candidate": 29.612397999999985 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 32.902851000000055, "baseline": 41.05498299999999 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 36.653589999999895, "candidate": 29.378081000000066 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 30.024726999999984, "baseline": 38.88385699999981 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 40.884575999999925, "candidate": 32.89763100000005 } @@ -237,74 +183,47 @@ "result_sha256": "74024fb5b745a035677c5a899ffca5d562db9816715203d1d2aacd66db985ad0", "pairs": [ { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 77.11932500000012, "candidate": 42.58652699999993 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 39.15601400000014, "baseline": 76.42492099999981 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 80.41769300000033, "candidate": 43.91076500000008 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 38.1670610000001, "baseline": 74.1527659999997 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 80.388508, "candidate": 41.512255000000096 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 41.521818999999596, "baseline": 77.67733499999986 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 78.02949099999978, "candidate": 39.358372000000145 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 42.156524999999874, "baseline": 74.95974699999988 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 77.83227399999987, "candidate": 39.81588000000011 } @@ -338,74 +257,47 @@ "result_sha256": "274de23d2629ae95bc92c3234c680e046caff8acb836956fe2f17e30c96736a7", "pairs": [ { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 29.14542900000015, "candidate": 22.70290299999988 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 19.512673000000177, "baseline": 29.16711899999973 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 22.40157100000033, "candidate": 19.63978999999972 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 20.533166000000165, "baseline": 23.599294999999984 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 22.065374000000247, "candidate": 20.46779400000014 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 19.539984000000004, "baseline": 21.87729300000001 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 28.005221999999776, "candidate": 20.249667000000045 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 23.231468999999834, "baseline": 24.938012000000526 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 21.595057000000452, "candidate": 21.847137000000657 } @@ -441,74 +333,47 @@ "result_sha256": "c18859b9b94bbbef72690fbdd332764c4fb3c1ace6e617a2c15e4e60118cce45", "pairs": [ { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 36.961607000000186, "candidate": 25.90255100000013 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 25.181555000000117, "baseline": 37.81098099999963 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 40.81570499999998, "candidate": 30.698475999999573 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 26.264164999999593, "baseline": 40.82644600000003 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 43.52538200000072, "candidate": 28.478516000000127 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 24.604769999999917, "baseline": 37.18445199999951 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 36.857842999999775, "candidate": 25.36314500000026 }, { - "order": [ - "candidate", - "baseline" - ], + "order": ["candidate", "baseline"], "candidate": 35.77864199999931, "baseline": 37.269722 }, { - "order": [ - "baseline", - "candidate" - ], + "order": ["baseline", "candidate"], "baseline": 43.32392400000026, "candidate": 28.913426000000072 } diff --git a/docs/performance/transfer-search-20261004/README.md b/docs/performance/transfer-search-20261004/README.md index 9fda02b..c2486f8 100644 --- a/docs/performance/transfer-search-20261004/README.md +++ b/docs/performance/transfer-search-20261004/README.md @@ -20,12 +20,12 @@ measurements, alternating execution order after one warmup per version. Fixtures, garbage collection and output assertions were outside the timed call. The timed function is the actual imported `applyTransferSearch`. -| History | Result cap | Before, ms | After, ms | Speedup | -| --- | ---: | ---: | ---: | ---: | -| 5,000 shuffled transfers | 100 | 22.827 | 2.484 | 9.19x | -| 100,000 shuffled transfers | 100 | 776.075 | 26.912 | 28.84x | -| 100,000 oldest-first transfers | 500 | 44.631 | 31.982 | 1.40x | -| 100,000 newest-first transfers | 500 | 46.296 | 25.439 | 1.82x | +| History | Result cap | Before, ms | After, ms | Speedup | +| ------------------------------ | ---------: | ---------: | --------: | ------: | +| 5,000 shuffled transfers | 100 | 22.827 | 2.484 | 9.19x | +| 100,000 shuffled transfers | 100 | 776.075 | 26.912 | 28.84x | +| 100,000 oldest-first transfers | 500 | 44.631 | 31.982 | 1.40x | +| 100,000 newest-first transfers | 500 | 46.296 | 25.439 | 1.82x | Every timed result matched the baseline's complete return value and original row references. The 100,000-row shuffled case reduced `Date.parse` calls from @@ -77,10 +77,10 @@ path selects a different candidate module. It asserts complete baseline equivalence for every sample and reports both input source hashes. The baseline staging file is temporary and is not part of the contribution. -| Executed source | Git blob | -| --- | --- | -| Baseline selector | `be8db651d5c2457323d6f8373a170a41d8b99b87` | -| Updated selector | `a75e2e4fe88e16cb0b03ed97aa0a1c18dbba1274` | +| Executed source | Git blob | +| -------------------- | ------------------------------------------ | +| Baseline selector | `be8db651d5c2457323d6f8373a170a41d8b99b87` | +| Updated selector | `a75e2e4fe88e16cb0b03ed97aa0a1c18dbba1274` | | Maintained unit file | `22be7a01462fd6a612d40cdc9e34f0c9f1f443ea` | The original contribution and claim remain unchanged. This is performance diff --git a/docs/performance/transfer-search-20261004/results.json b/docs/performance/transfer-search-20261004/results.json index a8dad99..7f62886 100644 --- a/docs/performance/transfer-search-20261004/results.json +++ b/docs/performance/transfer-search-20261004/results.json @@ -49,15 +49,11 @@ "speedup": 9.190602056982748, "baselineOperations": { "dateParseCalls": 110108, - "sortedLengths": [ - 5000 - ] + "sortedLengths": [5000] }, "candidateOperations": { "dateParseCalls": 5000, - "sortedLengths": [ - 100 - ] + "sortedLengths": [100] }, "identicalResults": true }, @@ -92,15 +88,11 @@ "speedup": 28.837778470577426, "baselineOperations": { "dateParseCalls": 3031310, - "sortedLengths": [ - 100000 - ] + "sortedLengths": [100000] }, "candidateOperations": { "dateParseCalls": 100000, - "sortedLengths": [ - 100 - ] + "sortedLengths": [100] }, "identicalResults": true }, @@ -135,15 +127,11 @@ "speedup": 1.3955117549015998, "baselineOperations": { "dateParseCalls": 199998, - "sortedLengths": [ - 100000 - ] + "sortedLengths": [100000] }, "candidateOperations": { "dateParseCalls": 100000, - "sortedLengths": [ - 500 - ] + "sortedLengths": [500] }, "identicalResults": true }, @@ -178,15 +166,11 @@ "speedup": 1.8198750483163826, "baselineOperations": { "dateParseCalls": 199998, - "sortedLengths": [ - 100000 - ] + "sortedLengths": [100000] }, "candidateOperations": { "dateParseCalls": 100000, - "sortedLengths": [ - 500 - ] + "sortedLengths": [500] }, "identicalResults": true } diff --git a/docs/validation/actor-scoped-transfer-validation.md b/docs/validation/actor-scoped-transfer-validation.md index 8e41c7a..947f009 100644 --- a/docs/validation/actor-scoped-transfer-validation.md +++ b/docs/validation/actor-scoped-transfer-validation.md @@ -10,11 +10,11 @@ Previously, `listTransfers` validated that entire collection before selecting the requesting actor's rows. As a result, the validity of a different wallet's records could change this wallet's error behavior. -| Stored rows | Before | Repaired behavior | -| --- | --- | --- | -| Only a corrupt foreign-wallet row | Schema error for an empty current-wallet history | Empty list | +| Stored rows | Before | Repaired behavior | +| ------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------- | +| Only a corrupt foreign-wallet row | Schema error for an empty current-wallet history | Empty list | | Valid current-wallet row and corrupt foreign-wallet row | Correct list, but foreign schema diagnostics were logged | Correct list without foreign diagnostics | -| Corrupt current-wallet row and valid foreign-wallet row | Empty list concealed the current wallet's schema failure | `ContractViolationError` | +| Corrupt current-wallet row and valid foreign-wallet row | Empty list concealed the current wallet's schema failure | `ContractViolationError` | The API now uses the existing `isVisibleToActor` predicate on raw rows before validating individual transfers. It still passes a non-array top-level response @@ -36,9 +36,9 @@ The repaired source commit is a direct child of `21fccd57636a96d6c844289157e9c4693419a478`. The preceding navigation and capped-selector changes are preserved. -| Executed file | Git blob | -| --- | --- | -| `src/services/api.js` | `1ec5dcd1f163d536c2f2cb6059fa6218c92f7d41` | +| Executed file | Git blob | +| ----------------------------------------- | ------------------------------------------ | +| `src/services/api.js` | `1ec5dcd1f163d536c2f2cb6059fa6218c92f7d41` | | `test/unit/list-transfers-search.test.js` | `2d2c4753103589a1c53421a7e8a66734f566e0ef` | Two focused executions completed on 2026-10-04: diff --git a/scripts/bench-transfer-search.mjs b/scripts/bench-transfer-search.mjs index 99da4a4..94dde7f 100644 --- a/scripts/bench-transfer-search.mjs +++ b/scripts/bench-transfer-search.mjs @@ -9,7 +9,9 @@ const args = process.argv.slice(2); const baselinePath = args[0]; const candidatePath = args[1] ?? 'src/utils/transferSearch.js'; if (!baselinePath) { - throw new Error('Usage: node --expose-gc bench-transfer-search.mjs BASELINE [CANDIDATE]'); + throw new Error( + 'Usage: node --expose-gc bench-transfer-search.mjs BASELINE [CANDIDATE]', + ); } const before = await import(pathToFileURL(resolve(baselinePath)).href); const after = await import(pathToFileURL(resolve(candidatePath)).href); @@ -26,8 +28,12 @@ function blob(path) { function fixture(count, order) { return Array.from({ length: count }, (_, i) => { - const rank = order === 'shuffled' ? (i * 7919) % count : - order === 'ascending' ? i : count - i - 1; + const rank = + order === 'shuffled' + ? (i * 7919) % count + : order === 'ascending' + ? i + : count - i - 1; return { id: `tx_${i}`, actorId: 'GACTOR_A', @@ -72,12 +78,22 @@ function median(values) { } const report = { - runtime: { node: process.version, platform: process.platform, arch: process.arch }, - sources: { baselineBlob: blob(baselinePath), candidateBlob: blob(candidatePath) }, + runtime: { + node: process.version, + platform: process.platform, + arch: process.arch, + }, + sources: { + baselineBlob: blob(baselinePath), + candidateBlob: blob(candidatePath), + }, method: { - rounds, warmupsPerVersion: 1, order: 'alternating before/after', + rounds, + warmupsPerVersion: 1, + order: 'alternating before/after', gcBeforeEachSample: Boolean(global.gc), - timingIncludes: 'actual applyTransferSearch only; fixtures/assertions outside timing', + timingIncludes: + 'actual applyTransferSearch only; fixtures/assertions outside timing', operationCounts: 'separate untimed production calls', providerRequests: 0, }, @@ -98,11 +114,16 @@ for (const [count, order, limit] of [ const calls = round % 2 === 0 ? ['before', 'after'] : ['after', 'before']; const pair = {}; for (const name of calls) { - const fn = name === 'before' ? before.applyTransferSearch : after.applyTransferSearch; + const fn = + name === 'before' + ? before.applyTransferSearch + : after.applyTransferSearch; const sample = measure(fn, rows, query); assert.deepEqual(sample.result, expected); assert.equal(sample.result.totalMatched, count); - sample.result.items.forEach((row, i) => assert.equal(row, expected.items[i])); + sample.result.items.forEach((row, i) => + assert.equal(row, expected.items[i]), + ); pair[name] = sample.ms; } samples.push(pair); @@ -110,10 +131,23 @@ for (const [count, order, limit] of [ const baselineMedianMs = median(samples.map((pair) => pair.before)); const candidateMedianMs = median(samples.map((pair) => pair.after)); report.scenarios.push({ - count, order, limit, samples, baselineMedianMs, candidateMedianMs, + count, + order, + limit, + samples, + baselineMedianMs, + candidateMedianMs, speedup: baselineMedianMs / candidateMedianMs, - baselineOperations: countOperations(before.applyTransferSearch, rows, query), - candidateOperations: countOperations(after.applyTransferSearch, rows, query), + baselineOperations: countOperations( + before.applyTransferSearch, + rows, + query, + ), + candidateOperations: countOperations( + after.applyTransferSearch, + rows, + query, + ), identicalResults: true, }); } diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 336af1b..452bf9f 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,4 +1,3 @@ - import { createContext, useContext, useEffect, useState } from 'react'; import { connectWallet, diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index 57b3e26..3fdb4e4 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -9,7 +9,11 @@ import Modal from '../components/Modal.jsx'; import { buildQuote } from '../services/quote.js'; import { ContractViolationError } from '../services/contracts/schema.js'; import { getUserErrorMessage, normalizeError } from '../services/errors.js'; -import { formatAmount, formatCurrencyInput, parseCurrencyInput } from '../utils/format.js'; +import { + formatAmount, + formatCurrencyInput, + parseCurrencyInput, +} from '../utils/format.js'; import { isPositiveAmount, validateRecipient, @@ -167,7 +171,9 @@ export default function SendMoney() { } const finalQuote = buildQuote(parsedAmount.value, from, to); if (!finalQuote) { - applyErrors({ amount: 'Enter an amount greater than zero.' }); + setSubmitError( + 'We could not price this transfer. Check the amount and currencies.', + ); return; } @@ -194,12 +200,16 @@ export default function SendMoney() { // Rebuild at confirmation time so the committed amounts match the note: // rates are indicative and update at confirmation. - const parsedAmount = parseCurrencyInput(amount, { currency: from, locale }); + const parsedAmount = parseCurrencyInput(amount, { + currency: from, + locale, + }); if (!parsedAmount.ok) { setSubmitError(parsedAmount.error); return; } - const finalQuote = pendingQuote ?? buildQuote(parsedAmount.value, from, to); + const finalQuote = + pendingQuote ?? buildQuote(parsedAmount.value, from, to); if (!finalQuote) { setSubmitError( 'We could not price this transfer. Check the amount and the selected currencies.', @@ -237,18 +247,19 @@ export default function SendMoney() { ); } else { const normalized = normalizeError(err, { source: 'api' }); - // A transfer can be interrupted mid-signature by a connection drop. - // The honest message here is "unknown", not "failed": the backend may - // have accepted the transfer even though the response never arrived. - // The transfers page reconciles real status on reconnect. - // Read the current connectivity directly (not from the render closure) - // so that a mid-flight disconnect produces the correct message. - const connectedNow = typeof navigator !== 'undefined' && navigator.onLine; - setSubmitError( - connectedNow - ? getUserErrorMessage(normalized) - : 'Connection lost while sending. Reconnect to check your transfer status.', - ); + // A transfer can be interrupted mid-signature by a connection drop. + // The honest message here is "unknown", not "failed": the backend may + // have accepted the transfer even though the response never arrived. + // The transfers page reconciles real status on reconnect. + // Read the current connectivity directly (not from the render closure) + // so that a mid-flight disconnect produces the correct message. + const connectedNow = + typeof navigator !== 'undefined' && navigator.onLine; + setSubmitError( + connectedNow + ? getUserErrorMessage(normalized) + : 'Connection lost while sending. Reconnect to check your transfer status.', + ); } } finally { submissionLock.current = false; @@ -294,8 +305,8 @@ export default function SendMoney() { role="status" aria-live="polite" > - ✓ Back online. Your form was not submitted while you were - offline — review it and send when ready. + ✓ Back online. Your form was not submitted while you were offline + — review it and send when ready. )} diff --git a/src/services/errors.js b/src/services/errors.js index bab520f..4ecc020 100644 --- a/src/services/errors.js +++ b/src/services/errors.js @@ -86,7 +86,9 @@ export function normalizeError(error, { source = 'api' } = {}) { } export function getUserErrorMessage(normalizedError) { - return SAFE_MESSAGES[normalizedError?.code] ?? SAFE_MESSAGES[ERROR_CODES.UNKNOWN]; + return ( + SAFE_MESSAGES[normalizedError?.code] ?? SAFE_MESSAGES[ERROR_CODES.UNKNOWN] + ); } export function canRetry(normalizedError) { diff --git a/test/components/WalletButton.test.jsx b/test/components/WalletButton.test.jsx index 46287e2..701fc6f 100644 --- a/test/components/WalletButton.test.jsx +++ b/test/components/WalletButton.test.jsx @@ -1,4 +1,3 @@ - import { describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -118,7 +117,9 @@ describe('WalletButton', () => { ); await waitFor(() => { - expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/something went wrong/i), + ).not.toBeInTheDocument(); expect(screen.getByText(/500 XLM/)).toBeInTheDocument(); }); }); diff --git a/test/integration/offline-reconnect.test.jsx b/test/integration/offline-reconnect.test.jsx index 10a8761..a6de581 100644 --- a/test/integration/offline-reconnect.test.jsx +++ b/test/integration/offline-reconnect.test.jsx @@ -1,4 +1,11 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '../../src/App.jsx'; @@ -43,6 +50,8 @@ describe('Offline and reconnect state for transfer mutations', () => { }); window.history.pushState({}, '', '/send'); localStorage.clear(); + // These cases exercise connectivity, not the demo wallet's random refusal. + vi.spyOn(Math, 'random').mockReturnValue(0.5); }); afterEach(() => { @@ -80,9 +89,7 @@ describe('Offline and reconnect state for transfer mutations', () => { goOffline(); expect( - await screen.findByText( - /you're offline\. some features may not work/i, - ), + await screen.findByText(/you're offline\. some features may not work/i), ).toBeInTheDocument(); }); @@ -119,14 +126,38 @@ describe('Offline and reconnect state for transfer mutations', () => { goOnline(); // After reconnecting, the button should change back to "Review & Send" and be enabled. - const submitButton = await screen.findByRole('button', { - name: /review & send/i, - }, { timeout: 5000 }); + const submitButton = await screen.findByRole( + 'button', + { + name: /review & send/i, + }, + { timeout: 5000 }, + ); expect(submitButton).toBeEnabled(); + expect(createTransfer).not.toHaveBeenCalled(); await user.click(submitButton); - - await screen.findByRole('heading', { name: /your transfers/i }, { timeout: 10000 }); + const confirmation = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(createTransfer).not.toHaveBeenCalled(); + await user.click( + within(confirmation).getByRole('button', { name: /confirm transfer/i }), + ); + const result = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await user.click( + within(result).getByRole('button', { name: /view transfers/i }), + ); + + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 10000 }, + ); expect(createTransfer).toHaveBeenCalledTimes(1); }); @@ -170,9 +201,15 @@ describe('Offline and reconnect state for transfer mutations', () => { await fillValidForm(user); - // Start the submission. - const submitButton = screen.getByRole('button', { name: /review & send/i }); - fireEvent.click(submitButton); + // Review first, then dispatch through the actual confirmation dialog. + await user.click(screen.getByRole('button', { name: /review & send/i })); + const confirmation = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(createTransfer).not.toHaveBeenCalled(); + await user.click( + within(confirmation).getByRole('button', { name: /confirm transfer/i }), + ); // After the catch block runs, the error message should be about // connection loss, NOT a generic "could not submit" message. @@ -185,5 +222,6 @@ describe('Offline and reconnect state for transfer mutations', () => { expect( screen.queryByText(/could not submit the transfer\./i), ).not.toBeInTheDocument(); + expect(createTransfer).toHaveBeenCalledTimes(1); }); -}); \ No newline at end of file +}); diff --git a/test/integration/send-money-form.test.jsx b/test/integration/send-money-form.test.jsx index 43a7381..9ae992d 100644 --- a/test/integration/send-money-form.test.jsx +++ b/test/integration/send-money-form.test.jsx @@ -1,4 +1,3 @@ - import { act, fireEvent, @@ -61,6 +60,8 @@ describe('Send money form flows', () => { beforeEach(() => { window.history.pushState({}, '', '/send'); localStorage.clear(); + // Keep the mock wallet's random rejection out of submission-guard tests. + vi.spyOn(Math, 'random').mockReturnValue(0.5); }); afterEach(() => { @@ -113,7 +114,9 @@ describe('Send money form flows', () => { within(dialog).getByRole('button', { name: /confirm transfer/i }), ); - expect(screen.getByRole('button', { name: /sending/i })).toBeDisabled(); + expect( + within(dialog).getByRole('button', { name: /sending/i }), + ).toBeDisabled(); expect(createTransfer).not.toHaveBeenCalled(); const resultDialog = await screen.findByRole( @@ -200,7 +203,9 @@ describe('Send money form flows', () => { within(dialog).getByRole('button', { name: /confirm transfer/i }), ); - expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument(); + expect( + await screen.findByText(/something went wrong/i), + ).toBeInTheDocument(); const retryButton = screen.getByRole('button', { name: /review & send/i }); expect(retryButton).toBeEnabled(); diff --git a/test/integration/send-money-precision.test.jsx b/test/integration/send-money-precision.test.jsx index f5cf076..136b66c 100644 --- a/test/integration/send-money-precision.test.jsx +++ b/test/integration/send-money-precision.test.jsx @@ -22,6 +22,32 @@ async function fillForm(user, { amount, to = 'NGN' } = {}) { return amountField; } +async function reviewAndConfirm(user) { + await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); +} + +async function viewSubmittedTransfer(user) { + const dialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await user.click( + within(dialog).getByRole('button', { name: /view transfers/i }), + ); + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 5000 }, + ); +} + describe('Send flow — amount parsing regressions', () => { beforeEach(() => { window.history.pushState({}, '', '/send'); @@ -44,17 +70,22 @@ describe('Send flow — amount parsing regressions', () => { }); it('keeps a negative amount negative so validation can reject it', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); const user = userEvent.setup(); render(); // "-5" used to be silently rewritten to "5.00" and sent as a real transfer. await fillForm(user, { amount: '-5' }); - expect(screen.getByLabelText(/^amount$/i)).toHaveValue(-5); + expect(screen.getByLabelText(/^amount$/i)).toHaveValue('-5.00'); await user.click(screen.getByRole('button', { name: /review & send/i })); expect( - await screen.findByText(/enter an amount greater than zero/i), + await screen.findByText(/amount cannot be negative/i), ).toBeInTheDocument(); + expect(createTransfer).not.toHaveBeenCalled(); + expect( + screen.queryByRole('dialog', { name: /confirm your transfer/i }), + ).not.toBeInTheDocument(); }); it('submits the exponent amount that the field actually held', async () => { @@ -63,13 +94,8 @@ describe('Send flow — amount parsing regressions', () => { render(); await fillForm(user, { amount: '1e3' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); expect(createTransfer).toHaveBeenCalledTimes(1); expect(createTransfer.mock.calls[0][0]).toMatchObject({ sendAmount: '1000', @@ -96,13 +122,8 @@ describe('Send flow — the receipt matches the quote', () => { render(); await fillForm(user, { amount: '200' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); // 200.00 - 1.10 fee = 198.90, at 1480.5 NGN/USD = 294471.45 exactly. const payload = createTransfer.mock.calls[0][0]; @@ -141,12 +162,8 @@ describe('Send flow — the receipt matches the quote', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); // The number on the receipt is the number that was quoted, to the cent. // The seeded demo transfer shares this recipient, so find the row by the @@ -205,7 +222,7 @@ describe('Send flow — contract failures do not submit', () => { render(); await fillForm(user, { amount: '200' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); + await reviewAndConfirm(user); expect( await screen.findByText(/rejected before it was sent/i, undefined, { diff --git a/test/unit/AppContext.wallet.test.jsx b/test/unit/AppContext.wallet.test.jsx index 20c04bf..4436e1d 100644 --- a/test/unit/AppContext.wallet.test.jsx +++ b/test/unit/AppContext.wallet.test.jsx @@ -1,4 +1,3 @@ - import { describe, expect, it, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { AppProvider, useApp } from '../../src/context/AppContext.jsx'; diff --git a/test/unit/list-transfers-search.test.js b/test/unit/list-transfers-search.test.js index 2d2c475..38d30c0 100644 --- a/test/unit/list-transfers-search.test.js +++ b/test/unit/list-transfers-search.test.js @@ -85,7 +85,11 @@ describe('listTransfers search / cancellation / scope', () => { const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); seed([ ...ownRows, - transfer({ id: 'tx_foreign', actorId: 'GOTHERWALLET', recipient: null }), + transfer({ + id: 'tx_foreign', + actorId: 'GOTHERWALLET', + recipient: null, + }), ]); const pending = api.listTransfers({ actorId: DEMO_PUBLIC_KEY }); diff --git a/test/unit/quote.test.js b/test/unit/quote.test.js index 0fcfe52..9461138 100644 --- a/test/unit/quote.test.js +++ b/test/unit/quote.test.js @@ -4,18 +4,26 @@ import { buildQuote } from '../../src/services/quote.js'; describe('buildQuote', () => { it('keeps submitted, fee, and receipt amounts as canonical decimal strings', () => { const quote = buildQuote('100.10', 'USD', 'NGN'); + // The quote contract removes redundant zeros while preserving exact values. expect(quote).toMatchObject({ - sendAmount: '100.10', - fee: '0.60', - amountAfterFee: '99.50', + sendAmount: '100.1', + fee: '0.6', + amountAfterFee: '99.5', receiveAmount: '147309.75', }); }); it('regresses binary floating-point drift at the receipt boundary', () => { const quote = buildQuote('0.30', 'USD', 'MXN'); - expect(quote.sendAmount).toBe('0.30'); - expect(quote.amountAfterFee).toBe('0.00'); - expect(quote.receiveAmount).toBe('0.00'); + expect(quote.sendAmount).toBe('0.3'); + // The minimum fee is 0.25: 0.05 * 17.1 = 0.855, rounded to 0.86 MXN. + expect(quote.fee).toBe('0.25'); + expect(quote.amountAfterFee).toBe('0.05'); + expect(quote.receiveAmount).toBe('0.86'); + + expect(buildQuote('0.20', 'USD', 'MXN')).toMatchObject({ + amountAfterFee: '0', + receiveAmount: '0', + }); }); }); diff --git a/test/unit/transfer-search.test.js b/test/unit/transfer-search.test.js index 22be7a0..135cf24 100644 --- a/test/unit/transfer-search.test.js +++ b/test/unit/transfer-search.test.js @@ -133,7 +133,13 @@ describe('transferSearch scope and filters', () => { const now = new Date('2026-06-08T12:00:00Z'); const queries = [ ...[1, 7, 100, 500, 9999].map((limit) => ({ actorId: ACTOR, limit })), - { actorId: ACTOR, search: ' AMINA ', status: 'completed', range: '7d', limit: 7 }, + { + actorId: ACTOR, + search: ' AMINA ', + status: 'completed', + range: '7d', + limit: 7, + }, { actorId: OTHER, status: 'pending', limit: 1 }, { actorId: ACTOR, search: 'absent', limit: 100 }, ]; @@ -159,15 +165,34 @@ describe('transferSearch scope and filters', () => { it('preserves zero-time ordering, Unicode ties and sparse legacy histories', () => { const rows = new Array(12); rows[1] = tx({ id: 'tx_\u00e9', actorId: undefined, createdAt: 'invalid' }); - rows[3] = tx({ id: 'tx_e\u0301', actorId: undefined, createdAt: undefined }); - rows[4] = tx({ id: 'tx_z', actorId: undefined, createdAt: '1970-01-01T00:00:00Z' }); - rows[5] = tx({ id: 'tx_z', actorId: undefined, createdAt: 'invalid', sendAmount: '200' }); - rows[7] = tx({ id: 'tx_z', actorId: undefined, createdAt: '1969-12-31T00:00:00Z' }); + rows[3] = tx({ + id: 'tx_e\u0301', + actorId: undefined, + createdAt: undefined, + }); + rows[4] = tx({ + id: 'tx_z', + actorId: undefined, + createdAt: '1970-01-01T00:00:00Z', + }); + rows[5] = tx({ + id: 'tx_z', + actorId: undefined, + createdAt: 'invalid', + sendAmount: '200', + }); + rows[7] = tx({ + id: 'tx_z', + actorId: undefined, + createdAt: '1969-12-31T00:00:00Z', + }); rows[9] = tx({ id: 'tx_new', actorId: OTHER }); for (const limit of [1, 3, 4, 100]) { const query = { actorId: LEGACY, limit }; const options = { legacyActorId: LEGACY }; - const matched = rows.filter((row) => isVisibleToActor(row, LEGACY, options)); + const matched = rows.filter((row) => + isVisibleToActor(row, LEGACY, options), + ); const expected = stableSortTransfers(matched).slice(0, limit); const result = applyTransferSearch(rows, query, options); expect(result.items).toEqual(expected); diff --git a/test/unit/useTransfers.errors.test.jsx b/test/unit/useTransfers.errors.test.jsx index 071426f..11741da 100644 --- a/test/unit/useTransfers.errors.test.jsx +++ b/test/unit/useTransfers.errors.test.jsx @@ -14,7 +14,9 @@ describe('useTransfers error retry policy', () => { const { result } = renderHook(() => useTransfers()); await waitFor(() => { - expect(result.current.error).toBe('Something went wrong. Please try again.'); + expect(result.current.error).toBe( + 'Something went wrong. Please try again.', + ); }); expect(result.current.retryable).toBe(false); diff --git a/vite-plugin-security-headers.js b/vite-plugin-security-headers.js index af430cf..d3fb1d7 100644 --- a/vite-plugin-security-headers.js +++ b/vite-plugin-security-headers.js @@ -66,8 +66,8 @@ export function buildCsp(apiOrig = apiOrigin()) { const directives = [ "default-src 'self'", "script-src 'self'", - "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag - "img-src 'self' data:", // data: URIs used by chart canvas toDataURL + "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag + "img-src 'self' data:", // data: URIs used by chart canvas toDataURL `connect-src ${connectSrc.join(' ')}`, "font-src 'self'", "object-src 'none'", @@ -90,8 +90,7 @@ export const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'Permissions-Policy': - 'camera=(), microphone=(), geolocation=(), payment=()', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Resource-Policy': 'same-origin', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', From 5ef024a5aab395c26b17b2c7c32a68097df97268 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 08:14:59 -0400 Subject: [PATCH 13/14] docs(search): publish complete composed-source CI evidence [skip ci] Record the final504/53 passing package selection, formatting, production build, and dated critical-threshold audit at source1e702e64. Preserve raw execution output and exact source manifests, with the earlier502/52 composition separately identified after the branch advanced during publication. This adds only the report, machine-readable receipt and raw evidence archive. All355 tested source blobs/modes and both historical performance reports remain intact. Hosted CI and Lighthouse action_required are recorded separately from passing local gates;34 noncritical audit findings remain. --- .../README.md | 58 ++++ .../evidence.zip | Bin 0 -> 147628 bytes .../receipt.json | 303 ++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 docs/validation/rf298-existing-ci-relay17-20261004/README.md create mode 100644 docs/validation/rf298-existing-ci-relay17-20261004/evidence.zip create mode 100644 docs/validation/rf298-existing-ci-relay17-20261004/receipt.json diff --git a/docs/validation/rf298-existing-ci-relay17-20261004/README.md b/docs/validation/rf298-existing-ci-relay17-20261004/README.md new file mode 100644 index 0000000..2746879 --- /dev/null +++ b/docs/validation/rf298-existing-ci-relay17-20261004/README.md @@ -0,0 +1,58 @@ +# PR298: required local checks pass on the composed source + +The complete package selection passes on [1e702e64](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/1e702e6437deee1bc1b0d75f5a3189abd178a226): **504 passed, zero failed or skipped, across all 53 files**. The final tree is `50b2704afb930440c23d30f6ab1dd7bbbf7fbf85`. + +[Issue281](https://github.com/RemitFlow/RemitFlow-Frontend/issues/281) requires existing tests and CI to remain passing without weakening unrelated tests. This continuation keeps the original [PR298](https://github.com/RemitFlow/RemitFlow-Frontend/pull/298), the earlier actor and navigation corrections, the bounded selector, and the later query-invariant filter preparation. + +## Final executed gates + +| Gate | Result | +| ------------------------ | -------------------------------------------------------------------------------------------- | +| Existing Prettier check | Passed; existing configuration and ignores unchanged | +| Complete package tests | 504 passed / 0 failed / 0 skipped; 53/53 files passed | +| Test duration | Vitest 69.54 s; complete process 70.096 s | +| Production build | Passed; Vite 1.75 s; complete command 2.078 s | +| Critical audit threshold | Exit 0; zero critical findings | +| Remaining audit findings | 20 high, 12 moderate, 2 low | +| Source identity | All 355 tracked blobs matched before execution and remained unchanged after testing/building | + +The final suite ran once with the complete package file selection, no retries, exclusions, altered timeouts or snapshot changes. It includes the original race, cancellation, actor-scope, pagination and scale cases and the new filter-preparation cases. Node 24.19.0 and Vitest 4.1.10 used the retained installation after package, lockfile, Vite configuration and test setup matched exactly. + +The audit executed once at 11:56:36–11:56:56 UTC on 2026-10-04. The final composition has identical package/lock bytes, so that dated result was reused. It passes the workflow's critical threshold while retaining the 34 findings above. + +## Changes and composition + +The four maintained send/quote fixtures now traverse the actual review, confirmation, result and receipt flow and assert the existing canonical-decimal contract. Their 22 original case titles remain. Exact amounts, fees, receipt reconciliation, negative/unquotable input, offline blocking, retry and transfer-creation assertions are retained. + +The only functional send-page change gives an otherwise valid amount with no available quote the pricing explanation expected by the maintained test. Required Prettier output covers the remaining flagged files. From the final parent 71746fa5, 19 paths change and 336 tracked paths remain unchanged. + +A first composition passed 502 tests across 52 files. Before its publication, KESTREL-104's selector preparation contribution advanced the original branch. A non-force update rejected the outdated parent with 422. Its five files were then incorporated, the two new report files were formatted, and the final 504-case composition above was executed. Both raw runs remain separately identified in the archive; the earlier result is not represented as the final source. + +The peer selector `f072d7879d4bceb74d3635f6bfda0797e7f549a0`, benchmark script `1bcf4b3f4d44596fe3381d4631cde0481e85aca9`, and added test `cea83462b01de6fa1631a6bfeaa25fbdb522f980` are preserved byte for byte. Formatting of both historical benchmark JSON documents preserves every measurement and source pin. + +## Performance evidence retained + +The [earlier bounded-selector report](../../performance/transfer-search-20261004/README.md) and [query-invariant filter-preparation report](../../performance/transfer-filter-preparation-20261004/README.md) retain their own executed sources, environments, paired samples and scope. This continuation did not rerun either benchmark or combine their speedups. + +The final package's scale and performance tests pass. The validation-command durations in this report are not application or network throughput measurements. + +## Reproduce and inspect + +From an installed checkout of the source commit: + +```bash +npm test -- --maxWorkers=2 +npm run format:check +npm audit --audit-level=critical +npm run build +``` + +The local test command used the default and JSON reporters and imported the actual repository Vite configuration with only `cacheDir` redirected to a task directory. `TMPDIR` and npm's cache/log locations were similarly redirected because the shared root filesystem was full. The exact wrappers/configuration are in the archive; dependencies were not reinstalled or changed. + +[receipt.json](receipt.json) records commands, source identities, timestamps, hosted status and archive/member hashes. [evidence.zip](evidence.zip) preserves raw Vitest output and JSON, formatter output, audit JSON, build output, source manifests, exact diffs and the separately identified intermediate execution. Raw execution files in the archive are unchanged. + +## Hosted and execution boundary + +At the published source, [CI 37201143157](https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37201143157) and [Lighthouse CI 37201143084](https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37201143084) report `action_required`. Local gate results do not establish hosted approval or maintainer acceptance. + +The app tests use the repository's demonstration wallet and transfer service in jsdom. The archive retains React/Vite warnings. No live-wallet, provider, settlement or whole-page performance result is established here. The original contribution and metadata handoff remain in place. diff --git a/docs/validation/rf298-existing-ci-relay17-20261004/evidence.zip b/docs/validation/rf298-existing-ci-relay17-20261004/evidence.zip new file mode 100644 index 0000000000000000000000000000000000000000..6069ed222c2de6965a37df789cf04e0d414a619a GIT binary patch literal 147628 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

0)n}^`&EtA*Z|fm?uF#_^i?mNX^vb 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�NsataYII9C?gT>uYGe?2S=&A%3_3VbWkmJ}pZN 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 Date: Sun, 4 Oct 2026 08:26:20 -0400 Subject: [PATCH 14/14] docs: keep composition report attribution neutral [skip ci] --- docs/validation/rf298-existing-ci-relay17-20261004/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/validation/rf298-existing-ci-relay17-20261004/README.md b/docs/validation/rf298-existing-ci-relay17-20261004/README.md index 2746879..53c03b8 100644 --- a/docs/validation/rf298-existing-ci-relay17-20261004/README.md +++ b/docs/validation/rf298-existing-ci-relay17-20261004/README.md @@ -26,7 +26,7 @@ The four maintained send/quote fixtures now traverse the actual review, confirma The only functional send-page change gives an otherwise valid amount with no available quote the pricing explanation expected by the maintained test. Required Prettier output covers the remaining flagged files. From the final parent 71746fa5, 19 paths change and 336 tracked paths remain unchanged. -A first composition passed 502 tests across 52 files. Before its publication, KESTREL-104's selector preparation contribution advanced the original branch. A non-force update rejected the outdated parent with 422. Its five files were then incorporated, the two new report files were formatted, and the final 504-case composition above was executed. Both raw runs remain separately identified in the archive; the earlier result is not represented as the final source. +A first composition passed 502 tests across 52 files. Before its publication, a concurrent selector preparation contribution advanced the original branch. A non-force update rejected the outdated parent with 422. Its five files were then incorporated, the two new report files were formatted, and the final 504-case composition above was executed. Both raw runs remain separately identified in the archive; the earlier result is not represented as the final source. The peer selector `f072d7879d4bceb74d3635f6bfda0797e7f549a0`, benchmark script `1bcf4b3f4d44596fe3381d4631cde0481e85aca9`, and added test `cea83462b01de6fa1631a6bfeaa25fbdb522f980` are preserved byte for byte. Formatting of both historical benchmark JSON documents preserves every measurement and source pin.