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
84 changes: 54 additions & 30 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,19 @@ jobs:
retention-days: 7

# ── Bundle size budget ────────────────────────────────────────────────────
# Fails the PR if the gzipped main entry chunk exceeds BUDGET_KB.
# Fails the PR if any chunk or total bundle exceeds its gzip budget.
# Add the label "skip-bundle-check" to the PR to bypass (e.g. intentional
# large dependency bump that will be addressed in a follow-up).
bundle-size:
name: Bundle Size Budget
runs-on: ubuntu-latest
needs: [lint, type-check, test]
env:
# Gzip budget for the main entry chunk in kilobytes.
BUDGET_KB: 500
# Per-chunk gzip budgets in kilobytes.
BUDGET_MAIN_KB: 50 # app entry chunk (index-*.js)
BUDGET_VENDOR_KB: 500 # any single vendor chunk
BUDGET_STELLAR_KB: 800 # stellar-sdk chunk (large, but isolated)
BUDGET_TOTAL_KB: 2000 # total gzipped JS across all chunks
steps:
- uses: actions/checkout@v6

Expand All @@ -228,48 +231,69 @@ jobs:
- name: Build production bundle
run: npm run build

- name: Measure gzipped bundle size
- name: Measure gzipped bundle sizes
id: measure
run: |
# Find the main entry JS chunk (assets/index-*.js or assets/main-*.js)
MAIN_CHUNK=$(ls dist/assets/index-*.js dist/assets/main-*.js 2>/dev/null | head -n 1)
if [ -z "$MAIN_CHUNK" ]; then
echo "::error::Could not locate main entry chunk in dist/assets/"
exit 1
fi
GZIP_BYTES=$(gzip -c "$MAIN_CHUNK" | wc -c)
GZIP_KB=$(( GZIP_BYTES / 1024 ))
echo "main_chunk=$MAIN_CHUNK" >> "$GITHUB_OUTPUT"
echo "gzip_kb=$GZIP_KB" >> "$GITHUB_OUTPUT"
echo "budget_kb=$BUDGET_KB" >> "$GITHUB_OUTPUT"
set -euo pipefail

