diff --git a/docs/TRANSACTION_PERSIST.md b/docs/TRANSACTION_PERSIST.md new file mode 100644 index 00000000..193aeb75 --- /dev/null +++ b/docs/TRANSACTION_PERSIST.md @@ -0,0 +1,85 @@ +# Transaction State Persistence + +**File:** `src/hooks/useTransactionPersistence.ts` +**Consumer:** `src/app/TransactionProgressModal.tsx` + +## Overview + +`TransactionProgressModal` persists its in-flight state to `localStorage` so that a page reload re-opens the modal at the correct step. When the user returns after an accidental reload they see exactly where the transaction was (e.g. "Confirming Transaction") and retain the copy-hash / retry affordances. + +## How It Works + +| Phase | Behavior | +|---|---| +| Non-IDLE state received | Hook writes state, timelinePhase, txHash, errorCode, actionName, and a `savedAt` timestamp to `localStorage`. | +| Page reloads | On mount the hook reads storage. If the entry is valid (schema version matches, not older than 30 min) it is exposed as `rehydrated`. | +| Modal renders with `state="IDLE"` | Falls back to `rehydrated.state` and shows the modal in the persisted step. | +| User closes from SUCCESS / ERROR | `handleClose` calls `clearPersisted()` which removes the key before invoking `onClose`. | +| Storage unavailable | All reads/writes are wrapped in try/catch — the component degrades gracefully with no error thrown. | + +## API + +### `useTransactionPersistence()` + +```ts +import { useTransactionPersistence } from '@/hooks/useTransactionPersistence'; + +const { rehydrated, persist, clearPersisted } = useTransactionPersistence(); +``` + +| Return | Type | Description | +|---|---|---| +| `rehydrated` | `PersistedTransactionState \| null` | Loaded from storage on mount; `null` when absent or expired. | +| `persist(data)` | `(data: Omit) => void` | Writes current state. Clears storage automatically for terminal states (`SUCCESS`, `IDLE`). | +| `clearPersisted()` | `() => void` | Removes the storage key and resets `rehydrated` to `null`. | + +### `PersistedTransactionState` + +```ts +interface PersistedTransactionState { + state: TransactionState; + timelinePhase: TransactionTimelinePhase; + actionName: string; + txHash?: string; + errorCode?: string; + successMessage?: string; + savedAt: number; // Unix ms +} +``` + +## Storage Details + +- **Key:** `commitlabs-tx-progress` +- **Schema version:** `1` (bump and clear on breaking changes) +- **Max age:** 30 minutes — stale entries are removed on read + +## Accessibility + +The modal already uses `role="dialog"`, `aria-modal="true"`, and `aria-labelledby`. The rehydrated modal appears with the same markup so screen-reader announcements are unchanged. The existing `aria-live="polite"` region in `TransactionStepTimeline` announces the restored step automatically. + +When `prefers-reduced-motion` is active the elapsed-time ticker in `TransactionStepTimeline` is suppressed — this behaviour is preserved for rehydrated sessions. + +## Usage Example + +```tsx +// In any page/route — no changes needed. TransactionProgressModal handles +// persistence internally. Simply pass the live props as before. + + +``` + +On reload, if `txState` is `'IDLE'` but storage has a non-terminal entry, the modal reopens automatically. Once the user closes it (via the close button, "Close" action, or "View Details") storage is cleared and the modal will not reopen on subsequent reloads. + +## Related + +- [`docs/TRANSACTION_TIMELINE.md`](./TRANSACTION_TIMELINE.md) — step timeline component +- [`docs/MODAL_SYSTEM.md`](./MODAL_SYSTEM.md) — modal system overview +- [`docs/CREATE_DRAFT_AUTOSAVE.md`](./CREATE_DRAFT_AUTOSAVE.md) — similar localStorage pattern used in commitment drafts diff --git a/src/app/TransactionModalPersist.test.tsx b/src/app/TransactionModalPersist.test.tsx new file mode 100644 index 00000000..6d60d79b --- /dev/null +++ b/src/app/TransactionModalPersist.test.tsx @@ -0,0 +1,204 @@ +// @vitest-environment happy-dom +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import TransactionProgressModal from './TransactionProgressModal'; + +// --------------------------------------------------------------------------- +// localStorage helpers +// --------------------------------------------------------------------------- +const STORAGE_KEY = 'commitlabs-tx-progress'; + +function seedStorage(partial: Record) { + const envelope = { + version: 1, + data: { savedAt: Date.now(), ...partial }, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope)); +} + +describe('TransactionProgressModal – persistence', () => { + const noop = () => {}; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // 1. Reload re-opens at correct step + // ------------------------------------------------------------------------- + it('re-opens at the correct step after reload using rehydrated state', () => { + seedStorage({ + state: 'PROCESSING', + timelinePhase: 'confirm', + actionName: 'Settling Funds', + txHash: 'hash-abc', + }); + + render( + , + ); + + // Modal should be visible even though isOpen=false because rehydrated state exists + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/Confirming Transaction/i)).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // 2. Hash preserved across reload + // ------------------------------------------------------------------------- + it('preserves the transaction hash in the rehydrated view', () => { + const hash = 'stellar-tx-xyz789'; + seedStorage({ + state: 'ERROR', + timelinePhase: 'submit', + actionName: 'Creating Commitment', + txHash: hash, + errorCode: 'RPC_TIMEOUT', + }); + + render( + , + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + // The hash should appear in the explorer link row + expect(screen.getByText(hash)).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // 3. Terminal SUCCESS state clears storage + // ------------------------------------------------------------------------- + it('clears persisted state when user closes from SUCCESS', () => { + const onClose = vi.fn(); + + render( + , + ); + + // Storage should have been written with terminal state written then cleared + // Click the Close button to trigger handleClose + const closeButtons = screen.getAllByRole('button'); + const closeBtn = closeButtons.find((b) => b.textContent === 'Close'); + expect(closeBtn).toBeDefined(); + + act(() => { + fireEvent.click(closeBtn!); + }); + + expect(onClose).toHaveBeenCalledOnce(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + // ------------------------------------------------------------------------- + // 4. Terminal ERROR acknowledged clears storage + // ------------------------------------------------------------------------- + it('clears persisted state when user closes from ERROR', () => { + const onClose = vi.fn(); + + render( + , + ); + + // The secondary "Close" button closes and acknowledges the error + const closeBtn = screen.getByRole('button', { name: /close modal/i }); + act(() => { + fireEvent.click(closeBtn); + }); + + expect(onClose).toHaveBeenCalledOnce(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + // ------------------------------------------------------------------------- + // 5. Storage-unavailable no-op + // ------------------------------------------------------------------------- + it('renders without errors when localStorage is unavailable', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + + expect(() => + render( + , + ), + ).not.toThrow(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // 6. Live state takes precedence over rehydrated state + // ------------------------------------------------------------------------- + it('uses live props when a non-IDLE state is provided, ignoring stale storage', () => { + seedStorage({ + state: 'PROCESSING', + timelinePhase: 'confirm', + actionName: 'Old Action', + }); + + render( + , + ); + + expect(screen.getByText(/Confirm in Freighter/i)).toBeInTheDocument(); + expect(screen.queryByText(/Confirming Transaction/i)).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // 7. Nothing rendered when no props and no storage + // ------------------------------------------------------------------------- + it('renders nothing when state is IDLE and storage is empty', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/app/TransactionProgressModal.tsx b/src/app/TransactionProgressModal.tsx index e4f559af..70f36adb 100644 --- a/src/app/TransactionProgressModal.tsx +++ b/src/app/TransactionProgressModal.tsx @@ -3,6 +3,7 @@ import React from 'react'; import TransactionStepTimeline, { type TransactionTimelinePhase } from '@/components/transaction/TransactionStepTimeline'; import { buildExplorerUrl, openExplorerUrl } from '@/utils/explorerLinks'; +import { useTransactionPersistence } from '@/hooks/useTransactionPersistence'; export type TransactionState = | 'IDLE' @@ -87,7 +88,21 @@ export default function TransactionProgressModal({ onRetry, onSuccessAction, }: TransactionProgressModalProps) { - const [timelinePhase, setTimelinePhase] = React.useState('build'); + const { rehydrated, persist, clearPersisted } = useTransactionPersistence(); + + // Resolve effective values: prefer live props, fall back to rehydrated state on reload + const effectiveState: TransactionState = + state !== 'IDLE' ? state : rehydrated?.state ?? 'IDLE'; + const effectiveHash = txHash ?? rehydrated?.txHash; + const effectiveErrorCode = errorCode !== 'UNKNOWN_ERROR' ? errorCode : rehydrated?.errorCode ?? 'UNKNOWN_ERROR'; + const effectiveActionName = actionName || rehydrated?.actionName || ''; + const effectiveSuccessMessage = successMessage !== 'Your transaction has been successfully processed.' + ? successMessage + : rehydrated?.successMessage ?? 'Your transaction has been successfully processed.'; + + const [timelinePhase, setTimelinePhase] = React.useState( + rehydrated?.timelinePhase ?? 'build', + ); React.useEffect(() => { if (!isOpen || state === 'IDLE') { @@ -115,9 +130,32 @@ export default function TransactionProgressModal({ } }, [isOpen, state]); - if (!isOpen || state === 'IDLE') return null; + // Persist whenever live state changes (non-IDLE) or clear on terminal states + React.useEffect(() => { + if (state === 'IDLE') return; + persist({ + state, + timelinePhase, + actionName, + txHash, + errorCode, + successMessage, + }); + }, [state, timelinePhase, actionName, txHash, errorCode, successMessage, persist]); + + // Clear persisted state when user explicitly closes from a terminal state + const handleClose = React.useCallback(() => { + if (effectiveState === 'SUCCESS' || effectiveState === 'ERROR') { + clearPersisted(); + } + onClose(); + }, [effectiveState, clearPersisted, onClose]); + + // Show modal if explicitly open OR if rehydrated non-idle state exists + const shouldShow = isOpen || (effectiveState !== 'IDLE' && !!rehydrated); + if (!shouldShow) return null; - const txExplorerUrl = buildExplorerUrl('tx', txHash); + const txExplorerUrl = buildExplorerUrl('tx', effectiveHash); const handleCopyHash = async (hash: string) => { if (!hash) return; @@ -133,44 +171,44 @@ export default function TransactionProgressModal({ // -- State Configuration Helpers -- const getHeader = () => { - switch (state) { + switch (effectiveState) { case 'AWAITING_SIGNATURE': return 'Confirm in Freighter'; - case 'SUBMITTING': return `${actionName} in Progress`; + case 'SUBMITTING': return `${effectiveActionName} in Progress`; case 'PROCESSING': return 'Confirming Transaction'; - case 'SUCCESS': return `${actionName} Successful!`; + case 'SUCCESS': return `${effectiveActionName} Successful!`; case 'ERROR': - return errorCode === 'RPC_TIMEOUT' ? 'Network Timeout' : 'Transaction Failed'; + return effectiveErrorCode === 'RPC_TIMEOUT' ? 'Network Timeout' : 'Transaction Failed'; default: return 'Transaction in Progress'; } }; const getLeadText = () => { - switch (state) { + switch (effectiveState) { case 'AWAITING_SIGNATURE': return 'Please sign the transaction in your wallet.'; case 'SUBMITTING': return 'Sending to the Stellar Network...'; case 'PROCESSING': return 'Waiting for network confirmation...'; case 'SUCCESS': return 'Your transaction has been confirmed.'; case 'ERROR': - return ERROR_MAPPINGS[errorCode]?.lead || ERROR_MAPPINGS['UNKNOWN_ERROR'].lead; + return ERROR_MAPPINGS[effectiveErrorCode]?.lead || ERROR_MAPPINGS['UNKNOWN_ERROR'].lead; default: return ''; } }; const getHelperText = () => { - switch (state) { + switch (effectiveState) { case 'AWAITING_SIGNATURE': return "We're waiting for your approval to proceed."; case 'SUBMITTING': return "This usually takes 3-5 seconds. Please don't close this window."; case 'PROCESSING': return 'The transaction has been submitted and is waiting to be included in the ledger.'; - case 'SUCCESS': return successMessage; + case 'SUCCESS': return effectiveSuccessMessage; case 'ERROR': - return ERROR_MAPPINGS[errorCode]?.helper || ERROR_MAPPINGS['UNKNOWN_ERROR'].helper; + return ERROR_MAPPINGS[effectiveErrorCode]?.helper || ERROR_MAPPINGS['UNKNOWN_ERROR'].helper; default: return ''; } }; // -- Visual Graphic Renderers -- const renderGraphic = () => { - if (state === 'AWAITING_SIGNATURE') { + if (effectiveState === 'AWAITING_SIGNATURE') { return (
@@ -182,7 +220,7 @@ export default function TransactionProgressModal({ ); } - if (state === 'SUBMITTING' || state === 'PROCESSING') { + if (effectiveState === 'SUBMITTING' || effectiveState === 'PROCESSING') { return (
@@ -193,7 +231,7 @@ export default function TransactionProgressModal({ ); } - if (state === 'SUCCESS') { + if (effectiveState === 'SUCCESS') { return (
@@ -203,8 +241,8 @@ export default function TransactionProgressModal({ ); } - if (state === 'ERROR') { - const errorType = ERROR_MAPPINGS[errorCode]?.type || 'error'; + if (effectiveState === 'ERROR') { + const errorType = ERROR_MAPPINGS[effectiveErrorCode]?.type || 'error'; const color = errorType === 'warning' ? '#FF8904' : '#FF4757'; const bgClass = errorType === 'warning' ? 'bg-[#FF8904]/10 border-[#FF8904]/20' : 'bg-[#FF4757]/10 border-[#FF4757]/20'; @@ -232,43 +270,43 @@ export default function TransactionProgressModal({ // -- Actions Renderer -- const renderActions = () => { - const inProgress = state === 'SUBMITTING' || state === 'PROCESSING'; + const inProgress = effectiveState === 'SUBMITTING' || effectiveState === 'PROCESSING'; if (inProgress) { return null; // No actions allowed while broadcasting/mining to prevent desync } - if (state === 'AWAITING_SIGNATURE') { + if (effectiveState === 'AWAITING_SIGNATURE') { return ( -
- -
); } - if (state === 'ERROR') { - const mapping = ERROR_MAPPINGS[errorCode] || ERROR_MAPPINGS['UNKNOWN_ERROR']; - const isTimeout = errorCode === 'RPC_TIMEOUT'; + if (effectiveState === 'ERROR') { + const mapping = ERROR_MAPPINGS[effectiveErrorCode] || ERROR_MAPPINGS['UNKNOWN_ERROR']; + const isTimeout = effectiveErrorCode === 'RPC_TIMEOUT'; const handlePrimaryClick = () => { if (isTimeout && txExplorerUrl) { - openExplorerUrl('tx', txHash); + openExplorerUrl('tx', effectiveHash); } else if (mapping.primary === 'Fund Wallet' || mapping.primary === 'Contact Support') { // Handle external redirect logic here if applicable, otherwise fallback to generic - onClose(); + handleClose(); } else { onRetry?.(); } @@ -276,13 +314,13 @@ export default function TransactionProgressModal({ return (
- -
@@ -294,7 +332,7 @@ export default function TransactionProgressModal({ return (
-
{/* Only show close button if it's safe to cancel */} - {state !== 'SUBMITTING' && state !== 'PROCESSING' && ( -