perf(workbench): LRU-evict PageCarousel's mounted-cards cache

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-18 15:38:08 +02:00
parent 2c0d866287
commit 4d82381737

View file

@ -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 (36 apps) with headroom.
const MAX_MOUNTED = 8;
let mountedIds = $state<Set<string>>(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