@@ -30,6 +30,7 @@ import {
30
30
} from "../types/stagehandErrors" ;
31
31
import { StagehandAPIError } from "@/types/stagehandApiErrors" ;
32
32
import { scriptContent } from "@/lib/dom/build/scriptContent" ;
33
+ import type { Protocol } from "devtools-protocol" ;
33
34
34
35
export class StagehandPage {
35
36
private stagehand : Stagehand ;
@@ -427,71 +428,165 @@ ${scriptContent} \
427
428
return this . intContext . context ;
428
429
}
429
430
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 ( ) ;
437
466
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
+ } ;
439
492
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 )
442
562
this . stagehand . log ( {
443
563
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 ,
446
567
auxiliary : {
447
- timeout_ms : {
448
- value : timeout . toString ( ) ,
568
+ count : {
569
+ value : inflight . size . toString ( ) ,
449
570
type : "integer" ,
450
571
} ,
451
572
} ,
452
573
} ) ;
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
+ } ) ;
495
590
}
496
591
497
592
async act (
0 commit comments