diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7473748..10a6a53 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,6 +27,11 @@ jobs:
- run: pnpm format:check
- run: pnpm build
+ # Fails when the initial load, the Stellar scanner worker, or any
+ # lazy-loaded route/chain chunk grows past its budget. See
+ # scripts/bundle-budget-gate.mjs for what each budget covers and why.
+ - run: pnpm build:budget-check
+
# Fails when a new critical (or more than the accepted number of high)
# production dependency finding appears. See docs/SECURITY_AUDIT.md.
- run: pnpm audit:gate
diff --git a/package.json b/package.json
index ec99e1c..f793f32 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"format:check": "prettier --check .",
"audit:prod": "pnpm audit --prod",
"audit:gate": "node scripts/audit-gate.mjs",
+ "build:budget-check": "node scripts/bundle-budget-gate.mjs",
"prepare": "husky",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
diff --git a/scripts/bundle-budget-gate.mjs b/scripts/bundle-budget-gate.mjs
new file mode 100644
index 0000000..871bb4a
--- /dev/null
+++ b/scripts/bundle-budget-gate.mjs
@@ -0,0 +1,172 @@
+#!/usr/bin/env node
+// CI gate for production bundle size budgets (Wave 10, #219).
+//
+// Reads `dist/` after `pnpm build` and checks three things:
+// 1. Initial load — every chunk referenced synchronously from
+// dist/index.html (the `';
+ expect(extractInitialLoadPaths(html)).toEqual(['/assets/index-ABC123.js']);
+ });
+
+ it('finds modulepreload links alongside the entry script', () => {
+ const html = [
+ '',
+ '',
+ ].join('\n');
+ expect(extractInitialLoadPaths(html)).toEqual([
+ '/assets/vendor-XYZ.js',
+ '/assets/index-ABC.js',
+ ]);
+ });
+
+ it('ignores cross-origin scripts (e.g. analytics)', () => {
+ const html = [
+ '',
+ '',
+ ].join('\n');
+ expect(extractInitialLoadPaths(html)).toEqual(['/assets/index-ABC.js']);
+ });
+
+ it('returns an empty array when there is no module entry', () => {
+ expect(extractInitialLoadPaths('')).toEqual([]);
+ });
+});
+
+describe('checkBudgets', () => {
+ let distDir;
+
+ async function writeFixture({ entryBytes = 100, workerBytes = 100, extraChunks = {} } = {}) {
+ distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-'));
+ await mkdir(path.join(distDir, 'assets'), { recursive: true });
+ await writeFile(
+ path.join(distDir, 'index.html'),
+ '',
+ );
+ await writeFile(path.join(distDir, 'assets', 'index-ABC.js'), 'x'.repeat(entryBytes));
+ await writeFile(
+ path.join(distDir, 'assets', 'stellar-scanner.worker-XYZ.js'),
+ 'x'.repeat(workerBytes),
+ );
+ for (const [name, bytes] of Object.entries(extraChunks)) {
+ await writeFile(path.join(distDir, 'assets', name), 'x'.repeat(bytes));
+ }
+ }
+
+ afterEach(async () => {
+ if (distDir) await rm(distDir, { recursive: true, force: true });
+ });
+
+ it('passes with no findings when everything is under budget', async () => {
+ await writeFixture({ extraChunks: { 'Vault-abc123.js': 1000 } });
+ const findings = await checkBudgets({ distDir, log: () => {} });
+ expect(findings).toEqual([]);
+ });
+
+ it('flags an over-budget initial load', async () => {
+ await writeFixture({ entryBytes: INITIAL_LOAD_BUDGET_BYTES + 1 });
+ const findings = await checkBudgets({ distDir, log: () => {} });
+ expect(findings).toEqual([expect.stringContaining('Initial load')]);
+ });
+
+ it('flags an over-budget scanner worker', async () => {
+ await writeFixture({ workerBytes: WORKER_BUDGET_BYTES + 1 });
+ const findings = await checkBudgets({ distDir, log: () => {} });
+ expect(findings).toEqual([expect.stringContaining('stellar-scanner.worker')]);
+ });
+
+ it('flags an over-budget lazy chunk', async () => {
+ await writeFixture({
+ extraChunks: { 'Portfolio-huge123.js': PER_CHUNK_BUDGET_BYTES + 1 },
+ });
+ const findings = await checkBudgets({ distDir, log: () => {} });
+ expect(findings).toEqual([expect.stringContaining('Portfolio-huge123.js')]);
+ });
+
+ it('does not double-count the entry chunk as a lazy chunk', async () => {
+ await writeFixture({ entryBytes: PER_CHUNK_BUDGET_BYTES + 1 });
+ const findings = await checkBudgets({ distDir, log: () => {} });
+ // Over the (much larger) initial-load budget only if huge enough to
+ // also exceed INITIAL_LOAD_BUDGET_BYTES; here it shouldn't be flagged
+ // as a *per-chunk* violation under its own entry filename.
+ expect(findings.some((f) => f.includes('assets/index-ABC.js'))).toBe(false);
+ });
+
+ it('throws BundleBudgetError when the scanner worker chunk is missing', async () => {
+ distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-'));
+ await mkdir(path.join(distDir, 'assets'), { recursive: true });
+ await writeFile(
+ path.join(distDir, 'index.html'),
+ '',
+ );
+ await writeFile(path.join(distDir, 'assets', 'index-ABC.js'), 'x');
+
+ await expect(checkBudgets({ distDir, log: () => {} })).rejects.toThrow(BundleBudgetError);
+ });
+
+ it('throws BundleBudgetError when index.html has no module entry', async () => {
+ distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-'));
+ await mkdir(path.join(distDir, 'assets'), { recursive: true });
+ await writeFile(path.join(distDir, 'index.html'), '');
+
+ await expect(checkBudgets({ distDir, log: () => {} })).rejects.toThrow(BundleBudgetError);
+ });
+});
diff --git a/src/App.tsx b/src/App.tsx
index 20fc307..b67bfb6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,27 +1,15 @@
-import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
+import { Suspense, useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Header } from '@/components/Header';
import { AutoSign } from '@/components/AutoSign';
import { TelemetryBanner } from '@/components/TelemetryBanner';
import { OfflineBanner } from '@/components/OfflineBanner';
import { UpdatePrompt } from '@/components/UpdatePrompt';
-import Send from '@/pages/Send';
-import Receive from '@/pages/Receive';
-import Privacy from '@/pages/Privacy';
-import Settings from '@/pages/Settings';
+import { RouteFallback } from '@/components/RouteFallback';
+import { RouteErrorBoundary, LazyPage } from '@/components/RouteErrorBoundary';
import { HelpButton } from '@/components/HelpButton';
-import Vault from '@/pages/Vault';
-import Notifications from '@/pages/Notifications';
import { useNotificationSW } from '@/hooks/useNotificationSW';
import { useOfflineReconcile } from '@/hooks/useOfflineReconcile';
-import Schedule from '@/pages/Schedule';
-import StellarSplit from '@/pages/StellarSplit';
-import Names from '@/pages/Names';
-import NameProfile from '@/pages/NameProfile';
-import NamesAuctions from '@/pages/NamesAuctions';
-import Activity from '@/pages/Activity';
-import Portfolio from '@/pages/Portfolio';
-import Debug from '@/pages/Debug';
import { useChain } from '@/context/ChainContext';
import { useStealthKeys } from '@/context/StealthKeysContext';
import { KeyVault } from '@/vault/KeyVault';
@@ -35,6 +23,27 @@ import { parseStellarQrPayload } from '@/utils/qr';
import { useOfflineQueueStore } from '@/stores/offlineQueueStore';
import { initNameWatchlistSync, cleanupNameWatchlistSync } from '@/store/nameWatchlistStore';
+// Code-split per route so the initial bundle only ships the app shell.
+// Each page (and the chain-specific wallet SDKs it pulls in — see
+// pages/Send.tsx and pages/Receive.tsx) loads on first navigation instead
+// of up front. RouteErrorBoundary + RouteFallback below handle the load.
+// Loaders stay as plain module-level functions (not lazy() itself) so
+// LazyPage can construct a fresh lazy component on retry; see
+// RouteErrorBoundary.tsx for why that distinction matters.
+const loadSend = () => import('@/pages/Send');
+const loadReceive = () => import('@/pages/Receive');
+const loadPrivacy = () => import('@/pages/Privacy');
+const loadVault = () => import('@/pages/Vault');
+const loadNotifications = () => import('@/pages/Notifications');
+const loadSchedule = () => import('@/pages/Schedule');
+const loadStellarSplit = () => import('@/pages/StellarSplit');
+const loadNames = () => import('@/pages/Names');
+const loadNameProfile = () => import('@/pages/NameProfile');
+const loadNamesAuctions = () => import('@/pages/NamesAuctions');
+const loadActivity = () => import('@/pages/Activity');
+const loadPortfolio = () => import('@/pages/Portfolio');
+const loadDebug = () => import('@/pages/Debug');
+
function SessionLock({ onUnlock }: { onUnlock: () => void }) {
const [passphrase, setPassphrase] = useState('');
const [busy, setBusy] = useState(false);
@@ -210,24 +219,28 @@ export function App() {
+ This page failed to load +
++ That can happen after a new version ships while this tab was open, or on a flaky + connection. +
+Loading…
+