echo "### Bundle Size Report" >> "$GITHUB_STEP_SUMMARY"
echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Main chunk | \`$MAIN_CHUNK\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Gzipped size | **${GZIP_KB} KB** |" >> "$GITHUB_STEP_SUMMARY"
echo "| Budget | ${BUDGET_KB} KB |" >> "$GITHUB_STEP_SUMMARY"
if [ "$GZIP_KB" -gt "$BUDGET_KB" ]; then
echo "| Status | ❌ Over budget by $(( GZIP_KB - BUDGET_KB )) KB |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Chunk | Gzip (KB) | Budget (KB) | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-----------|-------------|--------|" >> "$GITHUB_STEP_SUMMARY"

FAILED=0
TOTAL_KB=0

for CHUNK in dist/assets/*.js; do
[ -f "$CHUNK" ] || continue
CHUNK_NAME=$(basename "$CHUNK")
GZIP_KB=$(( $(gzip -c "$CHUNK" | wc -c) / 1024 ))
TOTAL_KB=$(( TOTAL_KB + GZIP_KB ))

# Determine per-chunk budget
if echo "$CHUNK_NAME" | grep -qE '^(index|main)-'; then
BUDGET=$BUDGET_MAIN_KB
elif echo "$CHUNK_NAME" | grep -q 'stellar-sdk'; then
BUDGET=$BUDGET_STELLAR_KB
else
BUDGET=$BUDGET_VENDOR_KB
fi

if [ "$GZIP_KB" -gt "$BUDGET" ]; then
echo "| \`$CHUNK_NAME\` | **${GZIP_KB}** | ${BUDGET} | ❌ over by $(( GZIP_KB - BUDGET )) KB |" >> "$GITHUB_STEP_SUMMARY"
echo "::warning::$CHUNK_NAME is ${GZIP_KB} KB gzipped (budget: ${BUDGET} KB)."
FAILED=$(( FAILED + 1 ))
else
echo "| \`$CHUNK_NAME\` | ${GZIP_KB} | ${BUDGET} | ✅ |" >> "$GITHUB_STEP_SUMMARY"
fi
done

echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "$TOTAL_KB" -gt "$BUDGET_TOTAL_KB" ]; then
echo "**Total JS (gzip): ${TOTAL_KB} KB — ❌ exceeds total budget of ${BUDGET_TOTAL_KB} KB**" >> "$GITHUB_STEP_SUMMARY"
echo "::warning::Total JS is ${TOTAL_KB} KB gzipped (budget: ${BUDGET_TOTAL_KB} KB)."
FAILED=$(( FAILED + 1 ))
else
echo "| Status | ✅ Within budget ($(( BUDGET_KB - GZIP_KB )) KB headroom) |" >> "$GITHUB_STEP_SUMMARY"
echo "**Total JS (gzip): ${TOTAL_KB} KB — ✅ within total budget of ${BUDGET_TOTAL_KB} KB**" >> "$GITHUB_STEP_SUMMARY"
fi

- name: Enforce budget (skippable via label)
# Skip enforcement when the PR carries the override label.
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
echo "total_kb=$TOTAL_KB" >> "$GITHUB_OUTPUT"

- name: Enforce budgets (skippable via label)
if: >
!contains(
github.event.pull_request.labels.*.name,
'skip-bundle-check'
)
run: |
GZIP_KB=${{ steps.measure.outputs.gzip_kb }}
BUDGET_KB=${{ steps.measure.outputs.budget_kb }}
if [ "$GZIP_KB" -gt "$BUDGET_KB" ]; then
echo "::error::Main bundle is ${GZIP_KB} KB gzipped — exceeds the ${BUDGET_KB} KB budget."
FAILED=${{ steps.measure.outputs.failed }}
if [ "$FAILED" -gt "0" ]; then
echo "::error::$FAILED chunk(s) exceeded their size budget. See the step summary for details."
echo "::error::To bypass, add the 'skip-bundle-check' label to this PR and document the reason."
exit 1
fi
echo "✅ Main bundle is ${GZIP_KB} KB gzipped (budget: ${BUDGET_KB} KB)."
echo "✅ All chunks within budget. Total JS: ${{ steps.measure.outputs.total_kb }} KB gzipped."

- name: Upload dist artifact
uses: actions/upload-artifact@v7
Expand Down
2 changes: 1 addition & 1 deletion src/components/dashboard/WalletConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
readSecurityAuditLog,
getSessionSecurityPosture,
} from '../../lib/wallet/security'
import { Card } from './Card'
import Card from './Card'

interface WalletDef {
id: string
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useStore } from '../../lib/store';
import CopyableValue from '../dashboard/CopyableValue';
import { NETWORKS, updateCustomNetworkConfig, switchToCustomProfile, loadCustomNetworkProfiles } from '../../lib/stellar';
import { getActiveProfile } from '../../lib/userPreferences';
import { preloadTab } from '../../hooks/usePreload';

const SESSION_API_KEY = 'stellar_custom_api_key';

Expand Down Expand Up @@ -418,6 +419,7 @@ export default function Sidebar({ isMobile = false }: SidebarProps) {
e.currentTarget.style.background = 'var(--bg-hover)'
e.currentTarget.style.color = 'var(--text-primary)'
}
if (item.id) preloadTab(item.id)
}}
onMouseLeave={e => {
if (!isActive && !isDisabled) {
Expand Down
3 changes: 1 addition & 2 deletions src/components/preferences/UserPreferences.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import React, { useState } from 'react'
import { usePreferences } from '../../hooks/usePreferences'
import AddressBook from './AddressBook'
import ThemeSettings from './ThemeSettings'
import AccessibilitySettings from './AccessibilitySettings'
import NotificationPreferences from '../notifications/NotificationPreferences'
import AccessibilitySettings from '../accessibility/AccessibilitySettings'
import { showTestNotification } from '../../utils/offline'
import { Bell, Globe2 } from 'lucide-react'
import { useI18nContext } from '../I18nProvider.jsx'
Expand Down
61 changes: 61 additions & 0 deletions src/hooks/usePreload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* usePreload — prefetch a lazy-loaded tab chunk when the user hovers over its nav link.
*
* Usage:
* const { preload } = usePreload()
* <button onMouseEnter={() => preload('overview')} />
*/

const TAB_LOADERS: Record<string, () => Promise<unknown>> = {
overview: () => import('../components/dashboard/Overview'),
account: () => import('../components/dashboard/Account'),
transactions: () => import('../components/dashboard/Transactions'),
contracts: () => import('../components/dashboard/Contracts'),
network: () => import('../components/dashboard/NetworkStats'),
builder: () => import('../components/dashboard/Builder'),
faucet: () => import('../components/dashboard/Faucet'),
compare: () => import('../components/dashboard/AccountComparison'),
wallet: () => import('../components/dashboard/WalletConnect'),
signer: () => import('../components/dashboard/TransactionSigner'),
portfolio: () => import('../components/dashboard/PortfolioValue'),
txBuilder: () => import('../components/dashboard/TransactionBuilder'),
contractInteraction: () => import('../components/dashboard/ContractInteraction'),
contractABI: () => import('../components/dashboard/ContractABI'),
dex: () => import('../components/dashboard/DEXExplorer'),
pathExplorer: () => import('../components/dashboard/PathExplorer'),
explorers: () => import('../components/dashboard/ExplorerEmbed'),
realtime: () => import('../components/dashboard/RealTimeLedger'),
charts: () => import('../components/dashboard/ChartsTab'),
assets: () => import('../components/assets'),
multisig: () => import('../components/multisig'),
analytics: () => import('../components/dashboard/Analytics'),
systemHealth: () => import('../components/dashboard/SystemHealth'),
performance: () => import('../components/dashboard/PerformanceMonitor'),
settings: () => import('../components/dashboard/Settings'),
audit: () => import('../components/dashboard/AuditLog'),
anchors: () => import('../components/anchors'),
search: () => import('../components/dashboard/AdvancedSearch'),
cacheStats: () => import('../components/dashboard/CacheStats'),
liveActivity: () => import('../components/dashboard/LiveActivityFeed'),
claimableBalances: () => import('../components/dashboard/ClaimableBalances'),
dataExport: () => import('../components/dashboard/DataExport'),
}

const preloaded = new Set<string>()

/** Trigger the dynamic import for a tab so the browser fetches the chunk early. */
export function preloadTab(tab: string): void {
if (preloaded.has(tab)) return
const loader = TAB_LOADERS[tab]
if (!loader) return
preloaded.add(tab)
loader().catch(() => {
// Non-fatal — chunk will still load when the user navigates
preloaded.delete(tab)
})
}

/** Hook that returns a stable `preload` callback for use in event handlers. */
export function usePreload() {
return { preload: preloadTab }
}
4 changes: 4 additions & 0 deletions src/lib/payments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Thin shim: re-exports path-payment utilities from stellar.ts under the names
// expected by PathExplorer.tsx.
export type { PaymentPathRecord as PathPaymentPath, FetchPaymentPathsParams } from './stellar'
export { fetchPaymentPaths as fetchPathPayments } from './stellar'
8 changes: 8 additions & 0 deletions src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2700,3 +2700,11 @@ export default {
getCacheStats,
StellarSdk,
};

// ── Compatibility aliases ──────────────────────────────────────────────────
/** @deprecated Use {@link fetchAccount} directly. */
export type AccountDetails = StellarSdk.Horizon.AccountResponse
/** @deprecated Use {@link fetchAccount} directly. */
export const fetchAccountDetails = fetchAccount
/** @deprecated Use {@link isValidEd25519PublicKey} directly. */
export const isPublicKey = isValidEd25519PublicKey
2 changes: 1 addition & 1 deletion src/utils/offline.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { enqueueOfflineOp, getOfflineQueue, dequeueOfflineOp } from '../lib/storage.js';
import { retryManager } from '../lib/errorHandling/RetryManager.ts';
import { createLogger } from './logger.js';
import { createLogger } from './logger';

const logger = createLogger('offline');

Expand Down
33 changes: 32 additions & 1 deletion src/utils/stateSync.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,35 @@ export function decodeSessionFromHash(hash) {
export function buildShareableURL(state) {
const base = window.location.origin + window.location.pathname;
return base + encodeSessionToHash(state);
}
}

// syncState / onStateChange — used by store.ts for cross-tab persistence.
// syncState persists a keyed slice to localStorage; onStateChange listens for
// changes from other tabs via the storage event.

export async function syncState(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (_) {
// Quota exceeded or private mode — fail silently.
}
}

const _stateChangeListeners = new Set()

export function onStateChange(callback) {
_stateChangeListeners.add(callback)
const handler = (e) => {
if (e.key && _stateChangeListeners.has(callback)) {
try {
const value = e.newValue ? JSON.parse(e.newValue) : null
callback(e.key, value)
} catch (_) {}
}
}
window.addEventListener('storage', handler)
return () => {
window.removeEventListener('storage', handler)
_stateChangeListeners.delete(callback)
}
}
34 changes: 27 additions & 7 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,34 @@ export default defineConfig({
// Security headers plugin (#106): injects HTTP security headers in dev server
{
name: 'copy-sw',
// During build, Vite processes public/ automatically — sw.js placed in
// public/ is already handled. This plugin just confirms it's included.
generateBundle() {
// sw.js lives in /public and is emitted by Vite's publicDir handling.
// Nothing extra needed; this hook serves as documentation.
},
},
],

build: {
// Target modern browsers for smaller output (ES2020 gives async/await, optional chaining)
target: 'es2020',

// Produce a sourcemap so Lighthouse and DevTools can audit the SW
sourcemap: true,

// Split CSS per chunk so each lazy-loaded route only loads its own styles
cssCodeSplit: true,

// Use esbuild for fast, effective minification
minify: 'esbuild',

// Inline assets smaller than 4 KB as base64 to save round trips
assetsInlineLimit: 4096,

// esbuild minify options: drop console/debugger in production
esbuildOptions: {
drop: ['debugger'],
legalComments: 'none',
},

rollupOptions: {
output: {
// Deterministic chunk names for subresource integrity (#106)
Expand All @@ -53,8 +68,10 @@ export default defineConfig({
if (id.includes('recharts')) return 'charts-vendor'
if (id.includes('lucide-react')) return 'icons-vendor'
if (id.includes('i18next')) return 'i18n'
if (id.includes('react')) return 'react-vendor'
if (id.includes('react-router-dom') || id.includes('react-router')) return 'react-vendor'
if (id.includes('react-dom') || id.includes('/react/')) return 'react-vendor'
if (id.includes('date-fns')) return 'date-vendor'
if (id.includes('zustand')) return 'react-vendor'

return 'vendor'
},
Expand All @@ -65,14 +82,17 @@ export default defineConfig({
// Allow the dev server to serve sw.js at the root scope
server: {
headers: {
// Required for SharedArrayBuffer (not needed here) and to allow the SW
// to intercept all requests under origin.
'Service-Worker-Allowed': '/',
},
},

// Allow .js imports to resolve to .ts files (TypeScript migration compatibility)
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs'],
},

// Optimise deps that are CommonJS
optimizeDeps: {
include: ['react', 'react-dom'],
},
});
})
Loading