mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] browser: lazy-load background tabs (only the active tab loads; agent/activation wakes deferred tabs)
This commit is contained in:
@@ -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() }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user