[eric] canvas: docked-mini and app-card camera listeners read layout once per frame, not inside the per-frame fan-out

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
ciregenz
2026-09-02 20:00:29 -07:00
co-authored by Claude Fable 5.1
parent b645d4e03f
commit fb17809682
3 changed files with 25 additions and 6 deletions
@@ -311,7 +311,8 @@ const BrowserCard: React.FC<Props> = ({
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<Props> = ({
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);
@@ -200,13 +200,17 @@ const DashboardViewCard: React.FC<Props> = ({
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<Props> = ({
};
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; }
};
@@ -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/<name> 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)`);
}
});