Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .github/workflows/cypress.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Cypress Smoke Tests
name: Cypress Smoke Tests

on:
pull_request:
Expand Down Expand Up @@ -32,6 +32,10 @@ jobs:

- name: Build shared API schemas
working-directory: packages/api-schemas
run: |
npm ci
# TypeScript 5.9 removed moduleResolution=node10; build with 5.5 for compatibility
npx -p typescript@5.5.4 tsc -p tsconfig.json
run: npm ci && npm run build

- name: Install dependencies
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ jobs:
uses: actions/checkout@v4

- name: Setup Rust
uses: dtolnay/rust-toolchain@1.84.0

- name: Generate Rust docs
run: cargo doc -p share-price-math --no-deps
uses: dtolnay/rust-toolchain@1.85.0

- name: Generate Rust docs
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: E2E Tests
name: E2E Tests

on:
pull_request:
Expand Down Expand Up @@ -31,6 +31,10 @@ jobs:

- name: Build shared API schemas
working-directory: packages/api-schemas
run: |
npm ci
# TypeScript 5.9 removed moduleResolution=node10; build with 5.5 for compatibility
npx -p typescript@5.5.4 tsc -p tsconfig.json
run: npm ci && npm run build

- name: Install dependencies
Expand Down
22 changes: 20 additions & 2 deletions docs/FRONTEND_STATE_MANAGEMENT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# YieldVault-RWA — Frontend State Management

> **Last Updated:** 2026-05-29
> **Last Updated:** 2026-07-24

A shared reference for the frontend state management architecture used across the YieldVault-RWA codebase. The purpose of this document is to clarify state ownership, data fetching boundaries, and UI synchronization patterns to ensure a consistent and maintainable developer experience.

Expand Down Expand Up @@ -65,6 +65,23 @@ Instead of relying on backend endpoints for every data permutation, raw data is
- **Vault Metrics**: `VaultContext` consumes raw `useVaultSummary` and `useVaultHistory` data, computes the active APY and Vault Utilization, and provides these derived metrics to all dashboard components.
- **Table Filtering**: `TransactionHistory` fetches a raw list of transactions (max 200) and uses `useClientDataTable` to handle sorting, filtering, and pagination entirely in memory.

### Optimistic Mutations And Rollback
Deposit and withdrawal mutations update React Query caches immediately so the UI feels responsive, then reconcile with the server.

Shared helpers live in `frontend/src/lib/optimisticVaultCache.ts` and are consumed by `useDepositMutation` / `useWithdrawMutation`:

1. **Cancel** in-flight reads for balance, holdings, vault summary, and transactions.
2. **Snapshot** each key, including whether the key existed (so rollback can `removeQueries` when there was no prior cache).
3. **Apply** optimistic patches:
- Deposit: wallet USDC ↓, holdings/TVL ↑, prepend pending tx row
- Withdrawal: wallet USDC ↑, holdings/TVL ↓, prepend pending tx row
4. **On error**: restore the snapshot exactly (cache consistency first).
5. **On settled** (success or failure): invalidate the same keys so server truth replaces optimistic rows.

`VaultDashboard` still owns user-facing feedback (result step + toast). Cache rollback must not depend on toast timing.

See also [`docs/VAULT_UX_PATTERN_LIBRARY.md`](./VAULT_UX_PATTERN_LIBRARY.md) for pending / optimistic UX rules.

---

## Key Contexts
Expand Down Expand Up @@ -93,7 +110,7 @@ Custom hooks in `frontend/src/hooks/` encapsulate all complex logic.
- **`useVaultSummary` & `useVaultHistory`**: Fetch global vault stats and historical data.

**Mutation Hooks (React Query):**
- **`useVaultMutations`**: Exposes `useDepositMutation` and `useWithdrawMutation` for executing Soroban contract calls. Automatically invalidates related query caches (balances, transactions) on success.
- **`useVaultMutations`**: Exposes `useDepositMutation` and `useWithdrawMutation` for executing Soroban contract calls. Applies optimistic cache updates via `optimisticVaultCache`, rolls back on failure, and invalidates related query caches on settle.

