From 43b526e04968485363a65c69850747490083dfc2 Mon Sep 17 00:00:00 2001 From: bestmountain Date: Fri, 28 Aug 2026 17:48:51 +0800 Subject: [PATCH 1/3] fix: preserve context in WaitRepaint --- element_test.go | 11 +++++++++++ page.go | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/element_test.go b/element_test.go index e10eb7c4..9b23addc 100644 --- a/element_test.go +++ b/element_test.go @@ -709,6 +709,17 @@ func TestWaitStableRAP(t *testing.T) { g.Err(el.WaitStableRAF()) } +func TestWaitRepaintHonorsPageContext(t *testing.T) { + g := setup(t) + ctx, cancel := context.WithCancel(g.page.GetContext()) + cancel() + + err := g.page.Context(ctx).WaitRepaint() + if !errors.Is(err, context.Canceled) { + t.Fatalf("WaitRepaint error = %v, want context.Canceled", err) + } +} + func TestCanvasToImage(t *testing.T) { g := setup(t) diff --git a/page.go b/page.go index f646ecea..0114e4bd 100644 --- a/page.go +++ b/page.go @@ -848,7 +848,10 @@ func (p *Page) WaitIdle(timeout time.Duration) (err error) { // Doc: https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame func (p *Page) WaitRepaint() error { // we use root here because iframe doesn't trigger requestAnimationFrame - _, err := p.root.Eval(`() => new Promise(r => requestAnimationFrame(r))`) + // but the root evaluation must still inherit the caller's context. Otherwise + // Page.Context/Timeout cannot cancel this wait when the root page is hidden + // or keeps repainting indefinitely. + _, err := p.root.Context(p.ctx).Eval(`() => new Promise(r => requestAnimationFrame(r))`) return err } From 4bb7c0135fad9b0e92a72823f51f55269af23be3 Mon Sep 17 00:00:00 2001 From: bestmountain Date: Fri, 28 Aug 2026 18:16:40 +0800 Subject: [PATCH 2/3] test: cover WaitStableRAF background tab cancellation --- element_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/element_test.go b/element_test.go index 9b23addc..dc8635fc 100644 --- a/element_test.go +++ b/element_test.go @@ -2,6 +2,7 @@ package rod_test import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -10,6 +11,7 @@ import ( "net" "os" "path/filepath" + "sync" "testing" "time" @@ -720,6 +722,81 @@ func TestWaitRepaintHonorsPageContext(t *testing.T) { } } +const waitStableRAFDemoPage = ` + + +` + +func TestWaitStableRAFOnBackgroundPageHonorsContext(t *testing.T) { + g := setup(t) + target := g.newPage(g.html(waitStableRAFDemoPage)).MustWaitLoad() + other := g.newPage(g.blank()).MustWaitLoad() + other.MustActivate() + + // Freeze the hidden target so requestAnimationFrame cannot resolve. The + // operation must still return when its caller context expires. + if err := (proto.PageSetWebLifecycleState{State: proto.PageSetWebLifecycleStateStateFrozen}).Call(target); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + err := target.MustElement("#target").Context(ctx).WaitStableRAF() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("background WaitStableRAF error = %v, want context deadline exceeded", err) + } +} + +func TestWaitStableRAFSurvivesFrequentActivationSwitches(t *testing.T) { + g := setup(t) + target := g.newPage(g.html(waitStableRAFDemoPage)).MustWaitLoad() + other := g.newPage(g.blank()).MustWaitLoad() + + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- target.MustElement("#target").Context(ctx).WaitStableRAF() + }() + + done := make(chan struct{}) + var switchers sync.WaitGroup + switchers.Add(1) + go func() { + defer switchers.Done() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + _, _ = target.Activate() + _, _ = other.Activate() + case <-done: + return + } + } + }() + defer func() { + close(done) + switchers.Wait() + }() + + select { + case err := <-errCh: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("WaitStableRAF during activation switches error = %v, want context deadline exceeded", err) + } + case <-time.After(2 * time.Second): + t.Fatal("WaitStableRAF remained blocked during frequent activation switches") + } +} + func TestCanvasToImage(t *testing.T) { g := setup(t) From 48b50af6175ed535fbbbfd54ac2f14cfb4818da5 Mon Sep 17 00:00:00 2001 From: bestmountain Date: Fri, 28 Aug 2026 18:20:31 +0800 Subject: [PATCH 3/3] test: reproduce background WaitStableRAF regression --- element_test.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/element_test.go b/element_test.go index dc8635fc..9b163949 100644 --- a/element_test.go +++ b/element_test.go @@ -747,9 +747,17 @@ func TestWaitStableRAFOnBackgroundPageHonorsContext(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() - err := target.MustElement("#target").Context(ctx).WaitStableRAF() - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("background WaitStableRAF error = %v, want context deadline exceeded", err) + errCh := make(chan error, 1) + go func() { + errCh <- target.MustElement("#target").Context(ctx).WaitStableRAF() + }() + select { + case err := <-errCh: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("background WaitStableRAF error = %v, want context deadline exceeded", err) + } + case <-time.After(1 * time.Second): + t.Fatal("background WaitStableRAF remained blocked; this reproduces the pre-fix root-context regression") } }