diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index a79fd9a1..6736c674 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { requestWebviewAttachSlot } from './webviewAttachQueue'; import { createPortal } from 'react-dom'; import { subscribeLiveDrag } from '../hooks/interaction/liveDragChannel'; import Box from '@mui/material/Box'; @@ -416,6 +417,15 @@ const BrowserCard: React.FC = ({ }, [activeUrl, activeTabId]); const webviewMap = useRef>(new Map()); + + // Electron attaches a guest with a SYNCHRONOUS renderer IPC, so a dashboard that mounts N cards + // puts N blocking round-trips in one frame (measured: 4755ms over 40 long tasks at 18 cards). + // Waiting for a slot spreads them one per frame; nothing unmounts, so sessions are untouched. + const [attachSlotReady, setAttachSlotReady] = useState(false); + useEffect(() => { + if (attachSlotReady) return undefined; + return requestWebviewAttachSlot(() => setAttachSlotReady(true)); + }, [attachSlotReady]); const initializedTabs = useRef(new Set()); const tabBarRef = useRef(null); // Some pages (Zillow's map) rewrite their own URL many times a second, across did-navigate-in-page AND did-stop-loading; throttle the persisted URL mirror so each tick can't fan out to a full dashboard save + webview suspend re-eval. Leading edge keeps a real navigation's URL immediate. @@ -1636,7 +1646,7 @@ const BrowserCard: React.FC = ({ ) ) : ( <> - {tabs.map((tab) => ( + {(attachSlotReady ? tabs : []).map((tab) => ( { diff --git a/frontend/src/app/pages/Dashboard/cards/webviewAttachQueue.ts b/frontend/src/app/pages/Dashboard/cards/webviewAttachQueue.ts new file mode 100644 index 00000000..2e298ff9 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/webviewAttachQueue.ts @@ -0,0 +1,49 @@ +/** + * Serialises attachment to one per frame. + * + * 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. + */ + +type Slot = () => void; + +let pending: Slot[] = []; +let pumping = false; + +function pump(): void { + const next = pending.shift(); + if (!next) { + pumping = false; + return; + } + try { + next(); + } catch { + /* a card that blew up on attach must not stall every card behind it */ + } + 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. + */ +export function requestWebviewAttachSlot(onReady: Slot): () => void { + pending.push(onReady); + if (!pumping) { + pumping = true; + // Start on the next frame so a burst of cards mounting in one commit all queue up first. + requestAnimationFrame(() => pump()); + } + return () => { + pending = pending.filter((s) => s !== onReady); + }; +} + +/** Cards waiting behind the queue right now; exposed so a test can prove the burst is serialised. */ +export function pendingAttachCount(): number { + return pending.length; +}