Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/TRANSACTION_PERSIST.md
Original file line number Diff line number Diff line change
@@ -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<PersistedTransactionState, 'savedAt'>) => 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.

<TransactionProgressModal
isOpen={txModalOpen}
state={txState} // 'IDLE' after reload → falls back to storage
actionName="Settling Funds"
txHash={hash}
errorCode={errCode}
onClose={handleClose}
onRetry={handleRetry}
/>
```

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
204 changes: 204 additions & 0 deletions src/app/TransactionModalPersist.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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(
<TransactionProgressModal
isOpen={false}
state="IDLE"
actionName=""
onClose={noop}
/>,
);

// 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(
<TransactionProgressModal
isOpen={false}
state="IDLE"
actionName=""
onClose={noop}
/>,
);

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(
<TransactionProgressModal
isOpen={true}
state="SUCCESS"
actionName="Settling Funds"
txHash="hash-done"
onClose={onClose}
/>,
);

// 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(
<TransactionProgressModal
isOpen={true}
state="ERROR"
actionName="Settling Funds"
errorCode="USER_REJECTED"
onClose={onClose}
/>,
);

// 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(
<TransactionProgressModal
isOpen={true}
state="AWAITING_SIGNATURE"
actionName="Test"
onClose={noop}
/>,
),
).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(
<TransactionProgressModal
isOpen={true}
state="AWAITING_SIGNATURE"
actionName="New Action"
onClose={noop}
/>,
);

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(
<TransactionProgressModal
isOpen={false}
state="IDLE"
actionName=""
onClose={noop}
/>,
);

expect(container.firstChild).toBeNull();
});
});
Loading
Loading