From 205cdf314cce9872492dff18dd8ed0e0860a249b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 21:19:33 +0100 Subject: [PATCH] perf: bound SWR cache, clean stale storage, cap annotations Add LRU cache provider (200 entries) to prevent unbounded SWR memory growth. Clean up stale PKCE localStorage keys on app init. Cap chart annotations to 20 visible reference lines with overflow indicator. --- CHANGELOG.md | 4 ++++ app/layout.tsx | 15 +++++++----- components/SWRProvider.tsx | 12 ++++++++++ components/dashboard/Chart.tsx | 14 ++++++++++-- lib/auth/context.tsx | 3 +++ lib/swr/cache-provider.ts | 42 ++++++++++++++++++++++++++++++++++ lib/utils/storage-cleanup.ts | 17 ++++++++++++++ 7 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 components/SWRProvider.tsx create mode 100644 lib/swr/cache-provider.ts create mode 100644 lib/utils/storage-cleanup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c487ce..ce3740e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem. - **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data. - **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices. +- **Real-time updates work across all servers.** If Pulse runs on multiple servers behind a load balancer, real-time visitor updates now stay in sync no matter which server you're connected to. Previously, you might miss live visitor changes if your connection landed on a different server than the one fetching data. +- **Lighter memory usage in long sessions.** If you manage many sites and keep Pulse open for hours, the app now automatically clears out old cached data for sites you're no longer viewing. This keeps the tab responsive and prevents it from slowly using more and more memory over time. +- **Cleaner login storage.** Temporary data left behind by abandoned sign-in attempts is now cleaned up automatically when the app loads. This prevents clutter from building up in your browser's storage over time. +- **Tidier annotation display.** If you've added a lot of annotations to your chart, only the 20 most recent are shown as lines on the chart to keep it readable. A "+N more" label lets you know there are additional annotations. - **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time. - **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time. - **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes. diff --git a/app/layout.tsx b/app/layout.tsx index 459df64..13c4f7b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import { ThemeProviders, Toaster } from '@ciphera-net/ui' import { AuthProvider } from '@/lib/auth/context' +import SWRProvider from '@/components/SWRProvider' import type { Metadata, Viewport } from 'next' import { Plus_Jakarta_Sans } from 'next/font/google' import LayoutContent from './layout-content' @@ -46,12 +47,14 @@ export default function RootLayout({ return ( - - - {children} - - - + + + + {children} + + + + ) diff --git a/components/SWRProvider.tsx b/components/SWRProvider.tsx new file mode 100644 index 0000000..c3a18e6 --- /dev/null +++ b/components/SWRProvider.tsx @@ -0,0 +1,12 @@ +'use client' + +import { SWRConfig } from 'swr' +import { boundedCacheProvider } from '@/lib/swr/cache-provider' + +export default function SWRProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index d40cad7..88e1bbe 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -19,6 +19,8 @@ const ANNOTATION_COLORS: Record = { other: '#a3a3a3', } +const MAX_VISIBLE_ANNOTATIONS = 20 + const ANNOTATION_LABELS: Record = { deploy: 'Deploy', campaign: 'Campaign', @@ -254,6 +256,9 @@ export default function Chart({ return markers }, [annotations, chartData]) + const visibleAnnotationMarkers = annotationMarkers.slice(0, MAX_VISIBLE_ANNOTATIONS) + const hiddenAnnotationCount = Math.max(0, annotationMarkers.length - MAX_VISIBLE_ANNOTATIONS) + // ─── Right-click handler ────────────────────────────────────────── const handleChartContextMenu = useCallback((e: React.MouseEvent) => { if (!canManageAnnotations) return @@ -490,7 +495,7 @@ export default function Chart({ /> {/* Annotation reference lines */} - {annotationMarkers.map((marker) => { + {visibleAnnotationMarkers.map((marker) => { const primaryCategory = marker.annotations[0].category const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other return ( @@ -534,7 +539,7 @@ export default function Chart({ {annotationMarkers.length > 0 && ( <> Annotations: - {annotationMarkers.map((marker) => { + {visibleAnnotationMarkers.map((marker) => { const primary = marker.annotations[0] const color = ANNOTATION_COLORS[primary.category] || ANNOTATION_COLORS.other const count = marker.annotations.length @@ -577,6 +582,11 @@ export default function Chart({ ) })} + {hiddenAnnotationCount > 0 && ( + + +{hiddenAnnotationCount} more + + )} )} diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index db49ca7..eeb8e90 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -7,6 +7,7 @@ import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-n import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth' import { getUserOrganizations, switchContext } from '@/lib/api/organization' import { logger } from '@/lib/utils/logger' +import { cleanupStaleStorage } from '@/lib/utils/storage-cleanup' interface User { id: string @@ -131,6 +132,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Initial load useEffect(() => { const init = async () => { + cleanupStaleStorage() + // * 1. Check server-side session (cookies) let session: Awaited> = null try { diff --git a/lib/swr/cache-provider.ts b/lib/swr/cache-provider.ts new file mode 100644 index 0000000..8aac5ec --- /dev/null +++ b/lib/swr/cache-provider.ts @@ -0,0 +1,42 @@ +// * Bounded LRU cache provider for SWR +// * Prevents unbounded memory growth during long sessions across many sites + +const MAX_CACHE_ENTRIES = 200 + +export function boundedCacheProvider() { + const map = new Map() + const accessOrder: string[] = [] + + const touch = (key: string) => { + const idx = accessOrder.indexOf(key) + if (idx > -1) accessOrder.splice(idx, 1) + accessOrder.push(key) + } + + const evict = () => { + while (map.size > MAX_CACHE_ENTRIES && accessOrder.length > 0) { + const oldest = accessOrder.shift()! + map.delete(oldest) + } + } + + return { + get(key: string) { + if (map.has(key)) touch(key) + return map.get(key) + }, + set(key: string, value: any) { + map.set(key, value) + touch(key) + evict() + }, + delete(key: string) { + map.delete(key) + const idx = accessOrder.indexOf(key) + if (idx > -1) accessOrder.splice(idx, 1) + }, + keys() { + return map.keys() + }, + } +} diff --git a/lib/utils/storage-cleanup.ts b/lib/utils/storage-cleanup.ts new file mode 100644 index 0000000..579192a --- /dev/null +++ b/lib/utils/storage-cleanup.ts @@ -0,0 +1,17 @@ +// * Cleans up stale localStorage entries on app initialization +// * Prevents accumulation from abandoned OAuth flows + +export function cleanupStaleStorage() { + if (typeof window === 'undefined') return + + try { + // * PKCE keys are only needed during the OAuth callback + // * If we're not on the callback page, they're stale leftovers + if (!window.location.pathname.includes('/auth/callback')) { + localStorage.removeItem('oauth_state') + localStorage.removeItem('oauth_code_verifier') + } + } catch { + // * Ignore errors (private browsing, storage disabled, etc.) + } +}