**Utility Hooks:**
- **`useClientDataTable`**: Handles client-side pagination, sorting, and text-based filtering of arrays.
Expand Down Expand Up @@ -127,6 +144,7 @@ To maintain a clean and scalable frontend architecture, adhere to the following
3. **Use the URL as the Source of Truth:** For shareable states like search queries, filters, or active tabs, use the URL parameters instead of internal `useState`.
4. **Don't Duplicate Server State:** Avoid copying React Query data into local `useState`. Derive values directly from the query data during render.
5. **Colocate Form State:** Use the custom `useForm` hook for transaction inputs and validation. Avoid storing form inputs in global contexts.
6. **Optimistic Updates Must Roll Back:** When mutating vault state, patch React Query caches through `optimisticVaultCache` helpers. Always snapshot before patching and restore on failure; never leave pending optimistic rows after an error.

---

Expand Down
3 changes: 3 additions & 0 deletions docs/VAULT_UX_PATTERN_LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,5 +358,8 @@ The current approved implementation lives in:
- `frontend/src/components/VaultDashboard.tsx`
- `frontend/src/components/TransactionConfirmationModal.tsx`
- `frontend/src/hooks/useVaultMutations.ts`
- `frontend/src/lib/optimisticVaultCache.ts` (snapshot / apply / rollback helpers)

Optimistic deposit and withdrawal patches credit or debit wallet USDC in opposite directions, mark holdings and pending transaction rows as `pending`, and restore the pre-mutation snapshot when the network or contract call fails. Settled mutations always invalidate related React Query keys so the UI converges on server truth.

If a future implementation intentionally diverges from this library, update this document in the same change set and explain the reason in the pull request.
6 changes: 6 additions & 0 deletions frontend/e2e/deposit-withdraw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import {
/** Valid Stellar public key (G + 55 base32 chars) for API validation in submitDeposit / submitWithdrawal. */
const MOCK_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';

/** Truncated or privacy-masked forms of MOCK_ADDRESS shown in the navbar. */
const SHORT_ADDR = /GBBD4?\.\.\.FLA5|GBBD•{8}FLA5/;

async function goToConnectedVault(page: Page, path = '/') {
await page.goto(path);
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 10_000 });
const SHORT_ADDR = `${MOCK_ADDRESS.substring(0, 5)}...${MOCK_ADDRESS.substring(MOCK_ADDRESS.length - 4)}`;

