diff --git a/src/app/quote/Client.tsx b/src/app/quote/Client.tsx index 502e23d..c5e739d 100644 --- a/src/app/quote/Client.tsx +++ b/src/app/quote/Client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { TextField } from '@/components/TextField'; import { SlippageView } from './Slippage'; import { apiFetch, type ApiError } from '@/lib/apiClient'; @@ -14,6 +14,14 @@ import { type HistoryEntry, type QuoteInputs, } from './QuoteHistory'; +import { + canonicalEntryFromQuote, + mergePendingEntry, + pushHistoryPure, + readHistory, + writeHistory, + type PendingHistoryEntry, +} from './historyModel'; type FieldErrors = { source?: string; @@ -22,10 +30,10 @@ type FieldErrors = { }; const INPUTS_KEY = 'stableroute.quote.inputs'; -const HISTORY_KEY = 'stableroute.quote.history'; -const MAX_HISTORY = 5; const ASSET_CODE_PATTERN = /^[A-Za-z0-9]{1,12}$/; const MIN_SUBMIT_INTERVAL_MS = 1_000; +const ROLLBACK_MESSAGE = + 'The recent quotes update failed and was rolled back.'; function normalizeAssetCode(value: string): string | null { const trimmed = value.trim(); @@ -46,29 +54,9 @@ function isQuoteInputs(value: unknown): value is QuoteInputs { ); } -function readHistory(): HistoryEntry[] { - try { - const raw = localStorage.getItem(HISTORY_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw) as HistoryEntry[]; - return Array.isArray(parsed) ? parsed.slice(0, MAX_HISTORY) : []; - } catch { - return []; - } -} - -function pushHistory(entry: QuoteInputs) { - const next: HistoryEntry[] = [ - { ...entry, savedAt: Date.now() }, - ...readHistory().filter( - (item) => - item.source !== entry.source || - item.dest !== entry.dest || - item.amount !== entry.amount - ), - ].slice(0, MAX_HISTORY); - localStorage.setItem(HISTORY_KEY, JSON.stringify(next)); - return next; +function pushHistory(entry: QuoteInputs): HistoryEntry[] { + // Confirmed writes only — optimistic entries never reach localStorage. + return writeHistory(pushHistoryPure(readHistory(), entry)); } export default function QuoteClient() { @@ -81,6 +69,9 @@ export default function QuoteClient() { const [destAsset, setDestAsset] = useState(''); const [amount, setAmount] = useState(''); const [history, setHistory] = useState([]); + const [pendingEntry, setPendingEntry] = useState( + null + ); const [quote, setQuote] = useState(null); const [fieldErrors, setFieldErrors] = useState({}); const [formError, setFormError] = useState(null); @@ -183,6 +174,17 @@ export default function QuoteClient() { const currentRequestId = activeRequestRef.current + 1; activeRequestRef.current = currentRequestId; + // Optimistic mutation (#723): reflect the requested quote in Recent + // quotes before the server responds. Rendered via mergePendingEntry and + // never persisted; reconciled or rolled back when the request settles. + // A newer submission overwrites this single slot, implicitly discarding + // the stale one. + setPendingEntry({ + ...inputs, + savedAt: now, + key: `pending-${currentRequestId}`, + }); + setLoading(true); announce('Requesting quote…'); try { @@ -197,7 +199,11 @@ export default function QuoteClient() { ); if (currentRequestId !== activeRequestRef.current) return; setQuote(body); - setHistory(pushHistory(inputs)); + // Reconcile (#723): the confirmed entry built from the server's + // response fields replaces the optimistic row; only now is anything + // written to localStorage. + setHistory(pushHistory(canonicalEntryFromQuote(body))); + setPendingEntry(null); announce('Quote received.'); const rateDisplay = formatQuoteRateDisplay(body.estimated_rate).display; const now = Date.now(); @@ -210,10 +216,15 @@ export default function QuoteClient() { } catch (err) { if (currentRequestId !== activeRequestRef.current) return; if (controller.signal.aborted) return; + // Roll back (#723): drop the optimistic row so the rendered history is + // exactly what it was before the submission. Only this slot is + // cleared — unrelated state (form fields, confirmed rows, storage) + // was never touched by the mutation. + setPendingEntry(null); const apiError = err as ApiError & { requestId?: string }; setFormError(apiError.message ?? 'quote request failed'); setRequestId(apiError.requestId ?? null); - announce(''); + announce(ROLLBACK_MESSAGE); const failTime = Date.now(); if (failTime - lastAnnounceAtRef.current >= 300) { lastAnnounceAtRef.current = failTime; @@ -246,6 +257,13 @@ export default function QuoteClient() { onSubmit(new Event('submit') as any); }, [onSubmit]); + // Stable identity across unrelated re-renders keeps QuoteHistory's memo + // effective; the pending entry (if any) leads the rendered rows. + const historyView = useMemo( + () => mergePendingEntry(history, pendingEntry), + [history, pendingEntry] + ); + return (
- +
void; + /** True while the first row is an optimistic (unconfirmed) entry. */ + hasPendingEntry?: boolean; } const COLUMN_LABELS: Record = { @@ -46,6 +37,7 @@ function ariaSortValue(dir: SortDir): 'ascending' | 'descending' | 'none' { export const QuoteHistory = memo(function QuoteHistory({ history, onSelect, + hasPendingEntry = false, }: QuoteHistoryProps) { const { view, update, filterInput, setFilterInput } = useTableViewState(); @@ -63,149 +55,32 @@ export const QuoteHistory = memo(function QuoteHistory({

Recent quotes

- -
- - -
- - {history.length === 0 ? ( - - ) : derived.totalFiltered === 0 ? ( - - ) : ( - <> -
- - - - - {SORT_COLUMNS.map((column) => { - const active = view.sort === column && view.dir !== 'none'; - return ( - - ); - })} - - - - - {derived.rows.map((entry) => { - const label = `${entry.source} → ${entry.dest} · ${entry.amount}`; - return ( - - - - - - - - ); - })} - -
- Recent quotes. Column headers are sortable. -
- - - Actions -
{entry.source}{entry.dest}{entry.amount} - - - -
-
- - - )} + + + ); + })} + ); }); diff --git a/src/app/quote/historyModel.test.ts b/src/app/quote/historyModel.test.ts new file mode 100644 index 0000000..b685ac6 --- /dev/null +++ b/src/app/quote/historyModel.test.ts @@ -0,0 +1,106 @@ +import { + MAX_HISTORY, + canonicalEntryFromQuote, + mergePendingEntry, + pushHistoryPure, + readHistory, + type HistoryEntry, +} from './historyModel'; + +const entry = ( + source: string, + dest: string, + amount: string, + savedAt = 1 +): HistoryEntry => ({ source, dest, amount, savedAt }); + +describe('historyModel', () => { + describe('readHistory', () => { + it('returns an empty list when nothing is stored', () => { + localStorage.clear(); + expect(readHistory()).toEqual([]); + }); + + it('degrades malformed JSON to an empty list', () => { + localStorage.setItem('stableroute.quote.history', '{not-json'); + expect(readHistory()).toEqual([]); + }); + + it('degrades non-array payloads to an empty list', () => { + localStorage.setItem( + 'stableroute.quote.history', + JSON.stringify({ source: 'X' }) + ); + expect(readHistory()).toEqual([]); + }); + + it('caps stored rows at MAX_HISTORY', () => { + const rows = Array.from({ length: 9 }, (_, i) => + entry('A', 'B', String(i), i) + ); + localStorage.setItem('stableroute.quote.history', JSON.stringify(rows)); + expect(readHistory()).toHaveLength(MAX_HISTORY); + }); + }); + + describe('pushHistoryPure', () => { + it('prepends the new entry without mutating the input array', () => { + const existing = [entry('A', 'B', '1')]; + const next = pushHistoryPure(existing, { source: 'C', dest: 'D', amount: '2' }, MAX_HISTORY, 7); + + expect(existing).toEqual([entry('A', 'B', '1')]); + expect(next[0]).toEqual({ source: 'C', dest: 'D', amount: '2', savedAt: 7 }); + expect(next).toHaveLength(2); + }); + + it('dedupes by the (source, dest, amount) triple, moving the match to the front', () => { + const existing = [ + entry('A', 'B', '1', 10), + entry('E', 'F', '3', 20), + ]; + const next = pushHistoryPure(existing, { source: 'A', dest: 'B', amount: '1' }, MAX_HISTORY, 30); + + expect(next[0].savedAt).toBe(30); + expect(next).toHaveLength(2); + expect(next[1].source).toBe('E'); + // Same source/dest but a different amount is NOT a duplicate. + expect(next.filter((e) => e.source === 'A')).toHaveLength(1); + }); + + it('caps the list at max entries and keeps relative order of survivors', () => { + let rows: HistoryEntry[] = [entry('S1', 'D', '1'), entry('S2', 'D', '2')]; + for (let i = 3; i <= 7; i++) { + rows = pushHistoryPure(rows, { source: `S${i}`, dest: 'D', amount: String(i) }, 5, i); + } + expect(rows).toHaveLength(5); + expect(rows.map((r) => r.source)).toEqual(['S7', 'S6', 'S5', 'S4', 'S3']); + }); + }); + + describe('canonicalEntryFromQuote', () => { + it('maps server response fields onto the canonical history triple', () => { + expect( + canonicalEntryFromQuote( + { source_asset: 'usdc', dest_asset: 'xlm', amount: '1000' }, + 42 + ) + ).toEqual({ source: 'usdc', dest: 'xlm', amount: '1000', savedAt: 42 }); + }); + }); + + describe('mergePendingEntry', () => { + it('leads with the optimistic entry when one is pending', () => { + const confirmed = [entry('A', 'B', '1')]; + const pending = { ...entry('C', 'D', '2', 5), key: 'pending-1' }; + + const view = mergePendingEntry(confirmed, pending); + expect(view[0]).toBe(pending); + expect(view.slice(1)).toEqual(confirmed); + }); + + it('returns only confirmed rows when nothing is pending', () => { + const confirmed = [entry('A', 'B', '1')]; + expect(mergePendingEntry(confirmed, null)).toEqual(confirmed); + }); + }); +}); diff --git a/src/app/quote/historyModel.ts b/src/app/quote/historyModel.ts new file mode 100644 index 0000000..6a60d88 --- /dev/null +++ b/src/app/quote/historyModel.ts @@ -0,0 +1,107 @@ +/** + * Pure model for the "Recent quotes" history list (#723). + * + * All functions are side-effect free apart from the explicitly injected + * storage parameter, so optimistic insert / reconcile / rollback behaviour + * can be unit tested without React or jsdom tricks. + */ +import type { Quote } from '@/lib/types'; + +export type QuoteInputs = { + source: string; + dest: string; + amount: string; +}; + +export type HistoryEntry = QuoteInputs & { savedAt: number }; + +/** Optimistic entry applied locally before the server confirms it. */ +export type PendingHistoryEntry = HistoryEntry & { key: string }; + +export const HISTORY_KEY = 'stableroute.quote.history'; +export const MAX_HISTORY = 5; + +type ReadStorage = Pick; +type WriteStorage = Pick; + +/** + * Load confirmed history. Malformed or non-array payloads degrade to an + * empty list; results are capped at MAX_HISTORY. + */ +export function readHistory( + storage: ReadStorage = localStorage +): HistoryEntry[] { + try { + const raw = storage.getItem(HISTORY_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as HistoryEntry[]; + return Array.isArray(parsed) ? parsed.slice(0, MAX_HISTORY) : []; + } catch { + return []; + } +} + +/** + * Pure counterpart of the historical pushHistory(): prepend `entry`, drop + * earlier duplicates of the same (source, dest, amount) triple, cap at + * `max`. Never mutates the input array, keeping ordering of untouched + * entries stable. + */ +export function pushHistoryPure( + entries: HistoryEntry[], + entry: QuoteInputs, + max: number = MAX_HISTORY, + savedAt: number = Date.now() +): HistoryEntry[] { + const next: HistoryEntry[] = [ + { + source: entry.source, + dest: entry.dest, + amount: entry.amount, + savedAt, + }, + ...entries.filter( + (item) => + item.source !== entry.source || + item.dest !== entry.dest || + item.amount !== entry.amount + ), + ]; + return next.slice(0, max); +} + +/** Persist the confirmed list and echo it back for state updates. */ +export function writeHistory( + entries: HistoryEntry[], + storage: WriteStorage = localStorage +): HistoryEntry[] { + storage.setItem(HISTORY_KEY, JSON.stringify(entries)); + return entries; +} + +/** + * Build the canonical confirmed entry from the server's quote response + * fields, so the stored row always reflects what the backend returned. + */ +export function canonicalEntryFromQuote( + quote: Pick, + savedAt: number = Date.now() +): HistoryEntry { + return { + source: quote.source_asset, + dest: quote.dest_asset, + amount: quote.amount, + savedAt, + }; +} + +/** + * Render view for the history list: the optimistic entry (if any) leads, + * followed by the confirmed rows. Optimistic rows are never persisted. + */ +export function mergePendingEntry( + confirmed: HistoryEntry[], + pending: PendingHistoryEntry | null +): HistoryEntry[] { + return pending ? [pending, ...confirmed] : confirmed; +} diff --git a/src/app/quote/optimistic-history.test.tsx b/src/app/quote/optimistic-history.test.tsx new file mode 100644 index 0000000..379e367 --- /dev/null +++ b/src/app/quote/optimistic-history.test.tsx @@ -0,0 +1,248 @@ +import { + render, + screen, + fireEvent, + waitFor, + cleanup, + act, +} from '@testing-library/react'; +import QuotePage from './page'; +import { HISTORY_KEY } from './historyModel'; + +const getSourceInput = () => + screen.getByRole('textbox', { name: /Source asset/i }); +const getDestinationInput = () => + screen.getByRole('textbox', { name: /Destination asset/i }); +const getAmountInput = () => + screen.getByRole('textbox', { name: /Amount \(base units\)/i }); + +const getRecentQuotesList = () => { + const heading = screen.getByRole('heading', { name: /Recent quotes/i }); + return withinList(heading); +}; + +// The list lives in the section labelled by the "Recent quotes" heading. +function withinList(heading: HTMLElement): HTMLUListElement { + const section = heading.closest('section'); + if (!section) throw new Error('Recent quotes section not found'); + return section.querySelector('ul') as HTMLUListElement; +} + +const getHistoryButtons = (): HTMLButtonElement[] => + Array.from(getRecentQuotesList().querySelectorAll('button')); + +const getPendingRow = (): HTMLElement | null => + getRecentQuotesList().querySelector('[data-pending]'); + +const quoteResponse = ( + body: Record +): Response => ({ ok: true, text: async () => JSON.stringify(body) } as unknown as Response); + +async function fillAndSubmit( + source: string, + dest: string, + amount: string +): Promise { + fireEvent.change(getSourceInput(), { target: { value: source } }); + fireEvent.change(getDestinationInput(), { target: { value: dest } }); + fireEvent.change(getAmountInput(), { target: { value: amount } }); + fireEvent.submit(getAmountInput().closest('form')!); +} + +describe('QuotePage optimistic recent quotes (#723)', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + jest.useRealTimers(); + }); + + it('shows the optimistic row immediately and replaces it with the server-canonical entry on success', async () => { + // Server canonicalises the destination to lowercase — distinct from the + // typed form value, so the swap proves reconciliation uses response fields. + globalThis.fetch = jest + .fn() + .mockResolvedValue( + quoteResponse({ + source_asset: 'USDC', + dest_asset: 'eurc', + amount: '1000000', + estimated_rate: '1.02', + route: ['USDC', 'eurc'], + }) + ) as unknown as typeof globalThis.fetch; + + render(); + await fillAndSubmit('USDC', 'EURC', '1000000'); + + // Optimistic row is visible before the response arrives... + expect(getPendingRow()).not.toBeNull(); + expect(getHistoryButtons()[0].textContent).toContain('USDC → EURC'); + // ...and nothing has been persisted yet. + expect(localStorage.getItem(HISTORY_KEY)).toBeNull(); + + await waitFor(() => { + expect(getPendingRow()).toBeNull(); + }); + // Confirmed entry is built from the SERVER fields ('eurc'), not the + // locally typed ones, and is now persisted. + expect(getHistoryButtons()[0].textContent).toContain('USDC → eurc'); + const stored = JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]'); + expect(stored[0]).toMatchObject({ source: 'USDC', dest: 'eurc' }); + expect(stored).toHaveLength(1); + }); + + it('rolls back exactly to the prior state on error and leaves storage untouched', async () => { + const seeded = [ + { source: 'XLM', dest: 'USDC', amount: '500', savedAt: 123 }, + ]; + localStorage.setItem(HISTORY_KEY, JSON.stringify(seeded)); + globalThis.fetch = jest + .fn() + .mockRejectedValue(new Error('network down')) as unknown as typeof globalThis.fetch; + + render(); + expect(getHistoryButtons()).toHaveLength(1); + + await fillAndSubmit('USDC', 'EURC', '1000000'); + expect(getPendingRow()).not.toBeNull(); + + await waitFor(() => { + expect(getPendingRow()).toBeNull(); + }); + + // Exact rollback: only the pre-existing row remains. + const rows = getHistoryButtons(); + expect(rows).toHaveLength(1); + expect(rows[0].textContent).toContain('XLM → USDC · 500'); + // Storage was never touched by the optimistic mutation. + expect(localStorage.getItem(HISTORY_KEY)).toBe( + JSON.stringify(seeded) + ); + }); + + it('ignores stale responses when a newer submission supersedes an older one', async () => { + jest.useFakeTimers(); + + let resolveA: ((value: Response) => void) | undefined; + let resolveB: ((value: Response) => void) | undefined; + const pendingA = new Promise((resolve) => { + resolveA = resolve; + }); + const pendingB = new Promise((resolve) => { + resolveB = resolve; + }); + globalThis.fetch = jest + .fn() + .mockImplementationOnce(() => pendingA) + .mockImplementationOnce(() => pendingB) as unknown as typeof globalThis.fetch; + + render(); + + await act(async () => { + await fillAndSubmit('USDC', 'EURC', '1000000'); + }); + + // Cooldown window passes; user edits the pair and submits again while + // request A is still unresolved. + act(() => { + jest.advanceTimersByTime(1000); + }); + await act(async () => { + await fillAndSubmit('XLM', 'BTC', '777'); + }); + + // Only B's optimistic row shows; A was implicitly discarded. + expect(getPendingRow()).not.toBeNull(); + expect(getHistoryButtons()).toHaveLength(1); + expect(getHistoryButtons()[0].textContent).toContain('XLM → BTC · 777'); + + // A resolves LATE — its result must not touch the newer state. + await act(async () => { + resolveA?.( + quoteResponse({ + source_asset: 'USDC', + dest_asset: 'EURC', + amount: '1000000', + estimated_rate: '1.0', + route: ['USDC', 'EURC'], + }) + ); + }); + expect(getPendingRow()).not.toBeNull(); + expect(getHistoryButtons()).toHaveLength(1); + expect(getHistoryButtons()[0].textContent).toContain('XLM → BTC · 777'); + expect(localStorage.getItem(HISTORY_KEY)).toBeNull(); + + // B settles: its optimistic row reconciles into the single stored entry. + await act(async () => { + resolveB?.( + quoteResponse({ + source_asset: 'XLM', + dest_asset: 'BTC', + amount: '777', + estimated_rate: '1.1', + route: ['XLM', 'BTC'], + }) + ); + }); + expect(getPendingRow()).toBeNull(); + expect(getHistoryButtons()[0].textContent).toContain('XLM → BTC · 777'); + const stored = JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]'); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ source: 'XLM', dest: 'BTC' }); + }); + + it('leaves concurrent unrelated updates unaffected by a rollback', async () => { + const seeded = [ + { source: 'XLM', dest: 'USDC', amount: '500', savedAt: 123 }, + ]; + localStorage.setItem(HISTORY_KEY, JSON.stringify(seeded)); + globalThis.fetch = jest + .fn() + .mockRejectedValue(new Error('network down')) as unknown as typeof globalThis.fetch; + + render(); + await fillAndSubmit('USDC', 'EURC', '1000000'); + expect(getPendingRow()).not.toBeNull(); + + // Unrelated update mid-flight: pick the existing row back into the form. + fireEvent.click(getHistoryButtons()[getHistoryButtons().length - 1]); + expect(getSourceInput()).toHaveValue('XLM'); + expect(getDestinationInput()).toHaveValue('USDC'); + expect(getAmountInput()).toHaveValue('500'); + + // Request fails and rolls back the optimistic row… + await waitFor(() => { + expect(getPendingRow()).toBeNull(); + }); + // …but the unrelated form edits survive untouched. + expect(getSourceInput()).toHaveValue('XLM'); + expect(getDestinationInput()).toHaveValue('USDC'); + expect(getAmountInput()).toHaveValue('500'); + expect(getHistoryButtons()).toHaveLength(1); + expect(localStorage.getItem(HISTORY_KEY)).toBe(JSON.stringify(seeded)); + }); + + it('announces the rollback to assistive technology', async () => { + globalThis.fetch = jest + .fn() + .mockRejectedValue(new Error('network down')) as unknown as typeof globalThis.fetch; + + render(); + await fillAndSubmit('USDC', 'EURC', '1000000'); + + await waitFor(() => { + const liveRegion = document.querySelector( + 'form p[aria-live="polite"]' + ); + expect(liveRegion?.textContent).toMatch(/rolled back/i); + }); + }); +}); diff --git a/src/app/quote/page.test.tsx b/src/app/quote/page.test.tsx index 9f8eb13..cd7325f 100644 --- a/src/app/quote/page.test.tsx +++ b/src/app/quote/page.test.tsx @@ -581,7 +581,7 @@ describe('QuotePage', () => { }); }); - it('clears the sr-only announcement when the request fails', async () => { + it('announces the rollback to assistive tech when the request fails', async () => { const mockFetch = jest.fn().mockResolvedValueOnce({ ok: false, status: 500, @@ -612,7 +612,7 @@ describe('QuotePage', () => { const liveAnnouncement = document.querySelector( '[aria-live=polite].sr-only' ); - expect(liveAnnouncement).toHaveTextContent(''); + expect(liveAnnouncement).toHaveTextContent(/rolled back/i); }); it('does not announce form status on initial render', () => {