diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a52519f..5f7d486 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,10 @@ jobs: - run: npm install + - name: Type-check TypeScript + working-directory: frontend + run: npm run typecheck + - name: Run tests with coverage run: npm run test:coverage diff --git a/frontend/docs/typescript-migration.md b/frontend/docs/typescript-migration.md new file mode 100644 index 0000000..6899d58 --- /dev/null +++ b/frontend/docs/typescript-migration.md @@ -0,0 +1,77 @@ +# TypeScript Migration Plan + +## Status + +**Phase 1 complete** — `src/utils/` is fully typed (8 files, ~300 lines). + +TypeScript is configured in strict mode with `allowJs: true` / `checkJs: false` so the remaining `.jsx`/`.js` files compile without errors while migration continues incrementally. + +--- + +## Completed + +| Layer | Files | Status | +|-------|-------|--------| +| `src/utils/` | `validateAmount`, `validateStellarAddress`, `errorMessages`, `errorLogger`, `formatBalance`, `searchHighlighter`, `animations`, `webVitals` | ✅ Converted to `.ts` | + +--- + +## Remaining migration order (lowest risk → highest risk) + +### Phase 2 — Design system (standalone, no business logic) +Convert `.jsx` → `.tsx` for files in `src/design-system/`: +`Button`, `Input`, `Modal`, `Badge`, `Card` (plus their `.stories.jsx` files). + +Each component has clear props and no external API calls; adding `interface Props` is mechanical. + +### Phase 3 — Custom hooks +Convert `src/hooks/*.js` to `.ts`. Hooks have explicit inputs/outputs and no JSX, making them straightforward candidates after the design system. + +Priority order: `useMessages`, `useNetworkStatus`, `useExchangeRate`, `useWebSocket`, `useOfflineQueue`, `usePWA`, `useRTL`, `useCopy`, `useFileUpload`, `useFocusTrap`. + +### Phase 4 — Store and context +Convert `src/store/` and `src/contexts/`: +- `reducer.js` → `reducer.ts` (type the action union) +- `AppStateContext.jsx` → `AppStateContext.tsx` +- `persistence.js` → `persistence.ts` +- `tabSync.js` → `tabSync.ts` +- `ThemeContext.jsx` → `ThemeContext.tsx` + +Define a `State` interface in `reducer.ts` and propagate it through context so all consumers get typed state. + +### Phase 5 — Leaf components (no children/complex props) +`Spinner`, `CopyButton`, `StatusMessage`, `NetworkBadge`, `NetworkStatusBanner`, `XLMInfoIcon`, `LanguageSelector`, `FileUpload`, `NotificationBell`. + +### Phase 6 — Feature components +`TransactionHistory`, `PaymentConfirmationModal`, `QRCodeModal`, `QRScanner`, `ImportAccountForm`, `ConfirmSendDialog`, `FeeDisplay`, `InlineConfirmation`, `AccountCreatedCelebration`, `TxLookup`, `AddressBook`, `AccountSettings`, `NotificationPreferences`, `BackupSettings`. + +### Phase 7 — Complex / data-heavy components +`AMMPoolBrowser`, `StreamPayment`, `PathPayment`, `ConvertWidget`, `AccountRecovery`, `MultiSigTransactions`, `KYCForm`, `ComplianceDashboard`. + +### Phase 8 — App entry point +`App.jsx` → `App.tsx` (convert last; it imports everything above and benefits most from having upstream types settled). + +--- + +## Guidelines for each conversion + +1. Rename `.jsx` → `.tsx` (or `.js` → `.ts`). +2. Add `interface Props { ... }` for component props; use `React.FC` or the equivalent function signature. +3. Replace `prop-types` guards with TypeScript types and remove the `prop-types` import. +4. Run `npm run typecheck` after each file to catch issues early. +5. Do not silence errors with `@ts-ignore` — fix them or open a follow-up issue. + +## Enabling stricter checks incrementally + +Once all files in a phase are converted, enable `checkJs: true` (or remove `allowJs`) and tighten flags: + +```json +"noUncheckedIndexedAccess": true, // after Phase 4 +"exactOptionalPropertyTypes": true // after Phase 7 +``` + +--- + +## CI + +`npm run typecheck` (`tsc --noEmit`) runs in CI on every push and pull request (see `.github/workflows/test.yml`). diff --git a/frontend/package.json b/frontend/package.json index 741d023..13511d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,9 @@ "analyze": "vite build && open stats.html", "preview": "vite preview", "test": "vitest run", + "typecheck": "tsc --noEmit", "lint": "eslint src", - "format": "prettier --write \"src/**/*.{js,jsx}\"", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, @@ -35,11 +36,14 @@ "@storybook/addon-a11y": "^8.0.0", "@storybook/addon-essentials": "^8.0.0", "@storybook/react-vite": "^8.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.1.0", "prettier": "^3.3.3", "storybook": "^8.0.0", + "typescript": "^5.0.0", "vite": "^5.3.3" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 603f5c1..fa6c6d1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, lazy, Suspense } from 'react'; import axios from 'axios'; import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; import { isValidStellarAddress } from './utils/validateStellarAddress'; @@ -35,8 +35,6 @@ import { FileUpload } from './components/FileUpload'; import { AccountCreatedCelebration } from './components/AccountCreatedCelebration'; import { TxLookup } from './components/TxLookup'; import { AddressBook } from './components/AddressBook'; -import { MultiSigTransactions } from './components/MultiSigTransactions'; -import { KYCForm } from './components/KYCForm'; import { NotificationPreferences } from './components/NotificationPreferences'; import { NotificationBell } from './components/NotificationBell'; import { useTheme } from './contexts/ThemeContext'; @@ -45,10 +43,15 @@ import { useExchangeRate } from './hooks/useExchangeRate'; import { useBalance, useSendPayment, useCreateAccount, useImportAccount, useKycStatus, useSaveAccountLabel, useNetworkStatusQuery } from './hooks/useQueryHooks'; import { AMMPoolBrowser } from './components/AMMPoolBrowser'; import { ConvertWidget } from './components/ConvertWidget'; -import { AccountRecovery } from './components/AccountRecovery'; import { XLMInfoIcon } from './components/XLMInfoIcon'; -import { ComplianceDashboard } from './components/ComplianceDashboard'; -import { BackupSettings } from './components/BackupSettings'; + +// Heavy components loaded on-demand to keep the initial bundle small +const AMMPoolBrowser = lazy(() => import('./components/AMMPoolBrowser').then(m => ({ default: m.AMMPoolBrowser }))); +const AccountRecovery = lazy(() => import('./components/AccountRecovery').then(m => ({ default: m.AccountRecovery }))); +const MultiSigTransactions = lazy(() => import('./components/MultiSigTransactions').then(m => ({ default: m.MultiSigTransactions }))); +const KYCForm = lazy(() => import('./components/KYCForm').then(m => ({ default: m.KYCForm }))); +const ComplianceDashboard = lazy(() => import('./components/ComplianceDashboard').then(m => ({ default: m.ComplianceDashboard }))); +const BackupSettings = lazy(() => import('./components/BackupSettings').then(m => ({ default: m.BackupSettings }))); const TIMEOUT_MS = 30000; const KYC_LARGE_TRANSACTION_LIMIT = 1000; @@ -930,12 +933,16 @@ function App() { {/* AMM Pool Browser */} - + }> + + {/* Account Recovery */} - + }> + + {/* Settings Sections Tabs */}

