[eric] browser: lazy-load background tabs (only the active tab loads; agent/activation wakes deferred tabs)

This commit is contained in:
ciregenz
2026-07-19 17:58:44 -07:00
parent 753f2f63ce
commit 8b9eff4104
4 changed files with 171 additions and 8 deletions
@@ -48,6 +48,8 @@ import {
registerWebview,
unregisterWebview,
setActiveTab as setRegistryActiveTab,
registerPendingLoad,
wakePendingLoad,
type BrowserWebview,
} from '@/shared/browserRegistry';
import { setLastInteractedBrowser } from '@/shared/browserFocus';
@@ -265,8 +267,14 @@ const BrowserCard: React.FC<Props> = ({
}
}, []);
// Kept current so the mount-time load decision (eager vs deferred) reads the live active tab, not a stale closure (the load effect keys on the tab SET, not activeTabId).
const activeTabIdRef = useRef(activeTabId);
useEffect(() => {
activeTabIdRef.current = activeTabId;
setRegistryActiveTab(browserId, activeTabId);
// Switching to a deferred background tab loads it now; no-op if it already loaded or hasn't reached dom-ready yet (onReady then loads it eagerly because it's the active tab).
const wv = webviewMap.current.get(activeTabId);
if (wv) wakePendingLoad(wv);
}, [browserId, activeTabId]);
// Open the find bar when AppShell routes a Ctrl/Cmd+F to this browser; re-trigger re-focuses the input.
@@ -320,8 +328,15 @@ const BrowserCard: React.FC<Props> = ({
(wv as any).setZoomFactor?.(1);
} catch (_) {}
};
wv.addEventListener('dom-ready', doLoad, { once: true });
cleanups.push(() => wv.removeEventListener('dom-ready', doLoad));
// Lazy tabs: only the VISIBLE tab loads its page on mount. A background tab stays at
// 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 = () => {
if (tabId === activeTabIdRef.current) doLoad();
else registerPendingLoad(wv, targetUrl, doLoad);
};
wv.addEventListener('dom-ready', onReady, { once: true });
cleanups.push(() => wv.removeEventListener('dom-ready', onReady));
}
const mirrorUrl = () => dispatch(updateBrowserTabUrl({ browserId, tabId, url: wv.getURL() }));
+33 -3
View File
@@ -1,4 +1,4 @@
import { getWebview, findWebviewByDomain, hasDomReady, markDomReady, type BrowserWebview } from './browserRegistry';
import { getWebview, findWebviewByDomain, hasDomReady, markDomReady, isPendingLoad, wakePendingLoad, clearPendingLoad, type BrowserWebview } from './browserRegistry';
import { store } from './state/store';
import { resumeBrowserCard } from './state/dashboardLayoutSlice';
import { dashboardWs } from './ws/WebSocketManager';
@@ -1434,7 +1434,7 @@ async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>):
}
// The registry is renderer-local and a card briefly unregisters on remount / tab-switch; a command landing in that gap shouldn't hard-fail. Wait a bounded window for (re)registration before giving up, so the error stays a real "card is gone" signal rather than a transient race.
async function awaitWebview(browserId: string, tabId?: string): Promise<BrowserWebview | undefined> {
async function awaitWebview(browserId: string, tabId?: string, action?: string): Promise<BrowserWebview | undefined> {
// A suspended (snapshot-swapped) card has no webview at all; wake it and wait out the remount + page reload before the command touches it.
const wasSuspended = !!store.getState().dashboardLayout.suspendedBrowserCards[browserId];
if (wasSuspended) store.dispatch(resumeBrowserCard(browserId));
@@ -1454,6 +1454,24 @@ async function awaitWebview(browserId: string, tabId?: string): Promise<BrowserW
await new Promise((r) => setTimeout(r, 150));
}
}
// A lazy background tab mounts at about:blank with its real page deferred; an agent command needs
// the real page, so wake it and wait out the load, same as a resumed suspended card. A navigate
// is about to load its own url, so just drop the deferred load instead of loading the old one first.
if (wv && isPendingLoad(wv)) {
if (action === 'navigate') {
clearPendingLoad(wv);
} else if (wakePendingLoad(wv)) {
const loadDeadline = Date.now() + 12000;
while (Date.now() < loadDeadline) {
try {
if (!wv.isLoading() && wv.getURL() !== 'about:blank') break;
} catch {
// mid-load hiccup; keep waiting
}
await new Promise((r) => setTimeout(r, 150));
}
}
}
return wv;
}
@@ -1501,6 +1519,18 @@ async function handlePerformAction(params: Record<string, any>): Promise<Record<
if (!wv) {
return { error: `No ${domain} browser card is open. Open ${domain} in an OpenSwarm browser card and sign in, then retry.` };
}
// findWebviewByDomain can resolve a deferred background tab by its intended url; wake it and wait out the load before driving it, so the session-borrow shims never act on an about:blank tab.
if (isPendingLoad(wv) && wakePendingLoad(wv)) {
const loadDeadline = Date.now() + 12000;
while (Date.now() < loadDeadline) {
try {
if (!wv.isLoading() && wv.getURL() !== 'about:blank') break;
} catch {
// mid-load hiccup; keep waiting
}
await new Promise((res) => setTimeout(res, 150));
}
}
const steps = Array.isArray(params.steps) ? params.steps : [];
const results: Record<string, any>[] = [];
for (const step of steps) {
@@ -1530,7 +1560,7 @@ async function runBrowserCommand(
dashboardWs.send('browser:result', { request_id, ...result });
return;
}
const wv = await awaitWebview(browser_id, tab_id || undefined);
const wv = await awaitWebview(browser_id, tab_id || undefined, action);
if (!wv) {
dashboardWs.send('browser:result', {
request_id,
+45 -3
View File
@@ -70,6 +70,38 @@ export function registerWebview(browserId: string, tabId: string, wv: BrowserWeb
armLoadStateTracking(wv);
}
// Lazy-tab loading: a background tab mounts its <webview> (so it stays registered + resolvable
// exactly like a live one) but defers loadURL until it's actually needed, so a many-tab card
// doesn't load every page at once. The tab is never starved: it's woken when it becomes active
// OR the moment an agent command resolves it.
const pendingLoad = new WeakMap<BrowserWebview, () => void>();
const intendedUrl = new WeakMap<BrowserWebview, string>();
export function registerPendingLoad(wv: BrowserWebview, url: string, load: () => void): void {
pendingLoad.set(wv, load);
intendedUrl.set(wv, url);
}
export function isPendingLoad(wv: BrowserWebview): boolean {
return pendingLoad.has(wv);
}
// Fire a lazy tab's deferred load exactly once; returns true if it was pending (the caller then
// waits out the page load, same as a resumed suspended card). No-op on an already-loaded tab.
export function wakePendingLoad(wv: BrowserWebview): boolean {
const load = pendingLoad.get(wv);
if (!load) return false;
pendingLoad.delete(wv);
load();
return true;
}
// Drop a lazy tab's deferred load WITHOUT firing it: an agent navigate is about to load a
// different url, so loading the old intended url first would be wasted work.
export function clearPendingLoad(wv: BrowserWebview): void {
pendingLoad.delete(wv);
}
export function unregisterWebview(browserId: string, tabId: string): void {
registry.delete(makeKey(browserId, tabId));
}
@@ -106,13 +138,23 @@ export function findBrowserByWebContentsId(wcId: number): string | undefined {
// LIVE url (not a stale persisted card.url) so the action lands on the real tab.
export function findWebviewByDomain(domain: string): BrowserWebview | undefined {
const d = domain.toLowerCase().replace(/^\./, '');
for (const wv of registry.values()) {
const matchesHost = (u: string): boolean => {
try {
const host = new URL(wv.getURL()).hostname.toLowerCase();
if (host === d || host.endsWith('.' + d)) return wv;
const host = new URL(u).hostname.toLowerCase();
return host === d || host.endsWith('.' + d);
} catch {
// about:blank or a torn-down webview has no parseable URL; skip it.
return false;
}
};
for (const wv of registry.values()) {
if (matchesHost(wv.getURL())) return wv;
}
// A lazy background tab sits at about:blank, so its LIVE url can't match; fall back to its
// INTENDED (deferred) url so the session-borrow shims still find + wake it. The caller wakes it.
for (const wv of registry.values()) {
const pend = intendedUrl.get(wv);
if (pend && pendingLoad.has(wv) && matchesHost(pend)) return wv;
}
return undefined;
}
@@ -0,0 +1,76 @@
// Run: node --test frontend/src/shared/browserRegistryLazy.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
registerWebview,
unregisterWebview,
registerPendingLoad,
isPendingLoad,
wakePendingLoad,
clearPendingLoad,
findWebviewByDomain,
type BrowserWebview,
} from './browserRegistry.ts';
// Minimal fake webview: the registry only calls addEventListener (load tracking) + getURL.
function fakeWebview(url: string): BrowserWebview {
return {
getURL: () => url,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as BrowserWebview;
}
test('a lazy tab is resolvable by its INTENDED url while deferred, then wakes exactly once', () => {
const wv = fakeWebview('about:blank');
registerWebview('b1', 't1', wv);
let loaded = 0;
registerPendingLoad(wv, 'https://tiktok.com/@me', () => { loaded += 1; });
assert.equal(isPendingLoad(wv), true);
// about:blank live url can't match, but the intended-url fallback finds it for the session-borrow shims.
assert.equal(findWebviewByDomain('tiktok.com'), wv);
assert.equal(wakePendingLoad(wv), true);
assert.equal(loaded, 1);
// Second wake is a no-op (already loaded), so an agent re-touching the tab can't double-load it.
assert.equal(wakePendingLoad(wv), false);
assert.equal(loaded, 1);
assert.equal(isPendingLoad(wv), false);
unregisterWebview('b1', 't1');
});
test('clearPendingLoad drops the deferred load without firing it (navigate replaces the url)', () => {
const wv = fakeWebview('about:blank');
registerWebview('b2', 't2', wv);
let loaded = 0;
registerPendingLoad(wv, 'https://old.example.com', () => { loaded += 1; });
clearPendingLoad(wv);
assert.equal(isPendingLoad(wv), false);
assert.equal(wakePendingLoad(wv), false);
assert.equal(loaded, 0);
unregisterWebview('b2', 't2');
});
test('a live-url tab still matches by its real url (unchanged path)', () => {
const wv = fakeWebview('https://youtube.com/watch?v=x');
registerWebview('b3', 't3', wv);
assert.equal(findWebviewByDomain('youtube.com'), wv);
assert.equal(isPendingLoad(wv), false);
unregisterWebview('b3', 't3');
});
test('a live tab wins over a deferred tab for the same domain', () => {
const live = fakeWebview('https://reddit.com/r/x');
const lazy = fakeWebview('about:blank');
registerWebview('b4', 'live', live);
registerWebview('b4', 'lazy', lazy);
registerPendingLoad(lazy, 'https://reddit.com/r/y', () => {});
// The already-loaded tab is preferred; the deferred one is only a fallback.
assert.equal(findWebviewByDomain('reddit.com'), live);
unregisterWebview('b4', 'live');
unregisterWebview('b4', 'lazy');
});