[eric] canvas: the next webview waits for the last one to attach, not for the next frame

This commit is contained in:
ciregenz
2026-08-07 13:44:30 -07:00
parent 5eed1385be
commit 3c711d0f85
2 changed files with 35 additions and 14 deletions
@@ -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<Props> = ({
// 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);
};
@@ -1,48 +1,66 @@
/**
* Serialises <webview> attachment to one per frame.
* Serialises <webview> 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<typeof setTimeout> | 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;