Advanced Features

@@ -976,6 +983,9 @@ function App() { {activeSettingsSection === 'multisig' && ( + }> + + @@ -983,6 +993,9 @@ function App() { )} {activeSettingsSection === 'kyc' && ( + }> + + @@ -1105,13 +1118,18 @@ function App() { )} {showComplianceDashboard && ( + }> + setShowComplianceDashboard(false)} /> + setShowComplianceDashboard(false)} /> )} {showBackupSettings && ( - setShowBackupSettings(false)} /> + }> + setShowBackupSettings(false)} /> + )} diff --git a/frontend/src/utils/animations.js b/frontend/src/utils/animations.js deleted file mode 100644 index 4098282..0000000 --- a/frontend/src/utils/animations.js +++ /dev/null @@ -1,19 +0,0 @@ -// Respects prefers-reduced-motion: pass `reducedMotion` (boolean) to disable transitions -export const makeVariants = (reducedMotion) => ({ - fadeSlide: { - hidden: { opacity: 0, y: reducedMotion ? 0 : 16 }, - visible: { opacity: 1, y: 0, transition: { duration: reducedMotion ? 0 : 0.3, ease: 'easeOut' } }, - exit: { opacity: 0, y: reducedMotion ? 0 : -8, transition: { duration: reducedMotion ? 0 : 0.2 } }, - }, - pop: { - hidden: { opacity: 0, scale: reducedMotion ? 1 : 0.92 }, - visible: { opacity: 1, scale: 1, transition: { duration: reducedMotion ? 0 : 0.25, ease: 'easeOut' } }, - exit: { opacity: 0, scale: reducedMotion ? 1 : 0.95, transition: { duration: reducedMotion ? 0 : 0.15 } }, - }, - stagger: { - visible: { transition: { staggerChildren: reducedMotion ? 0 : 0.07 } }, - }, -}); - -export const tapScale = (reducedMotion) => - reducedMotion ? {} : { whileTap: { scale: 0.96 }, whileHover: { scale: 1.02 } }; diff --git a/frontend/src/utils/animations.ts b/frontend/src/utils/animations.ts new file mode 100644 index 0000000..69783d0 --- /dev/null +++ b/frontend/src/utils/animations.ts @@ -0,0 +1,35 @@ +import type { Variants } from 'framer-motion'; + +interface AnimationVariants { + fadeSlide: Variants; + pop: Variants; + stagger: Variants; +} + +interface TapScaleResult { + whileTap?: { scale: number }; + whileHover?: { scale: number }; +} + +// Respects prefers-reduced-motion: pass `reducedMotion` (boolean) to disable transitions +export function makeVariants(reducedMotion: boolean): AnimationVariants { + return { + fadeSlide: { + hidden: { opacity: 0, y: reducedMotion ? 0 : 16 }, + visible: { opacity: 1, y: 0, transition: { duration: reducedMotion ? 0 : 0.3, ease: 'easeOut' } }, + exit: { opacity: 0, y: reducedMotion ? 0 : -8, transition: { duration: reducedMotion ? 0 : 0.2 } }, + }, + pop: { + hidden: { opacity: 0, scale: reducedMotion ? 1 : 0.92 }, + visible: { opacity: 1, scale: 1, transition: { duration: reducedMotion ? 0 : 0.25, ease: 'easeOut' } }, + exit: { opacity: 0, scale: reducedMotion ? 1 : 0.95, transition: { duration: reducedMotion ? 0 : 0.15 } }, + }, + stagger: { + visible: { transition: { staggerChildren: reducedMotion ? 0 : 0.07 } }, + }, + }; +} + +export function tapScale(reducedMotion: boolean): TapScaleResult { + return reducedMotion ? {} : { whileTap: { scale: 0.96 }, whileHover: { scale: 1.02 } }; +} diff --git a/frontend/src/utils/errorLogger.js b/frontend/src/utils/errorLogger.js deleted file mode 100644 index f6e4e9a..0000000 --- a/frontend/src/utils/errorLogger.js +++ /dev/null @@ -1,22 +0,0 @@ -// Lightweight error logger — logs locally and can be wired to an external service -const logs = []; - -export function logError(error, info = {}) { - const entry = { - message: error?.message || String(error), - stack: error?.stack, - timestamp: new Date().toISOString(), - ...info, - }; - logs.push(entry); - console.error('[ErrorLogger]', entry); - - // Hook for external service (e.g. Sentry): window.__reportError?.(entry) - if (typeof window.__reportError === 'function') { - window.__reportError(entry); - } -} - -export function getLogs() { - return [...logs]; -} diff --git a/frontend/src/utils/errorLogger.ts b/frontend/src/utils/errorLogger.ts new file mode 100644 index 0000000..2857f25 --- /dev/null +++ b/frontend/src/utils/errorLogger.ts @@ -0,0 +1,35 @@ +interface ErrorEntry { + message: string; + stack?: string; + timestamp: string; + [key: string]: unknown; +} + +declare global { + interface Window { + __reportError?: (entry: ErrorEntry) => void; + } +} + +// Lightweight error logger — logs locally and can be wired to an external service +const logs: ErrorEntry[] = []; + +export function logError(error: unknown, info: Record = {}): void { + const entry: ErrorEntry = { + message: (error as Error)?.message || String(error), + stack: (error as Error)?.stack, + timestamp: new Date().toISOString(), + ...info, + }; + logs.push(entry); + console.error('[ErrorLogger]', entry); + + // Hook for external service (e.g. Sentry): window.__reportError?.(entry) + if (typeof window.__reportError === 'function') { + window.__reportError(entry); + } +} + +export function getLogs(): ErrorEntry[] { + return [...logs]; +} diff --git a/frontend/src/utils/errorMessages.js b/frontend/src/utils/errorMessages.ts similarity index 81% rename from frontend/src/utils/errorMessages.js rename to frontend/src/utils/errorMessages.ts index e2b0877..3529eb5 100644 --- a/frontend/src/utils/errorMessages.js +++ b/frontend/src/utils/errorMessages.ts @@ -1,4 +1,25 @@ -const ERROR_MAP = [ +interface ErrorMatch { + match: RegExp; + message: string; +} + +interface ApiError { + response?: { + data?: { + extras?: { + result_codes?: { + transaction?: string; + operations?: string[]; + }; + }; + error?: string; + }; + }; + code?: string; + message?: string; +} + +const ERROR_MAP: ErrorMatch[] = [ { match: /insufficient balance/i, message: 'Insufficient balance to complete this payment.' }, { match: /no account found|account not found|404/i, message: 'Destination account does not exist on the Stellar network.' }, { match: /ECONNABORTED|ERR_NETWORK/i, message: 'Connection timed out — please check your internet connection.' }, @@ -8,7 +29,7 @@ const ERROR_MAP = [ { match: /tx_failed/i, message: 'Transaction was rejected by the Stellar network.' }, ]; -const STELLAR_RESULT_CODES = { +const STELLAR_RESULT_CODES: Record = { // Transaction result codes tx_success: 'Transaction completed successfully.', tx_failed: 'Transaction failed.', @@ -41,9 +62,11 @@ const STELLAR_RESULT_CODES = { op_not_supported: 'Operation type is not supported.', }; -export function getFriendlyError(error) { +export function getFriendlyError(error: unknown): string { + const err = error as ApiError; + // Check for Stellar SDK result codes first - const resultCodes = error?.response?.data?.extras?.result_codes; + const resultCodes = err?.response?.data?.extras?.result_codes; if (resultCodes) { if (resultCodes.transaction) { const txCode = resultCodes.transaction; @@ -60,12 +83,12 @@ export function getFriendlyError(error) { } // Handle axios timeout (ECONNABORTED) and network errors (ERR_NETWORK) by error code - if (error?.code === 'ECONNABORTED' || error?.code === 'ERR_NETWORK') { + if (err?.code === 'ECONNABORTED' || err?.code === 'ERR_NETWORK') { return 'Connection timed out — please check your internet connection.'; } // Fall back to string matching - const raw = error?.response?.data?.error || error?.message || String(error); + const raw = err?.response?.data?.error || err?.message || String(error); console.error('[Stellar Error]', raw); const match = ERROR_MAP.find(e => e.match.test(raw)); return match ? match.message : `Something went wrong: ${raw}`; diff --git a/frontend/src/utils/formatBalance.js b/frontend/src/utils/formatBalance.ts similarity index 53% rename from frontend/src/utils/formatBalance.js rename to frontend/src/utils/formatBalance.ts index 7a71982..b07daa5 100644 --- a/frontend/src/utils/formatBalance.js +++ b/frontend/src/utils/formatBalance.ts @@ -1,14 +1,8 @@ const MAX_DECIMALS = 7; -/** - * Format a Stellar balance value for display. - * - Adds thousand separators - * - Limits to 7 decimal places (Stellar precision) - * - Handles very small and very large numbers - */ -export function formatBalance(value, decimals = MAX_DECIMALS) { +export function formatBalance(value: string | number | null | undefined, decimals: number = MAX_DECIMALS): string { if (value === null || value === undefined || value === '') return '—'; - const num = parseFloat(value); + const num = parseFloat(String(value)); if (isNaN(num)) return String(value); // Very small non-zero: show in fixed notation with max precision @@ -20,10 +14,7 @@ export function formatBalance(value, decimals = MAX_DECIMALS) { }); } -/** - * Format a balance with its asset label, e.g. "1,234.5670000 XLM" - */ -export function formatBalanceWithAsset(balance, asset) { +export function formatBalanceWithAsset(balance: string | number | null | undefined, asset?: string): string { const formatted = formatBalance(balance); return asset ? `${formatted} ${asset}` : formatted; } diff --git a/frontend/src/utils/searchHighlighter.js b/frontend/src/utils/searchHighlighter.ts similarity index 66% rename from frontend/src/utils/searchHighlighter.js rename to frontend/src/utils/searchHighlighter.ts index 66afacb..a6e2cd4 100644 --- a/frontend/src/utils/searchHighlighter.js +++ b/frontend/src/utils/searchHighlighter.ts @@ -1,33 +1,43 @@ -/** - * Highlights search terms in text - * @param {string} text - The text to highlight - * @param {string} query - The search query - * @returns {string} HTML string with highlighted terms - */ -export function highlightSearchTerms(text, query) { +export interface Transaction { + id?: string; + memo?: string; + source?: string; + destination?: string; + type?: string; + status?: string; + created_at?: string; + amount?: string; +} + +export interface SearchCriteria { + query?: string; + type?: string; + status?: string; + dateFrom?: string; + dateTo?: string; + amountMin?: string; + amountMax?: string; + address?: string; +} + +export function highlightSearchTerms(text: string, query: string): string { if (!query || !text) return text; - + const regex = new RegExp(`(${query})`, 'gi'); return text.replace(regex, '$1'); } -/** - * Filters transactions based on search criteria - * @param {Array} transactions - Array of transactions - * @param {Object} criteria - Search criteria - * @returns {Array} Filtered transactions - */ -export function filterTransactions(transactions, criteria) { +export function filterTransactions(transactions: Transaction[], criteria: SearchCriteria): Transaction[] { return transactions.filter(tx => { // Text search if (criteria.query) { const searchText = criteria.query.toLowerCase(); - const matchesQuery = + const matchesQuery = tx.id?.toLowerCase().includes(searchText) || tx.memo?.toLowerCase().includes(searchText) || tx.source?.toLowerCase().includes(searchText) || tx.destination?.toLowerCase().includes(searchText); - + if (!matchesQuery) return false; } @@ -43,13 +53,13 @@ export function filterTransactions(transactions, criteria) { // Date range filter if (criteria.dateFrom) { - const txDate = new Date(tx.created_at); + const txDate = new Date(tx.created_at ?? ''); const fromDate = new Date(criteria.dateFrom); if (txDate < fromDate) return false; } if (criteria.dateTo) { - const txDate = new Date(tx.created_at); + const txDate = new Date(tx.created_at ?? ''); const toDate = new Date(criteria.dateTo); toDate.setHours(23, 59, 59, 999); if (txDate > toDate) return false; @@ -57,22 +67,22 @@ export function filterTransactions(transactions, criteria) { // Amount range filter if (criteria.amountMin) { - const amount = parseFloat(tx.amount); + const amount = parseFloat(tx.amount ?? '0'); if (amount < parseFloat(criteria.amountMin)) return false; } if (criteria.amountMax) { - const amount = parseFloat(tx.amount); + const amount = parseFloat(tx.amount ?? '0'); if (amount > parseFloat(criteria.amountMax)) return false; } // Address filter if (criteria.address) { const addressLower = criteria.address.toLowerCase(); - const matchesAddress = + const matchesAddress = tx.source?.toLowerCase().includes(addressLower) || tx.destination?.toLowerCase().includes(addressLower); - + if (!matchesAddress) return false; } diff --git a/frontend/src/utils/validateAmount.js b/frontend/src/utils/validateAmount.ts similarity index 85% rename from frontend/src/utils/validateAmount.js rename to frontend/src/utils/validateAmount.ts index e7837e5..c77113f 100644 --- a/frontend/src/utils/validateAmount.js +++ b/frontend/src/utils/validateAmount.ts @@ -3,7 +3,7 @@ const MAX_DECIMALS = 7; const BASE_FEE = 0.00001; const MIN_RESERVE = 1; -export function validateAmount(value, availableBalance) { +export function validateAmount(value: string, availableBalance: number | null): string | null { if (!value) return null; if (/e/i.test(value)) return 'Scientific notation is not allowed'; const num = parseFloat(value); @@ -19,7 +19,7 @@ export function validateAmount(value, availableBalance) { return null; } -export function formatAmount(value) { +export function formatAmount(value: string): string { // Remove leading zeros (but keep "0." prefix) return value.replace(/^0+(?=\d)/, ''); } diff --git a/frontend/src/utils/validateStellarAddress.js b/frontend/src/utils/validateStellarAddress.js deleted file mode 100644 index 8e72cd2..0000000 --- a/frontend/src/utils/validateStellarAddress.js +++ /dev/null @@ -1,5 +0,0 @@ -import { StrKey } from '@stellar/stellar-sdk'; - -export function isValidStellarAddress(address) { - return typeof address === 'string' && StrKey.isValidEd25519PublicKey(address); -} diff --git a/frontend/src/utils/validateStellarAddress.ts b/frontend/src/utils/validateStellarAddress.ts new file mode 100644 index 0000000..6123308 --- /dev/null +++ b/frontend/src/utils/validateStellarAddress.ts @@ -0,0 +1,5 @@ +import { StrKey } from '@stellar/stellar-sdk'; + +export function isValidStellarAddress(address: string): boolean { + return StrKey.isValidEd25519PublicKey(address); +} diff --git a/frontend/src/utils/webVitals.js b/frontend/src/utils/webVitals.ts similarity index 68% rename from frontend/src/utils/webVitals.js rename to frontend/src/utils/webVitals.ts index 177769c..8f120e5 100644 --- a/frontend/src/utils/webVitals.js +++ b/frontend/src/utils/webVitals.ts @@ -1,7 +1,22 @@ +import type { Metric } from 'web-vitals'; import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals'; +interface VitalEntry { + name: string; + value: number; + rating: string; + budget: number | undefined; + over: boolean; +} + +declare global { + interface Window { + __reportVital?: (entry: VitalEntry) => void; + } +} + // Performance budgets — alert if exceeded -const BUDGETS = { +const BUDGETS: Record = { CLS: 0.1, FCP: 1800, INP: 200, @@ -9,11 +24,11 @@ const BUDGETS = { TTFB: 800, }; -function report(metric) { +function report(metric: Metric): void { const budget = BUDGETS[metric.name]; const over = budget != null && metric.value > budget; - const entry = { + const entry: VitalEntry = { name: metric.name, value: Math.round(metric.value), rating: metric.rating, // 'good' | 'needs-improvement' | 'poor' @@ -32,7 +47,7 @@ function report(metric) { window.__reportVital?.(entry); } -export function initWebVitals() { +export function initWebVitals(): void { onCLS(report); onFCP(report); onINP(report); diff --git a/frontend/tests/lazy-loading.test.js b/frontend/tests/lazy-loading.test.js new file mode 100644 index 0000000..1529f80 --- /dev/null +++ b/frontend/tests/lazy-loading.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appSource = readFileSync(resolve(__dirname, '../src/App.jsx'), 'utf-8'); + +const HEAVY_COMPONENTS = [ + 'AMMPoolBrowser', + 'AccountRecovery', + 'MultiSigTransactions', + 'KYCForm', + 'ComplianceDashboard', + 'BackupSettings', +]; + +describe('code splitting — heavy components', () => { + for (const name of HEAVY_COMPONENTS) { + it(`${name} is not statically imported`, () => { + // A static import would look like: import { Foo } from './components/Foo' + const staticImportPattern = new RegExp(`^import\\s+.*\\b${name}\\b.*from`, 'm'); + expect(appSource).not.toMatch(staticImportPattern); + }); + + it(`${name} uses React.lazy()`, () => { + const lazyPattern = new RegExp(`lazy\\(.*import\\(.*${name}.*\\)`, 's'); + expect(appSource).toMatch(lazyPattern); + }); + } + + it('lazy components are wrapped in Suspense at each render site', () => { + // Count Suspense occurrences — at least one per heavy component + const suspenseCount = (appSource.match(/ { + // Confirms the modules exist and export the expected named export + const modules = await Promise.all( + HEAVY_COMPONENTS.map(name => + import(`../src/components/${name}.jsx`).then(m => ({ name, exported: name in m })) + ) + ); + for (const { name, exported } of modules) { + expect(exported, `${name} should export a named member`).toBe(true); + } + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..198c0e0 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowJs": true, + "checkJs": false + }, + "include": ["src"] +}