Skip to content

Use captureBeyondViewport for full-page screenshots instead of window resize#580

Merged
route merged 2 commits into
rubycdp:mainfrom
mickeytgl:fix-full-page-screenshot-focus-steal
Apr 22, 2026
Merged

Use captureBeyondViewport for full-page screenshots instead of window resize#580
route merged 2 commits into
rubycdp:mainfrom
mickeytgl:fix-full-page-screenshot-focus-steal

Conversation

@mickeytgl
Copy link
Copy Markdown
Contributor

@mickeytgl mickeytgl commented Apr 21, 2026

Problem

Page#screenshot(full: true) resizes the window to fullscreen and back (maybe_resize_fullscreen). On macOS Chrome this triggers native fullscreen so the window steals focus from whatever app you're in if you're working while letting an agent run a task on your browser (this is how I found the issue).

Fix

Pass captureBeyondViewport: true to Page.captureScreenshot and drop the resize. CDP-native since Chrome 81; same flag Playwright and puppeteer use for full-page screenshots. Output is the same, one CDP round-trip instead of three, no window-state mutation.

Replaces the window-resize hack in capture_screenshot with CDP's
native captureBeyondViewport: true flag on Page.captureScreenshot.

The previous approach called Browser.setWindowBounds to put the
window into windowState: "fullscreen" before capture and back to
"normal" in an ensure block. On macOS Chrome this triggers *native*
fullscreen, moving the window to its own Space and stealing focus
from the user's foreground app on every full-page screenshot.

captureBeyondViewport has been stable in Chrome since v81 and is the
same flag Playwright and puppeteer use for their fullPage path.
Output is pixel-identical, one CDP round-trip instead of three, no
window-state mutation.

