diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index b618a629..4acf12a7 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -311,7 +311,8 @@ const BrowserCard: React.FC = ({ window.addEventListener('resize', measure); // A RO only fires on slot RESIZE; the chat tiling/untiling MOVES the slot without resizing the // window, so re-measure on camera writes + settle timers or the docked card lags behind. - window.addEventListener('openswarm:canvas-pan-changed', measure); + // Through the rAF coalescer, not directly: the camera event fans out per frame between style writes, and a layout read here forced a whole-board layout per docked mini per frame (6.5 s in one edge-pan frame on a 44-card board). + window.addEventListener('openswarm:canvas-pan-changed', onScroll); document.addEventListener('visibilitychange', measure); const timers = [60, 250, 700].map((ms) => window.setTimeout(measure, ms)); // The slot lives in the WINDOWED transcript and remounts without firing any of the events @@ -328,7 +329,7 @@ const BrowserCard: React.FC = ({ ro.disconnect(); mo.disconnect(); window.removeEventListener('resize', measure); - window.removeEventListener('openswarm:canvas-pan-changed', measure); + window.removeEventListener('openswarm:canvas-pan-changed', onScroll); document.removeEventListener('visibilitychange', measure); window.removeEventListener('openswarm:browser-slot-mounted', onSlotMounted); scrollHost?.removeEventListener('load', measure, true); diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 083d87cd..7337f4bd 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -200,13 +200,17 @@ const DashboardViewCard: React.FC = ({ window.addEventListener('resize', measure); // A RO only fires on slot RESIZE; the chat tiling/untiling MOVES the slot without resizing the // window, so re-measure on camera writes + settle timers or the docked card lags behind. - window.addEventListener('openswarm:canvas-pan-changed', measure); + // One read per frame, after the frame's own layout: a direct read inside the per-frame camera fan-out forced a whole-board layout per card per frame. + let panRaf = 0; + const onPanChanged = (): void => { if (!panRaf) panRaf = requestAnimationFrame(() => { panRaf = 0; measure(); }); }; + window.addEventListener('openswarm:canvas-pan-changed', onPanChanged); document.addEventListener('visibilitychange', measure); const timers = [60, 250, 700].map((ms) => window.setTimeout(measure, ms)); return () => { ro.disconnect(); window.removeEventListener('resize', measure); - window.removeEventListener('openswarm:canvas-pan-changed', measure); + window.removeEventListener('openswarm:canvas-pan-changed', onPanChanged); + if (panRaf) cancelAnimationFrame(panRaf); document.removeEventListener('visibilitychange', measure); timers.forEach((tm) => window.clearTimeout(tm)); }; @@ -287,11 +291,15 @@ const DashboardViewCard: React.FC = ({ }; evaluate(); const unsubBudget = subscribeAppBudget(evaluate); // an eviction or a freed slot re-runs this card's decision - window.addEventListener('openswarm:canvas-pan-changed', evaluate); + // Same rule as the dock measure above: the viewport size read must not force a layout inside the per-frame camera fan-out. + let panRaf = 0; + const onPanChanged = (): void => { if (!panRaf) panRaf = requestAnimationFrame(() => { panRaf = 0; evaluate(); }); }; + window.addEventListener('openswarm:canvas-pan-changed', onPanChanged); window.addEventListener('resize', evaluate); return () => { unsubBudget(); - window.removeEventListener('openswarm:canvas-pan-changed', evaluate); + window.removeEventListener('openswarm:canvas-pan-changed', onPanChanged); + if (panRaf) cancelAnimationFrame(panRaf); window.removeEventListener('resize', evaluate); if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } }; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/gestureStartCosts.test.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/gestureStartCosts.test.ts index 525bd83a..92fdd847 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/gestureStartCosts.test.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/gestureStartCosts.test.ts @@ -43,3 +43,13 @@ test('the vendored widgets\' group-hover variant is keyed on .group, never on a const widgets = execSync('grep -rl "group-hover/" src/toolui --include=*.tsx || true', { cwd: process.cwd() }).toString().trim(); assert.equal(widgets, '', 'a named group-hover/ falls back to Tailwind\'s :is(:where(.group):hover *) shape: ' + widgets); }); + +test('no card reads layout directly inside the per-frame camera fan-out', () => { + // Every openswarm:canvas-pan-changed listener that READS layout goes through a rAF coalescer; the direct listeners left are the drag re-pins, which only write. + for (const [file, allowed] of [['../../cards/BrowserCard.tsx', ['onScroll', 'onPanChange']], ['../../cards/DashboardViewCard.tsx', ['onPanChanged', 'onPanChange']], ['../../cards/AgentCard.tsx', ['onPanChange']]] as const) { + const src = read(file); + const listeners = [...src.matchAll(/addEventListener\('openswarm:canvas-pan-changed', (\w+)\)/g)].map((m) => m[1]); + assert.ok(listeners.length > 0, `${file} lost its camera listeners`); + for (const name of listeners) assert.ok(allowed.includes(name), `${file}: ${name} listens to the per-frame camera event directly (measure/evaluate must ride a rAF)`); + } +});