async function goToConnectedVault(page: Page, path = '/') {
Expand Down
21 changes: 21 additions & 0 deletions frontend/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ async function fulfillHorizonRoute(route: import('@playwright/test').Route) {
export async function interceptApiRoutes(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('hasSeenWalkthrough', 'true');
// Privacy mode defaults to masked identifiers; disable for stable E2E address matchers.
window.localStorage.setItem(
'yieldvault-preferences:guest',
JSON.stringify({ maskSensitiveValues: false }),
);
// Match Cypress: skip service-worker registration so Playwright route mocks
// are not bypassed by cross-origin fetches issued from the SW context.
(window as Window & { Cypress?: boolean }).Cypress = true;
Expand Down Expand Up @@ -407,6 +412,14 @@ export async function completeVaultReviewStep(
*/
export async function stubFreighterConnected(page: Page, address: string) {
await page.addInitScript((addr) => {
window.localStorage.setItem(
`yieldvault-preferences:${addr}`,
JSON.stringify({ maskSensitiveValues: false }),
);
window.localStorage.setItem(
'yieldvault-preferences:guest',
JSON.stringify({ maskSensitiveValues: false }),
);
try {
const raw = localStorage.getItem('yieldvault-preferences:guest');
const base = raw ? JSON.parse(raw) : { maskSensitiveValues: false };
Expand Down Expand Up @@ -478,6 +491,14 @@ export async function stubFreighterConnected(page: Page, address: string) {
*/
export async function stubFreighterManualConnect(page: Page, address: string) {
await page.addInitScript((addr) => {
window.localStorage.setItem(
`yieldvault-preferences:${addr}`,
JSON.stringify({ maskSensitiveValues: false }),
);
window.localStorage.setItem(
'yieldvault-preferences:guest',
JSON.stringify({ maskSensitiveValues: false }),
);
try {
const raw = localStorage.getItem('yieldvault-preferences:guest');
const base = raw ? JSON.parse(raw) : { maskSensitiveValues: false };
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/portfolio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { test, expect, interceptApiRoutes, stubFreighterConnected } from './fixtures';

const MOCK_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
const SHORT_ADDR = `${MOCK_ADDRESS.substring(0, 5)}...${MOCK_ADDRESS.substring(MOCK_ADDRESS.length - 4)}`;
const SHORT_ADDR = /GBBD4?\.\.\.FLA5|GBBD•{8}FLA5/;

test.describe('Portfolio page unauthenticated', () => {
test('shows connect-wallet prompt when no wallet is connected', async ({ page }) => {
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PreferencesProvider } from '../context/PreferencesContext';
import Navbar from './Navbar';
Expand All @@ -14,6 +14,13 @@ describe('Navbar', () => {
defaultOptions: { queries: { retry: false } },
});

beforeEach(() => {
localStorage.setItem(
'yieldvault-preferences:guest',
JSON.stringify({ maskSensitiveValues: false }),
);
});

it('renders the navbar with navigation links', () => {
render(
<MemoryRouter>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/TransactionConfirmationModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ describe('TransactionConfirmationModal', () => {
it('displays contract address in monospace font', () => {
render(<TransactionConfirmationModal {...defaultProps} />);
const addressText = screen.getByText(mockSummary.contractAddress);
const styledParent = addressText.parentElement;
expect(styledParent?.style.fontFamily).toMatch(/monospace/i);
expect(addressText.parentElement?.getAttribute("style") ?? "").toMatch(/monospace/i);
});
});
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/VaultDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ vi.mock("../hooks/useVaultData", () => ({
useVaultHistory: vi.fn(),
}));

const { mockDepositMutateAsync, mockWithdrawMutateAsync } = vi.hoisted(() => ({
mockDepositMutateAsync: vi.fn().mockResolvedValue({}),
mockWithdrawMutateAsync: vi.fn().mockResolvedValue({}),
}));

vi.mock("../hooks/useVaultMutations", () => ({
useDepositMutation: vi.fn(() => ({
mutateAsync: mockDepositMutateAsync,
Expand Down Expand Up @@ -89,6 +94,7 @@ vi.mock("../hooks/useTransactionConfirmation", () => ({

const mockSummary = {
tvl: 12450800,
depositCap: 15000000,
apy: 8.45,
participantCount: 1248,
monthlyGrowthPct: 12.5,
Expand Down Expand Up @@ -193,6 +199,9 @@ describe("VaultDashboard", () => {
approve: vi.fn().mockResolvedValue(undefined),
resetApproval: vi.fn(),
});
window.matchMedia = vi.fn().mockReturnValue({
matches: false,
media: "",
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
Expand All @@ -202,6 +211,7 @@ describe("VaultDashboard", () => {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
} as MediaQueryList);
}));
localStorage.clear();
});
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/WalletConnect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ describe('WalletConnect', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
localStorage.setItem(
'yieldvault-preferences:guest',
JSON.stringify({ maskSensitiveValues: false }),
);
mockedFreighter.isConnected.mockResolvedValue({ isConnected: true });
mockedWalletSession.getLastWalletProvider.mockReturnValue(null);
mockedWalletSession.isProviderAvailable.mockResolvedValue(true);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/context/ToastContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
...options
}: ToastOptions) => {
const dedupeKey = generateDedupeKey({ ...options, variant });
// eslint-disable-next-line react-hooks/purity -- showToast runs on user/system events
// Timestamp is intentionally captured at toast creation time for dedupe windows.
// eslint-disable-next-line react-hooks/purity -- event-handler side effect, not render output
const now = Date.now();
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/useAsyncActionButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function useAsyncActionButton({
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect -- mirror external async flags into button status */
if (isPending) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- maps external async flags into button chrome
// Sync button chrome with mutation status flags from the caller.
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived UI status from external flags
setStatus("pending");
Expand Down
Loading
Loading