perf: implement Phase 1 homepage diet via Next.js Cache Components

P1-0: V8 heap cap (HOMARR_MAX_OLD_SPACE_SIZE, default 512MB)
P1-1: Module graph cuts
  - Dynamic integration imports (55 static → lazy loaders)
  - Conditional DB driver loading (only configured driver loaded)
  - Replace jsdom with linkedom for SVG sanitization
  - Delete eager widget barrel (55 import * as → 0)
  - Extract createRetryableLoader to @homarr/common
P1-2: Enable cacheComponents + restructure root layout
  - Suspense boundaries around session-dependent providers
  - ColorSchemeScript replaces server-side cookie reads
  - Board/manage/auth layouts wrapped in Suspense
P1-3: Cache read paths
  - Cache tag registry + invalidation helpers
  - Cache invalidation wired into all mutation routers
  - Translation deep-merge memoized per locale
P1-4: Prerendered shells + loading.tsx files
P1-5: Shared integration data cache (60s TTL, stale-while-revalidate)
P1-6: Redis-backed Next.js cache handler (@homarr/next-cache-handler)
P1-7: Redis optional (memory channel fallback), TTL on pubSub:last:*,
      Redis clients consolidated from 6 to 2
This commit is contained in:
Thomas Camlong committed 2026-08-07 18:59:47 +02:00
1 parent 366ec6bdbc
commit 018f98e318
86 files changed
+2219 -1232

No files matched your search

@@ -0,0 +1,78 @@
// ponytail: module-level cache — one snapshot per integration per query key.
// Ceiling: stale data for up to `ttlMs` (60s default). Upgrade: Redis-backed with pub/sub invalidation.
interface CachedEntry<T> {
data: T;
timestamp: number;
pending?: Promise<T>;
}
const cache = new Map<string, CachedEntry<unknown>>();
const DEFAULT_TTL_MS = 60_000;
const MAX_ENTRIES = 200;
const cacheKey = (integrationId: string, queryKey: string) => `${integrationId}:${queryKey}`;
export const getCachedIntegrationData = async <T>(
integrationId: string,
queryKey: string,
fetcher: () => Promise<T>,
ttlMs = DEFAULT_TTL_MS,
): Promise<T> => {
const key = cacheKey(integrationId, queryKey);
const existing = cache.get(key) as CachedEntry<T> | undefined;
const now = Date.now();
if (existing) {
if (now - existing.timestamp < ttlMs) {
return existing.data;
}
if (existing.pending) {
return existing.pending;
}
}
const pending = fetcher()
.then((data) => {
cache.set(key, { data, timestamp: Date.now() });
if (cache.size > MAX_ENTRIES) {
let oldest: string | undefined;
let oldestTime = Infinity;
for (const [k, v] of cache) {
if (v.timestamp < oldestTime) {
oldestTime = v.timestamp;
oldest = k;
}
}
if (oldest) cache.delete(oldest);
}
return data;
})
.catch((error) => {
const entry = cache.get(key) as CachedEntry<T> | undefined;
if (entry) {
delete entry.pending;
if (entry.timestamp > 0) return entry.data;
}
cache.delete(key);
throw error;
});
if (existing) {
existing.pending = pending;
return existing.data;
}
cache.set(key, { data: undefined as T, timestamp: 0, pending });
return pending;
};
export const invalidateIntegrationDataCache = (integrationId: string) => {
for (const [key] of cache) {
if (key.startsWith(`${integrationId}:`)) {
cache.delete(key);
}
}
};