perf(workbench): persistent IO + stable bar callbacks

- PageCarousel now creates the IntersectionObserver exactly once when
  the track mounts and diffs observed wrappers on pages changes,
  instead of tearing down and rebuilding the entire IO on every
  add/remove. Already-mounted pages no longer re-fire the intersection
  callback on each reactive tick.
- SceneAppBar receives stable callback identities. The bar-props effect
  previously recreated fresh inline arrows on every reactive pass
  (carouselPages / appTitles / DEFAULT_WIDTH change), forcing the bar
  to see new props even when only data changed. Hoisted handlers make
  the bar's prop diff a pure data comparison.
- `createScene` failures from the bar now surface in the console
  instead of silently rejecting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 15:27:48 +02:00
parent 38b9fdb91b
commit bd1e273f60
2 changed files with 65 additions and 12 deletions

View file

@ -78,14 +78,20 @@
});
let trackEl = $state<HTMLDivElement | null>(null);
let io = $state<IntersectionObserver | null>(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<string, HTMLElement>();
// 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<HTMLElement>('[data-page-id]');
wrappers.forEach((el) => io.observe(el));
return () => io.disconnect();
const nextIds = new Set<string>();
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);
}
}
});
</script>

View file

@ -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,
});
});