diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 080e0f33..fd01ddce 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -20,6 +20,7 @@ import { scrollJs, autoScrollJs, networkRequestsJs, + waitForDomStableJs, } from './dom-helpers.js'; export interface CDPTarget { @@ -166,10 +167,11 @@ class CDPPage implements IPage { .catch(() => {}); // Don't fail if event times out await this.bridge.send('Page.navigate', { url }); await loadPromise; - // Post-load settle: SPA frameworks need extra time to render after load event + // Smart settle: use DOM stability detection instead of fixed sleep. + // settleMs is now a timeout cap (default 1000ms), not a fixed wait. if (options?.waitUntil !== 'none') { - const settleMs = options?.settleMs ?? 1000; - await new Promise(resolve => setTimeout(resolve, settleMs)); + const maxMs = options?.settleMs ?? 1000; + await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs))); } } diff --git a/src/browser/dom-helpers.ts b/src/browser/dom-helpers.ts index 63c07fa9..bcc9b413 100644 --- a/src/browser/dom-helpers.ts +++ b/src/browser/dom-helpers.ts @@ -145,3 +145,37 @@ export function networkRequestsJs(includeStatic: boolean): string { })() `; } + +/** + * Generate JS to wait until the DOM stabilizes (no mutations for `quietMs`), + * with a hard cap at `maxMs`. Uses MutationObserver in the browser. + * + * Returns as soon as the page stops changing, avoiding unnecessary fixed waits. + * If document.body is not available, falls back to a fixed sleep of maxMs. + */ +export function waitForDomStableJs(maxMs: number, quietMs: number): string { + return ` + new Promise(resolve => { + if (!document.body) { + setTimeout(() => resolve('nobody'), ${maxMs}); + return; + } + let timer = null; + let cap = null; + const done = (reason) => { + clearTimeout(timer); + clearTimeout(cap); + obs.disconnect(); + resolve(reason); + }; + const resetQuiet = () => { + clearTimeout(timer); + timer = setTimeout(() => done('quiet'), ${quietMs}); + }; + const obs = new MutationObserver(resetQuiet); + obs.observe(document.body, { childList: true, subtree: true, attributes: true }); + resetQuiet(); + cap = setTimeout(() => done('capped'), ${maxMs}); + }) + `; +} diff --git a/src/browser/page.ts b/src/browser/page.ts index ddb139ec..0a38a7f7 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -23,6 +23,7 @@ import { scrollJs, autoScrollJs, networkRequestsJs, + waitForDomStableJs, } from './dom-helpers.js'; /** @@ -53,11 +54,15 @@ export class Page implements IPage { if (result?.tabId) { this._tabId = result.tabId; } - // Post-load settle: the extension already waits for tab.status === 'complete', - // but SPA frameworks (React/Vue) need extra time to render after DOM load. + // Smart settle: use DOM stability detection instead of fixed sleep. + // settleMs is now a timeout cap (default 1000ms), not a fixed wait. if (options?.waitUntil !== 'none') { - const settleMs = options?.settleMs ?? 1000; - await new Promise(resolve => setTimeout(resolve, settleMs)); + const maxMs = options?.settleMs ?? 1000; + await sendCommand('exec', { + code: waitForDomStableJs(maxMs, Math.min(500, maxMs)), + ...this._workspaceOpt(), + ...this._tabOpt(), + }); } }