Skip to content

Commit 4144510

Browse files
authored
Merge pull request #374 from temma02/feature/issue-46-performance-optimization
perf(ui): code splitting, lazy loading, caching, and performance monitoring
2 parents 9288bfb + 4ef8b1b commit 4144510

8 files changed

Lines changed: 137 additions & 8 deletions

File tree

client/src/lib/api/client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
import { ApiRequestError } from './types';
22

3+
// ---------------------------------------------------------------------------
4+
// Simple in-memory GET cache with TTL
5+
// ---------------------------------------------------------------------------
6+
interface CacheEntry { value: unknown; expiresAt: number }
7+
const cache = new Map<string, CacheEntry>();
8+
9+
export function cachedFetch<T>(
10+
key: string,
11+
ttlMs: number,
12+
fetcher: () => Promise<T>
13+
): Promise<T> {
14+
const hit = cache.get(key);
15+
if (hit && Date.now() < hit.expiresAt) return Promise.resolve(hit.value as T);
16+
return fetcher().then((value) => {
17+
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
18+
return value;
19+
});
20+
}
21+
22+
export function invalidateCache(prefix?: string) {
23+
if (!prefix) { cache.clear(); return; }
24+
for (const key of cache.keys()) if (key.startsWith(prefix)) cache.delete(key);
25+
}
26+
327
const RETRY_STATUSES = new Set([429, 502, 503, 504]);
428
const DEFAULT_RETRIES = 3;
529
const BASE_DELAY_MS = 300;

client/src/lib/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ const config: ClientConfig = {
2626
export const indexerApi = USE_MOCK ? mockIndexerApi : createIndexerApi(config);
2727

2828
export { ApiRequestError } from './types';
29+
export { invalidateCache } from './client';
2930
export type { Event, PagedResponse, TradeSearchResult, UserSearchResult, SearchSuggestion } from './types';
3031
export type { IndexerApi } from './indexer';

client/src/lib/api/indexer.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { apiFetch, type ClientConfig } from './client';
1+
import { apiFetch, cachedFetch, type ClientConfig } from './client';
22
import type { Event, PagedResponse, TradeSearchResult, UserSearchResult, SearchSuggestion } from './types';
33

44
export function createIndexerApi(config: ClientConfig) {
@@ -19,17 +19,18 @@ export function createIndexerApi(config: ClientConfig) {
1919
.filter(([, v]) => v !== undefined)
2020
.map(([k, v]) => [k, String(v)])
2121
).toString();
22-
return get(`/events${qs ? `?${qs}` : ''}`);
22+
const path = `/events${qs ? `?${qs}` : ''}`;
23+
return cachedFetch(path, 30_000, () => get(path));
2324
},
2425

2526
/** GET /events/:id */
2627
getEvent(id: string): Promise<Event> {
27-
return get(`/events/${id}`);
28+
return cachedFetch(`/events/${id}`, 60_000, () => get(`/events/${id}`));
2829
},
2930

3031
/** GET /events/trade/:trade_id */
3132
getTradeEvents(tradeId: number): Promise<Event[]> {
32-
return get(`/events/trade/${tradeId}`);
33+
return cachedFetch(`/events/trade/${tradeId}`, 30_000, () => get(`/events/trade/${tradeId}`));
3334
},
3435

3536
/** GET /search/trades */

client/src/lib/perf.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { browser } from '$app/environment';
2+
3+
export interface Metric { name: string; value: number; rating: 'good' | 'needs-improvement' | 'poor' }
4+
5+
const thresholds: Record<string, [number, number]> = {
6+
LCP: [2500, 4000],
7+
FID: [100, 300],
8+
CLS: [0.1, 0.25],
9+
FCP: [1800, 3000],
10+
TTFB: [800, 1800],
11+
};
12+
13+
function rate(name: string, value: number): Metric['rating'] {
14+
const [good, poor] = thresholds[name] ?? [Infinity, Infinity];
15+
return value <= good ? 'good' : value <= poor ? 'needs-improvement' : 'poor';
16+
}
17+
18+
/** Collect Web Vitals via PerformanceObserver and report via callback */
19+
export function collectWebVitals(onMetric: (m: Metric) => void) {
20+
if (!browser || !('PerformanceObserver' in window)) return;
21+
22+
observe('largest-contentful-paint', (entries) => {
23+
const e = entries.at(-1) as PerformanceEntry & { renderTime?: number; loadTime?: number };
24+
const value = (e.renderTime || e.loadTime) ?? 0;
25+
onMetric({ name: 'LCP', value, rating: rate('LCP', value) });
26+
});
27+
28+
observe('first-input', (entries) => {
29+
const e = entries[0] as PerformanceEntry & { processingStart: number };
30+
const value = e.processingStart - e.startTime;
31+
onMetric({ name: 'FID', value, rating: rate('FID', value) });
32+
});
33+
34+
let clsValue = 0;
35+
observe('layout-shift', (entries) => {
36+
for (const e of entries as (PerformanceEntry & { hadRecentInput: boolean; value: number })[]) {
37+
if (!e.hadRecentInput) clsValue += e.value;
38+
}
39+
onMetric({ name: 'CLS', value: clsValue, rating: rate('CLS', clsValue) });
40+
});
41+
42+
observe('paint', (entries) => {
43+
const fcp = entries.find((e) => e.name === 'first-contentful-paint');
44+
if (fcp) onMetric({ name: 'FCP', value: fcp.startTime, rating: rate('FCP', fcp.startTime) });
45+
});
46+
47+
observe('navigation', (entries) => {
48+
const e = entries[0] as PerformanceNavigationTiming;
49+
const value = e.responseStart - e.requestStart;
50+
onMetric({ name: 'TTFB', value, rating: rate('TTFB', value) });
51+
});
52+
}
53+
54+
function observe(type: string, cb: (entries: PerformanceEntry[]) => void) {
55+
try {
56+
new PerformanceObserver((list) => cb(list.getEntries())).observe({ type, buffered: true });
57+
} catch { /* unsupported entry type */ }
58+
}
59+
60+
/** Measure an async operation and log duration in dev */
61+
export async function measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
62+
const start = performance.now();
63+
const result = await fn();
64+
if (import.meta.env.DEV) console.debug(`[perf] ${label}: ${(performance.now() - start).toFixed(1)}ms`);
65+
return result;
66+
}

