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 b5e5c4b36..528777ed2 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 @@ -78,14 +78,20 @@ }); let trackEl = $state(null); + let io = $state(null); + // Per-id element refs so we can diff add/remove instead of disconnecting + // and re-observing every wrapper on each pages change. Svelte's keyed + // {#each} preserves DOM nodes per id, so a given id → element mapping is + // stable for the id's lifetime. + const observed = new Map(); + + // Create the IntersectionObserver exactly once — when the track element + // mounts. Previously the IO was torn down and rebuilt on every + // pages.length change, forcing every still-visible wrapper to re-fire + // the intersection callback. Now it persists across adds/removes. $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( + const instance = new IntersectionObserver( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; @@ -101,9 +107,39 @@ threshold: 0.01, } ); + io = instance; + return () => { + instance.disconnect(); + observed.clear(); + io = null; + }; + }); + + // Sync the observed set with the current `pages`: observe new wrappers, + // unobserve wrappers that were removed. Re-runs only when `pages` + // identity (add/remove/reorder) or `io` changes. + $effect(() => { + const instance = io; + if (!instance || !trackEl) return; + // Reading pages.length subscribes this effect to additions/removals. + void pages.length; const wrappers = trackEl.querySelectorAll('[data-page-id]'); - wrappers.forEach((el) => io.observe(el)); - return () => io.disconnect(); + const nextIds = new Set(); + for (const el of wrappers) { + const id = el.dataset.pageId; + if (!id) continue; + nextIds.add(id); + if (!observed.has(id)) { + instance.observe(el); + observed.set(id, el); + } + } + for (const [id, el] of observed) { + if (!nextIds.has(id)) { + instance.unobserve(el); + observed.delete(id); + } + } }); diff --git a/apps/mana/apps/web/src/routes/(app)/+page.svelte b/apps/mana/apps/web/src/routes/(app)/+page.svelte index 2cde4ce7d..30e0730d5 100644 --- a/apps/mana/apps/web/src/routes/(app)/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+page.svelte @@ -227,6 +227,23 @@ // every openApps change) doesn't re-write barComponent. The first // effect handles registration/teardown when scenes appear/disappear; // the second pushes fresh props on every reactive tick. + // + // Callback identities are stable across ticks — previously these were + // inline arrows re-created on every reactive pass, which forced the + // bar to see new props every tick even when only the scene/page data + // actually changed. + function handleBarSceneSelect(id: string) { + workbenchScenesStore.setActiveScene(id); + } + function handleBarSceneCreate(name: string) { + workbenchScenesStore.createScene({ name }).catch((err) => { + console.error('[workbench] createScene failed:', err); + }); + } + function handleBarToggleShowPicker() { + showPicker = !showPicker; + } + let barRegistered = $state(false); $effect(() => { const hasScenes = scenes.length > 0; @@ -244,12 +261,12 @@ scenes, activeSceneId, pages: carouselPages, - onSceneSelect: (id: string) => workbenchScenesStore.setActiveScene(id), - onSceneCreate: (name: string) => workbenchScenesStore.createScene({ name }), + onSceneSelect: handleBarSceneSelect, + onSceneCreate: handleBarSceneCreate, onSceneContextMenu: handleSceneContextMenu, onAppClick: scrollToPage, - onAppContextMenu: (e: MouseEvent, id: string) => handleTabContextMenu(e, id), - onAddApp: () => (showPicker = !showPicker), + onAppContextMenu: handleTabContextMenu, + onAddApp: handleBarToggleShowPicker, }); });