@@ -30,6 +30,7 @@ import {
3030} from "../types/stagehandErrors" ;
3131import { StagehandAPIError } from "@/types/stagehandApiErrors" ;
3232import { scriptContent } from "@/lib/dom/build/scriptContent" ;
33+ import type { Protocol } from "devtools-protocol" ;
3334
3435export class StagehandPage {
3536 private stagehand : Stagehand ;
@@ -427,71 +428,165 @@ ${scriptContent} \
427428 return this . intContext . context ;
428429 }
429430
430- // We can make methods public because StagehandPage is private to the Stagehand class.
431- // When a user gets stagehand.page, they are getting a proxy to the Playwright page.
432- // We can override the methods on the proxy to add our own behavior
433- public async _waitForSettledDom ( timeoutMs ?: number ) {
434- try {
435- const timeout = timeoutMs ?? this . stagehand . domSettleTimeoutMs ;
436- let timeoutHandle : NodeJS . Timeout ;
431+ /**
432+ * `_waitForSettledDom` waits until the DOM is settled, and therefore is
433+ * ready for actions to be taken.
434+ *
435+ * **Definition of “settled”**
436+ * • No in-flight network requests (except WebSocket / Server-Sent-Events).
437+ * • That idle state lasts for at least **500 ms** (the “quiet-window”).
438+ *
439+ * **How it works**
440+ * 1. Subscribes to CDP Network and Page events for the main target and all
441+ * out-of-process iframes (via `Target.setAutoAttach { flatten:true }`).
442+ * 2. Every time `Network.requestWillBeSent` fires, the request ID is added
443+ * to an **`inflight`** `Set`.
444+ * 3. When the request finishes—`loadingFinished`, `loadingFailed`,
445+ * `requestServedFromCache`, or a *data:* response—the request ID is
446+ * removed.
447+ * 4. *Document* requests are also mapped **frameId → requestId**; when
448+ * `Page.frameStoppedLoading` fires the corresponding Document request is
449+ * removed immediately (covers iframes whose network events never close).
450+ * 5. A **stalled-request sweep timer** runs every 500 ms. If a *Document*
451+ * request has been open for ≥ 2 s it is forcibly removed; this prevents
452+ * ad/analytics iframes from blocking the wait forever.
453+ * 6. When `inflight` becomes empty the helper starts a 500 ms timer.
454+ * If no new request appears before the timer fires, the promise
455+ * resolves → **DOM is considered settled**.
456+ * 7. A global guard (`timeoutMs` or `stagehand.domSettleTimeoutMs`,
457+ * default ≈ 30 s) ensures we always resolve; if it fires we log how many
458+ * requests were still outstanding.
459+ *
460+ * @param timeoutMs – Optional hard cap (ms). Defaults to
461+ * `this.stagehand.domSettleTimeoutMs`.
462+ */
463+ public async _waitForSettledDom ( timeoutMs ?: number ) : Promise < void > {
464+ const timeout = timeoutMs ?? this . stagehand . domSettleTimeoutMs ;
465+ const client = await this . getCDPClient ( ) ;
437466
438- await this . page . waitForLoadState ( "domcontentloaded" ) ;
467+ const hasDoc = ! ! ( await this . page . title ( ) . catch ( ( ) => false ) ) ;
468+ if ( ! hasDoc ) await this . page . waitForLoadState ( "domcontentloaded" ) ;
469+
470+ await client . send ( "Network.enable" ) ;
471+ await client . send ( "Page.enable" ) ;
472+ await client . send ( "Target.setAutoAttach" , {
473+ autoAttach : true ,
474+ waitForDebuggerOnStart : false ,
475+ flatten : true ,
476+ } ) ;
477+
478+ return new Promise < void > ( ( resolve ) => {
479+ const inflight = new Set < string > ( ) ;
480+ const meta = new Map < string , { url : string ; start : number } > ( ) ;
481+ const docByFrame = new Map < string , string > ( ) ;
482+
483+ let quietTimer : NodeJS . Timeout | null = null ;
484+ let stalledRequestSweepTimer : NodeJS . Timeout | null = null ;
485+
486+ const clearQuiet = ( ) => {
487+ if ( quietTimer ) {
488+ clearTimeout ( quietTimer ) ;
489+ quietTimer = null ;
490+ }
491+ } ;
439492
440- const timeoutPromise = new Promise < void > ( ( resolve ) => {
441- timeoutHandle = setTimeout ( ( ) => {
493+ const maybeQuiet = ( ) => {
494+ if ( inflight . size === 0 && ! quietTimer )
495+ quietTimer = setTimeout ( ( ) => resolveDone ( ) , 500 ) ;
496+ } ;
497+
498+ const finishReq = ( id : string ) => {
499+ if ( ! inflight . delete ( id ) ) return ;
500+ meta . delete ( id ) ;
501+ for ( const [ fid , rid ] of docByFrame )
502+ if ( rid === id ) docByFrame . delete ( fid ) ;
503+ clearQuiet ( ) ;
504+ maybeQuiet ( ) ;
505+ } ;
506+
507+ const onRequest = ( p : Protocol . Network . RequestWillBeSentEvent ) => {
508+ if ( p . type === "WebSocket" || p . type === "EventSource" ) return ;
509+
510+ inflight . add ( p . requestId ) ;
511+ meta . set ( p . requestId , { url : p . request . url , start : Date . now ( ) } ) ;
512+
513+ if ( p . type === "Document" && p . frameId )
514+ docByFrame . set ( p . frameId , p . requestId ) ;
515+
516+ clearQuiet ( ) ;
517+ } ;
518+
519+ const onFinish = ( p : { requestId : string } ) => finishReq ( p . requestId ) ;
520+ const onCached = ( p : { requestId : string } ) => finishReq ( p . requestId ) ;
521+ const onDataUrl = ( p : Protocol . Network . ResponseReceivedEvent ) =>
522+ p . response . url . startsWith ( "data:" ) && finishReq ( p . requestId ) ;
523+
524+ const onFrameStop = ( f : Protocol . Page . FrameStoppedLoadingEvent ) => {
525+ const id = docByFrame . get ( f . frameId ) ;
526+ if ( id ) finishReq ( id ) ;
527+ } ;
528+
529+ client . on ( "Network.requestWillBeSent" , onRequest ) ;
530+ client . on ( "Network.loadingFinished" , onFinish ) ;
531+ client . on ( "Network.loadingFailed" , onFinish ) ;
532+ client . on ( "Network.requestServedFromCache" , onCached ) ;
533+ client . on ( "Network.responseReceived" , onDataUrl ) ;
534+ client . on ( "Page.frameStoppedLoading" , onFrameStop ) ;
535+
536+ stalledRequestSweepTimer = setInterval ( ( ) => {
537+ const now = Date . now ( ) ;
538+ for ( const [ id , m ] of meta ) {
539+ if ( now - m . start > 2_000 ) {
540+ inflight . delete ( id ) ;
541+ meta . delete ( id ) ;
542+ this . stagehand . log ( {
543+ category : "dom" ,
544+ message : "⏳ forcing completion of stalled iframe document" ,
545+ level : 2 ,
546+ auxiliary : {
547+ url : {
548+ value : m . url . slice ( 0 , 120 ) ,
549+ type : "string" ,
550+ } ,
551+ } ,
552+ } ) ;
553+ }
554+ }
555+ maybeQuiet ( ) ;
556+ } , 500 ) ;
557+
558+ maybeQuiet ( ) ;
559+
560+ const guard = setTimeout ( ( ) => {
561+ if ( inflight . size )
442562 this . stagehand . log ( {
443563 category : "dom" ,
444- message : "DOM settle timeout exceeded, continuing anyway" ,
445- level : 1 ,
564+ message :
565+ "⚠️ DOM-settle timeout reached – network requests still pending" ,
566+ level : 2 ,
446567 auxiliary : {
447- timeout_ms : {
448- value : timeout . toString ( ) ,
568+ count : {
569+ value : inflight . size . toString ( ) ,
449570 type : "integer" ,
450571 } ,
451572 } ,
452573 } ) ;
453- resolve ( ) ;
454- } , timeout ) ;
455- } ) ;
456-
457- try {
458- await Promise . race ( [
459- this . page . evaluate ( ( ) => {
460- return new Promise < void > ( ( resolve ) => {
461- if ( typeof window . waitForDomSettle === "function" ) {
462- window . waitForDomSettle ( ) . then ( resolve ) ;
463- } else {
464- console . warn (
465- "waitForDomSettle is not defined, considering DOM as settled" ,
466- ) ;
467- resolve ( ) ;
468- }
469- } ) ;
470- } ) ,
471- this . page . waitForLoadState ( "domcontentloaded" ) ,
472- this . page . waitForSelector ( "body" ) ,
473- timeoutPromise ,
474- ] ) ;
475- } finally {
476- clearTimeout ( timeoutHandle ! ) ;
477- }
478- } catch ( e ) {
479- this . stagehand . log ( {
480- category : "dom" ,
481- message : "Error in waitForSettledDom" ,
482- level : 1 ,
483- auxiliary : {
484- error : {
485- value : e . message ,
486- type : "string" ,
487- } ,
488- trace : {
489- value : e . stack ,
490- type : "string" ,
491- } ,
492- } ,
493- } ) ;
494- }
574+ resolveDone ( ) ;
575+ } , timeout ) ;
576+
577+ const resolveDone = ( ) => {
578+ client . off ( "Network.requestWillBeSent" , onRequest ) ;
579+ client . off ( "Network.loadingFinished" , onFinish ) ;
580+ client . off ( "Network.loadingFailed" , onFinish ) ;
581+ client . off ( "Network.requestServedFromCache" , onCached ) ;
582+ client . off ( "Network.responseReceived" , onDataUrl ) ;
583+ client . off ( "Page.frameStoppedLoading" , onFrameStop ) ;
584+ if ( quietTimer ) clearTimeout ( quietTimer ) ;
585+ if ( stalledRequestSweepTimer ) clearInterval ( stalledRequestSweepTimer ) ;
586+ clearTimeout ( guard ) ;
587+ resolve ( ) ;
588+ } ;
589+ } ) ;
495590 }
496591
497592 async act (
0 commit comments