From f5cb833b04570b6775d4086713a9c2094d411fec Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 15:27:32 +0200 Subject: [PATCH] perf(workbench): lazy-mount carousel cards via IntersectionObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each open workbench app card previously mounted its full ListView with its own Dexie liveQuery on initial render — so 5+ open apps meant 5+ parallel IndexedDB reads and 5+ async chunk fetches before first paint, even though only the 1-2 cards in the horizontal viewport are visible at a time. PageCarousel now wraps each card in an IntersectionObserver-driven gate. The first card mounts eagerly so paint isn't gated on observer callback timing; the rest swap in a fixed-size placeholder until they enter the viewport (with 50% horizontal overshoot so the next card on either side is ready before the user scrolls to it). Mount is sticky — once a card has been instantiated we keep it resident, since re-running a liveQuery and re-fetching its chunk costs more than keeping the DOM tree around. Affects all three carousel users: workbench /, /todo, /contacts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../page-carousel/PageCarousel.svelte | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 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 96c2ea84d..fbfa12e40 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 @@ -40,13 +40,82 @@ if (showPicker && pickerEl) pickerEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); }); + + // ── Lazy-mount via IntersectionObserver ───────────────── + // Each card mounts a full ListView with its own Dexie liveQuery on + // first render. With 5+ open apps that's 5+ parallel IndexedDB reads + // + 5+ async chunk fetches before first paint, even though only the + // 1-2 cards in the horizontal viewport are actually visible. + // + // 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. + let mountedIds = $state>(new Set()); + function markMounted(id: string) { + if (mountedIds.has(id)) return; + // Replace the Set so $state notices the change. + const next = new Set(mountedIds); + next.add(id); + mountedIds = next; + } + // Mount the first card eagerly so initial paint always shows + // content even before the IntersectionObserver fires its first + // callback (which would otherwise leave a 1-frame placeholder + // flash on cold load). + $effect(() => { + if (pages.length > 0) markMounted(pages[0].id); + }); + + let trackEl = $state(null); + $effect(() => { + if (!trackEl || typeof IntersectionObserver === 'undefined') return; + // Track pages.length so the effect re-runs (and re-observes + // freshly-added wrappers) when the user adds or removes an app. + // Cheap to tear down and recreate; the IO has no internal state + // we care to preserve. + void pages.length; + const io = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const id = (entry.target as HTMLElement).dataset.pageId; + if (id) markMounted(id); + } + }, + { + root: trackEl, + // Pre-mount the immediate neighbours so the next card on + // either side is ready when the user starts scrolling. + rootMargin: '0px 50% 0px 50%', + threshold: 0.01, + } + ); + const wrappers = trackEl.querySelectorAll('[data-page-id]'); + wrappers.forEach((el) => io.observe(el)); + return () => io.disconnect(); + });