From 8b9eff4104c83d6568047ec9e232e217d3d8bbbb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 17:58:44 -0700 Subject: [PATCH] [eric] browser: lazy-load background tabs (only the active tab loads; agent/activation wakes deferred tabs) --- .../app/pages/Dashboard/cards/BrowserCard.tsx | 19 ++++- frontend/src/shared/browserCommandHandler.ts | 36 ++++++++- frontend/src/shared/browserRegistry.ts | 48 +++++++++++- .../src/shared/browserRegistryLazy.test.ts | 76 +++++++++++++++++++ 4 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 frontend/src/shared/browserRegistryLazy.test.ts diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index e8524d25..13254693 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -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 = ({ } }, []); + // 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 = ({ (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() })); diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index d8e5b319..ddfd6575 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -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): } // 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 { +async function awaitWebview(browserId: string, tabId?: string, action?: string): Promise { // 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 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): Promise setTimeout(res, 150)); + } + } const steps = Array.isArray(params.steps) ? params.steps : []; const results: Record[] = []; 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, diff --git a/frontend/src/shared/browserRegistry.ts b/frontend/src/shared/browserRegistry.ts index 32699da1..d4151a52 100644 --- a/frontend/src/shared/browserRegistry.ts +++ b/frontend/src/shared/browserRegistry.ts @@ -70,6 +70,38 @@ export function registerWebview(browserId: string, tabId: string, wv: BrowserWeb armLoadStateTracking(wv); } +// Lazy-tab loading: a background tab mounts its (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 void>(); +const intendedUrl = new WeakMap(); + +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; } diff --git a/frontend/src/shared/browserRegistryLazy.test.ts b/frontend/src/shared/browserRegistryLazy.test.ts new file mode 100644 index 00000000..20068519 --- /dev/null +++ b/frontend/src/shared/browserRegistryLazy.test.ts @@ -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'); +});