client/src/routes/+layout.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55
import ThemeToggle from '$lib/ThemeToggle.svelte';
66
import SearchBar from '$lib/SearchBar.svelte';
77
import OfflineIndicator from '$lib/OfflineIndicator.svelte';
8+
import { collectWebVitals } from '$lib/perf';
89
9-
onMount(() => themeStore.init());
10+
onMount(() => {
11+
themeStore.init();
12+
collectWebVitals((m) => {
13+
// In production, forward to your analytics endpoint here
14+
if (import.meta.env.DEV) console.info(`[vitals] ${m.name}: ${m.value.toFixed(1)} (${m.rating})`);
15+
});
16+
});
1017
</script>
1118

1219
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200">

client/src/routes/+layout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Enable client-side navigation with preloading on hover/tap (SvelteKit default is 'hover').
2+
// Setting preloadingStrategy here makes it explicit and easy to tune.
3+
export const prerender = false;
4+
export const ssr = false; // SPA mode — all routes are client-side only
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { PageLoad } from './$types';
2+
import { indexerApi } from '$lib/api';
3+
4+
// Preload trade events alongside the route chunk so the page renders with data immediately
5+
export const load: PageLoad = async ({ params }) => {
6+
const tradeId = parseInt(params.id);
7+
const events = await indexerApi.getTradeEvents(tradeId).catch(() => []);
8+
return { tradeId, events };
9+
};

client/vite.config.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@ export default defineConfig({
2121
]
2222
},
2323
workbox: {
24-
// Cache app shell + static assets
2524
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
2625
runtimeCaching: [
2726
{
28-
// Cache API responses (indexer) with network-first strategy
2927
urlPattern: ({ url }) => url.pathname.startsWith('/events') || url.pathname.startsWith('/search'),
3028
handler: 'NetworkFirst',
3129
options: {
@@ -37,5 +35,24 @@ export default defineConfig({
3735
]
3836
}
3937
})
40-
]
38+
],
39+
40+
build: {
41+
// Raise chunk warning threshold to 600kb (stellar-sdk is large)
42+
chunkSizeWarningLimit: 600,
43+
rollupOptions: {
44+
output: {
45+
// Manual code splitting: vendor libs into separate chunks
46+
manualChunks(id) {
47+
if (id.includes('node_modules/@stellar')) return 'stellar-sdk';
48+
if (id.includes('node_modules')) return 'vendor';
49+
}
50+
}
51+
}
52+
},
53+
54+
// Aggressive dependency pre-bundling for faster dev cold starts
55+
optimizeDeps: {
56+
include: ['@stellar/stellar-sdk']
57+
}
4158
});

0 commit comments

Comments
 (0)