From 4d82381737a2c24474c4c9573d33a9f07b3d5a0a Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 18 Apr 2026 15:38:08 +0200 Subject: [PATCH] perf(workbench): LRU-evict PageCarousel's mounted-cards cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the intersection-observer cache grew monotonically: once a card mounted its ListView + Dexie liveQuery, it stayed mounted for the lifetime of the workbench page. A user who scrolled through 20 apps kept 20 parallel liveQueries alive. Now the cache is capped at MAX_MOUNTED=8 with insertion-order LRU semantics: re-intersecting a mounted card bumps it to MRU, and the oldest gets evicted when a new mount pushes the set over cap. Set insertion-order is used for the LRU list so the template's has() check stays O(1). The cap is well above typical working-set sizes (3–6 apps) so regular workbench use never hits the eviction path. Users with large scenes pay at most one extra liveQuery + chunk re-request when scrolling back to an evicted card. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../page-carousel/PageCarousel.svelte | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/mana/apps/web/src/lib/components/page-carousel/PageCarousel.svelte b/apps/mana/apps/web/src/lib/components/page-carousel/PageCarousel.svelte index 528777ed2..9dd415e99 100644 --- a/apps/mana/apps/web/src/lib/components/page-carousel/PageCarousel.svelte +++ b/apps/mana/apps/web/src/lib/components/page-carousel/PageCarousel.svelte @@ -56,17 +56,28 @@ // Strategy: render a fixed-size placeholder until the wrapper enters // the viewport (50% horizontal overshoot so the next card is ready // before the user scrolls to it), then swap in the real snippet. - // Sticky-cache: once a card has been mounted we never tear it down - // — re-running a liveQuery + re-fetching a chunk costs more than - // keeping the DOM tree resident. Memory still scales with how many - // apps the user has actively scrolled through, not how many they - // have open. + // + // LRU cache: keep the MAX_MOUNTED most-recently-intersected cards + // mounted and let older ones drop back to a placeholder. Re-mounting + // a re-intersected card costs one more liveQuery + chunk request, + // but memory now stays bounded regardless of how many apps the user + // scrolls through. The cap is set high enough to cover typical + // working sets (3–6 apps) with headroom. + const MAX_MOUNTED = 8; let mountedIds = $state>(new Set()); function markMounted(id: string) { - if (mountedIds.has(id)) return; - // Replace the Set so $state notices the change. + // Set preserves insertion order → MRU sits at the back. Already-MRU + // is a no-op so we don't churn reactivity on every intersect tick. + if (Array.from(mountedIds).at(-1) === id) return; const next = new Set(mountedIds); + // delete+add re-seats the id at the back of insertion order. + next.delete(id); next.add(id); + while (next.size > MAX_MOUNTED) { + const oldest = next.values().next().value; + if (!oldest) break; + next.delete(oldest); + } mountedIds = next; } // Mount the first card eagerly so initial paint always shows