Also implicitly fixes rubycdp#178 — huge pages that returned blank under the
resize approach (because the window can't grow past the display size)
now render correctly, since the native path has no viewport ceiling.
@route
Copy link
Copy Markdown
Member

route commented Apr 22, 2026

Very nice! Mind adding changelog entry? And we are good to go!

@mickeytgl
Copy link
Copy Markdown
Contributor Author

No problem! Done ✅

@route route merged commit 25367b5 into rubycdp:main Apr 22, 2026
7 checks passed
@route
Copy link
Copy Markdown
Member

route commented Apr 22, 2026

Thank you so much!

@brendon
Copy link
Copy Markdown

brendon commented May 29, 2026

Just wondering if this change is why I've started ending up with a much smaller viewport size when exporting a screenshot? Previously page.set_viewport(width: 1280, height: 720) would work but now it does't.

@route
Copy link
Copy Markdown
Member

route commented May 29, 2026

@brendon it's hard to say without small reproduction test, can you come up with one?

@brendon
Copy link
Copy Markdown

brendon commented May 29, 2026

Yep, I'll have a go next week. I think it may be due to using a newer version of Browserless as it seems to be ignoring other options like cpu pinning and queue size.

@brendon
Copy link
Copy Markdown

brendon commented Jun 2, 2026

I got Claude to fix things for me (it worked). Here's the report it gave of why the bug happened. Hopefully it's reproducible by you. Let me know if this is unrelated to this PR and I'll open a new issue:

set_viewport is lost after a cross-process navigation, silently truncating screenshot(full: false)

Environment

  • ferrum 0.17.2
  • Chromium 148.0.7778.96 (reproduced via browserless/chromium v2.50.0; also reproducible with a locally-launched browser whose window is smaller than the requested viewport)
  • CDP 1.3

Summary

A device-metrics override applied via set_viewport before the first navigation is discarded when that navigation performs a cross-process swap (e.g. an apex→www or http→https redirect). After the swap, window.innerWidth/innerHeight revert to the browser's real window size. Because screenshot(full: false) computes its clip from live window.innerWidth/innerHeight (viewport_area), the resulting capture is silently clipped to the smaller window — you get only the top-left corner of the viewport you asked for, at correct-looking but wrong dimensions.

Puppeteer and Playwright both persist viewport emulation by re-applying it after navigations; Ferrum does not, which is the underlying gap.

Reproduction

require "ferrum"

# A window smaller than the requested viewport makes the bug visible.
# (Remote browsers like browserless default to 800x600, so it always shows there.)
browser = Ferrum::Browser.new(window_size: [800, 600])
page = browser.create_page

page.set_viewport(width: 1280, height: 720)

# First navigation ends on a different origin (host or scheme change) -> renderer swap.
page.go_to("http://google.com") # -> https://www.google.com

p page.evaluate("[window.innerWidth, window.innerHeight]")
#   expected: [1280, 720]
#   actual:   [800, 600]   <- the device-metrics override was discarded

page.screenshot(path: "out.png", full: false)
#   expected: a 1280x720 capture
#   actual:   only the top-left 800x600 region

browser.quit

Expected

The viewport set via set_viewport persists across navigations (as in Puppeteer/Playwright), and screenshot(full: false) captures the full requested viewport.

Actual

The override is dropped on the cross-process navigation; the screenshot is clipped to the real window size.

Evidence (CDP trace)

In a real run, the two commands disagree — the override is set correctly but the capture clip is computed from the post-navigation innerWidth/innerHeight:

Emulation.setDeviceMetricsOverride  width=1280 height=720  deviceScaleFactor=0
Page.captureScreenshot              clip={x:0,y:0,width:800,height:600,scale:1.0}

Workaround (for others hitting this)

Re-assert the viewport after navigation, immediately before the screenshot. Note that clearing first is required — re-sending setDeviceMetricsOverride with identical params after the visual viewport has reset is a no-op in Chrome, so a plain set_viewport with the same values does nothing:

page.command("Emulation.clearDeviceMetricsOverride")
page.set_viewport(width: 1280, height: 720, scale_factor: 1)

Suggested fix

  1. Track the active viewport on the page and re-apply it after navigations / renderer swaps (mirroring Puppeteer/Playwright's emulation persistence). Because re-sending identical override params is a no-op, the re-apply should clear first (or only fire when the live viewport no longer matches the requested one).
  2. Alternatively/additionally, have screenshot(full: false) clip to the explicitly-requested viewport rather than live window.innerWidth/innerHeight.

@brendon
Copy link
Copy Markdown

brendon commented Jun 2, 2026

I think the bug has occurred because Browserless changed their default viewport size at some point recently thought I can't see it in the changelog.

@brendon
Copy link
Copy Markdown

brendon commented Jun 2, 2026

I think this is probably best dealt with in a seperate issue but here's the root source of the change in behaviour (Claude):

Root cause: Chrome's RenderDocument discards emulation on navigation; Ferrum doesn't re-apply it

After more digging, this isn't a browserless or Chrome-version bug — it's a gap in Ferrum, newly exposed by a Chrome feature rollout.

What happens

set_viewport issues Emulation.setDeviceMetricsOverride, which is per-document CDP state. Chrome's RenderDocument feature swaps to a fresh document (new RenderFrameHost) on navigation, which discards that override. So a viewport set before go_to is silently lost after the page navigates/redirects — window.innerWidth/innerHeight revert to the browser's real window size (e.g. 800×600).

Because screenshot(full: false) computes its capture clip from live window.innerWidth/innerHeight (viewport_area), the result is silently clipped to that smaller window — a top-left crop at plausible-but-wrong dimensions.

Why it surfaced now

RenderDocument is rolling out in Chrome, and in puppeteer-launched setups it became active when puppeteer-core 24.41.0 stopped disabling it (puppeteer#14745, "remove RenderDocument from disabled Chrome features"). Previously the override happened to survive navigation, so the missing re-apply was invisible. As RenderDocument becomes the default more broadly, this will hit more Ferrum users.

Puppeteer and Playwright both re-apply viewport emulation after each navigation — Ferrum does not, which is the underlying gap.

Reproduction

require "ferrum"
# window smaller than the requested viewport makes the crop visible
browser = Ferrum::Browser.new(window_size: [800, 600])
page = browser.create_page
page.set_viewport(width: 1280, height: 720)
page.go_to("http://google.com")   # ends on a different origin -> document swap

p page.evaluate("[window.innerWidth, window.innerHeight]")
# expected: [1280, 720]   actual: [800, 600]  (override discarded)

page.screenshot(path: "out.png", full: false)
# expected: 1280x720       actual: top-left 800x600

Suggested fix

  1. Track the active viewport on the page and re-apply it after navigations (e.g. on Page.frameNavigated for the main frame / new-document events), matching Puppeteer/Playwright. Note: re-sending setDeviceMetricsOverride with identical params after the reset is a no-op in Chrome, so the re-apply should clear first (Emulation.clearDeviceMetricsOverride) or only fire when the live viewport no longer matches the requested one.
  2. Optionally, have screenshot(full: false) clip to the explicitly-requested viewport rather than live window.innerWidth/innerHeight.

Workaround for others

Re-assert the viewport right before the screenshot:

page.command("Emulation.clearDeviceMetricsOverride")   # clear is required — identical re-set is a no-op
page.set_viewport(width: 1280, height: 720, scale_factor: 1)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants