diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 6736c674..7f811a21 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; -import { requestWebviewAttachSlot } from './webviewAttachQueue'; +import { requestWebviewAttachSlot, releaseWebviewAttachSlot } from './webviewAttachQueue'; import { createPortal } from 'react-dom'; import { subscribeLiveDrag } from '../hooks/interaction/liveDragChannel'; import Box from '@mui/material/Box'; @@ -519,6 +519,9 @@ const BrowserCard: React.FC = ({ // about:blank (deferred) so a many-tab card doesn't load every page at once; it's woken // the instant it becomes active OR an agent command resolves it (browserRegistry wake). const onReady = () => { + // The guest is attached; let the next card in the queue take its turn. An attach costs + // 60-140ms, longer than a frame, so releasing on a timer would let them overlap again. + releaseWebviewAttachSlot(); if (tabId === activeTabIdRef.current) doLoad(); else registerPendingLoad(wv, targetUrl, doLoad); }; diff --git a/frontend/src/app/pages/Dashboard/cards/webviewAttachQueue.ts b/frontend/src/app/pages/Dashboard/cards/webviewAttachQueue.ts index 2e298ff9..91e03404 100644 --- a/frontend/src/app/pages/Dashboard/cards/webviewAttachQueue.ts +++ b/frontend/src/app/pages/Dashboard/cards/webviewAttachQueue.ts @@ -1,48 +1,66 @@ /** - * Serialises attachment to one per frame. + * Serialises attachment so only one is in flight at a time. * * Electron attaches a guest view with a SYNCHRONOUS renderer IPC (GUEST_VIEW_MANAGER_CALL), so N * cards mounting together put N blocking round-trips in one frame. Measured on a real dashboard: * opening one with 18 cards / 8 webviews blocked the main thread for 4755ms across 40 long tasks, - * while an idle canvas blocked for 0ms (ENG-193). Nothing here makes the attach cheaper; it just - * stops them landing on the same frame, so the UI keeps painting between them. + * while an idle canvas blocked for 0ms (ENG-193). + * + * The first version released a slot per animation frame, which was not enough: an attach costs + * 60-140ms, i.e. several frames, so the next slot fired mid-attach and they overlapped anyway + * (worst single task stayed at 292ms). Slots now wait for the previous card to report it finished, + * with a ceiling so a card that never reports cannot wedge every card behind it. */ type Slot = () => void; -let pending: Slot[] = []; -let pumping = false; +// An attach measured 60-140ms in isolation; this only bounds the pathological case where a card +// mounts and never signals, so it is deliberately far above the real cost. +const P_ATTACH_CEILING_MS = 1200; -function pump(): void { +let pending: Slot[] = []; +let inFlight = false; +let ceiling: ReturnType | null = null; + +function p_startNext(): void { const next = pending.shift(); if (!next) { - pumping = false; + inFlight = false; return; } + inFlight = true; + if (ceiling) clearTimeout(ceiling); + ceiling = setTimeout(() => { ceiling = null; p_startNext(); }, P_ATTACH_CEILING_MS); try { next(); } catch { /* a card that blew up on attach must not stall every card behind it */ + if (ceiling) { clearTimeout(ceiling); ceiling = null; } + p_startNext(); } - requestAnimationFrame(() => pump()); } /** - * Ask for the next attach slot. `onReady` fires on this frame if the queue is empty, otherwise one - * frame per card ahead of it. Returns a cancel function for unmount before the slot arrives. + * Ask for the next attach slot. `onReady` fires immediately if nothing is attaching, otherwise once + * the card ahead reports done. Returns a cancel function for unmount before the slot arrives. */ export function requestWebviewAttachSlot(onReady: Slot): () => void { pending.push(onReady); - if (!pumping) { - pumping = true; + if (!inFlight) { // Start on the next frame so a burst of cards mounting in one commit all queue up first. - requestAnimationFrame(() => pump()); + requestAnimationFrame(() => { if (!inFlight) p_startNext(); }); } return () => { pending = pending.filter((s) => s !== onReady); }; } +/** A card calls this once its guest has actually attached, releasing the next card in line. */ +export function releaseWebviewAttachSlot(): void { + if (ceiling) { clearTimeout(ceiling); ceiling = null; } + p_startNext(); +} + /** Cards waiting behind the queue right now; exposed so a test can prove the burst is serialised. */ export function pendingAttachCount(): number { return pending.length;