fix(pdf): navigation-tolerant readiness gate; skip screenshot poll for painted pages#837
Conversation
…r painted pages prepare()'s `auto` mode gated readiness on a screenshot poll (waitUntilAuto): screenshot -> isWhite? -> repeat. Two problems in the GPU-less fleet (llvmpipe): the screenshots are software-rasterized (seconds each), and the poll throws `Execution context was destroyed` / `Target closed` when a page client-navigates under it (SPAs re-commit their own URL after load), failing the whole render. Add `waitForReady` to @browserless/screenshot: a navigation-tolerant gate that resolves once the page is visually quiet (document height stable, every image decoded, readyState complete) held for `quietMs`, taking no screenshots. When `page.evaluate` throws on a destroyed context it resets the quiet window and keeps polling instead of failing. prepare() now runs waitForReady first; real painted content (decoded images in a document taller than the viewport) returns immediately, and only a page that settles still-blank falls back to the original screenshot poll, preserving the blank-SPA protection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a navigation-tolerant ChangesReadiness gate and PDF integration
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant PDFPrepare
participant WaitForReady
participant BrowserPage
participant ScreenshotCapture
PDFPrepare->>WaitForReady: wait for stable height, decoded images, and complete readyState
WaitForReady->>BrowserPage: evaluate readiness snapshot
BrowserPage-->>WaitForReady: snapshot or navigation error
WaitForReady-->>PDFPrepare: readiness result
PDFPrepare->>ScreenshotCapture: check for likely white screen
ScreenshotCapture->>BrowserPage: capture screenshot
BrowserPage-->>ScreenshotCapture: screenshot pixels
ScreenshotCapture-->>PDFPrepare: blank or non-white result
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/pdf/src/index.js (1)
93-138: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftFix global timeout leakage and redundant screenshots.
This block introduces two significant issues that degrade stability and performance:
- Global Timeout Leakage:
timePdfis initialized afterwaitForReadyand the first screenshot check. Since both of those operations can take up totimeoutmilliseconds, the global timeout can be massively exceeded before the fallback loop even starts tracking time.timePdfshould be initialized at the top ofwaitUntilAuto, and subsequent operations must use the remaining time.- Redundant Screenshots: If the initial single white-screen check correctly detects a blank page, the code falls into the
do-whileloop and immediately takes another screenshot without waiting. This duplicates an expensive operation.Refactoring to initialize the timer early, update the remaining timeout on each call, and use a
whileloop resolves both issues.🛠️ Proposed refactoring
async function waitUntilAuto (page) { await waitForDomStabilityResult(page) const timeout = goto.timeouts.action(rest.timeout) + const timePdf = timeSpan() // Cheap, navigation-tolerant readiness — no screenshots. Resolves once the // page is visually quiet (height stable, images decoded, load complete), // absorbing the client-side re-navigation that makes a screenshot poll // throw `Execution context was destroyed`. const readyTime = timeSpan() - const ready = await waitForReady(page, { timeout }) + const ready = await waitForReady(page, { timeout: Math.max(0, timeout - timePdf()) }) debug('ready', { ...ready, duration: readyTime() }) // Fast path: real painted content — decoded images in a document taller // than the viewport can't be a blank shell, so skip the screenshot poll. const viewportHeight = (page.viewport() || {}).height || 0 if (ready.decoded > 0 && ready.height > viewportHeight) return + if (timePdf() >= timeout) return + // Otherwise a single white-screen check, now that the page has settled (so // it won't race a navigation). A genuinely blank page falls back to the // original screenshot poll to keep the blank-SPA protection. const shot = await pReflect( captureWithNavigationRetry( () => page.screenshot({ ...rest, optimizeForSpeed: true, type: 'jpeg', quality: 30 }), - { page, goto, timeout } + { page, goto, timeout: Math.max(0, timeout - timePdf()) } ) ) - if (shot.isFulfilled && !(await isWhiteScreenshot(shot.value))) return - - let isWhite = true - let retry = -1 - const timePdf = timeSpan() - do { - ++retry - const screenshotTime = timeSpan() - const screenshot = await captureWithNavigationRetry( - () => - page.screenshot({ - ...rest, - optimizeForSpeed: true, - type: 'jpeg', - quality: 30 - }), - { page, goto, timeout } - ) - isWhite = await isWhiteScreenshot(screenshot) - if (isWhite) await goto.waitUntilAuto(page, { timeout: rest.timeout }) - debug('retry', { waitUntil, isWhite, retry, duration: screenshotTime() }) - } while (isWhite && timePdf() < timeout) + let isWhite = shot.isRejected || (await isWhiteScreenshot(shot.value)) + if (!isWhite) return + + let retry = 1 + while (isWhite && timePdf() < timeout) { + await goto.waitUntilAuto(page, { timeout: Math.max(0, timeout - timePdf()) }) + + const screenshotTime = timeSpan() + const screenshot = await captureWithNavigationRetry( + () => + page.screenshot({ + ...rest, + optimizeForSpeed: true, + type: 'jpeg', + quality: 30 + }), + { page, goto, timeout: Math.max(0, timeout - timePdf()) } + ) + isWhite = await isWhiteScreenshot(screenshot) + debug('retry', { waitUntil, isWhite, retry, duration: screenshotTime() }) + retry++ + } debug({ waitUntil, isWhite, timeout, duration: require('pretty-ms')(timePdf()) }) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/pdf/src/index.js` around lines 93 - 138, Update the waitUntilAuto flow around waitForReady and captureWithNavigationRetry to initialize timePdf before any readiness or screenshot work, pass the remaining timeout to each subsequent operation, and stop when that budget is exhausted. Replace the do-while fallback with a while loop so the first retry screenshot occurs only after the existing white-page wait, avoiding an immediate duplicate capture.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/screenshot/src/wait-for-ready.js`:
- Around line 31-35: Validate timeout at the start of waitForReady before
calculating deadline, and fail fast when it is missing or undefined instead of
allowing Date.now() + timeout to produce NaN. Preserve the existing behavior for
valid timeout values and the subsequent readiness loop.
---
Outside diff comments:
In `@packages/pdf/src/index.js`:
- Around line 93-138: Update the waitUntilAuto flow around waitForReady and
captureWithNavigationRetry to initialize timePdf before any readiness or
screenshot work, pass the remaining timeout to each subsequent operation, and
stop when that budget is exhausted. Replace the do-while fallback with a while
loop so the first retry screenshot occurs only after the existing white-page
wait, avoiding an immediate duplicate capture.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9097e869-3473-4c82-bbf7-b0217bcc20d8
📒 Files selected for processing (4)
packages/pdf/src/index.jspackages/screenshot/src/index.jspackages/screenshot/src/wait-for-ready.jspackages/screenshot/test/wait-for-ready.js
Address PR #837 review: - Fast path in `waitUntilAuto` now requires `!ready.timedOut`. A gate that timed out never settled, so its partial snapshot can't be trusted to skip the blank-SPA screenshot poll (Cursor Bugbot). - `waitForReady` fails fast on a non-number `timeout` instead of computing a NaN deadline and silently returning `timedOut: true` (CodeRabbit). Cleanups from /simplify: - Reuse `node:timers/promises` `setTimeout` instead of a hand-rolled `sleep`. - Collapse the standalone white-screen check into the poll loop's first iteration — it screenshotted with identical options and no page change in between, wasting a software-rasterized capture on the blank path. - Inline the unused `DEFAULT_READY_OPTS` constant/export into defaults. Adds a regression test for the missing-timeout guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/screenshot/src/wait-for-ready.js`:
- Line 30: Update the timeout validation in the wait-for-ready logic to use
Number.isFinite(timeout), rejecting NaN and Infinity while preserving the
existing TypeError message and behavior for invalid values.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 24e1cdd3-3ee8-46e9-897d-e2726ee3519c
📒 Files selected for processing (4)
packages/pdf/src/index.jspackages/screenshot/src/index.jspackages/screenshot/src/wait-for-ready.jspackages/screenshot/test/wait-for-ready.js
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/screenshot/test/wait-for-ready.js
- packages/pdf/src/index.js
The branch's `Test browserless` job has been red since the gate landed: the
`waitUntil: 'auto'` mocks predate `waitForReady` + `page.viewport()`. Their
shared `evaluate` returned `{ status: 'idle' }`, which the paint snapshot can
never satisfy, so `waitForReady` spun to the timeout; and the mock page had no
`viewport()`, throwing `page.viewport is not a function`.
- Add `viewport()` to each mock page.
- `scriptEvaluate` answers the dom-stability evaluate (has an arg) vs. the
readiness snapshot (no arg) with the right shape; imageless snapshots keep
the existing tests on the white-screen poll path.
- Add a fast-path test: painted content (decoded images, height > viewport)
prints with zero screenshots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address PR #837 review: - `waitForReady` only absorbs a torn-down execution context now, reusing `isContextDestroyed` from `@browserless/errors` (the same matcher `captureWithNavigationRetry` uses). Any other `page.evaluate` failure surfaces immediately instead of spinning to a misleading `timedOut` (Cursor Bugbot). - Guard with `Number.isFinite` so `NaN`/`Infinity` are rejected, not just non-numbers (CodeRabbit). - `waitUntilAuto` shares one action budget across the readiness gate and the screenshot poll, so worst-case prepare stays within a single `timeout` instead of one per stage (Cursor Bugbot). Adds a regression test asserting a non-navigation evaluate error rejects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the shared-budget change: capping total at one `timeout` let a slow readiness gate starve the blank-SPA screenshot poll (Cursor Bugbot). Cap the gate at `READY_BUDGET_RATIO` (half) of the action budget so the poll keeps a guaranteed reserve to re-wait, while total prepare still stays within one `timeout`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gate that times out with still-painted content must not take the painted fast path — it falls through to the screenshot poll. Covers the `ready.timedOut` branch added when the fast path was gated on settle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When `waitForReady` is called with a `timeout` at or below `quietMs`, the gate could never observe a full quiet window within its budget and would always time out (Cursor Bugbot, seen once the pdf gate was capped at half the action budget). Clamp `quietMs` to half the timeout so a small budget still resolves early instead of guaranteeing a timeout. The gate now owns this invariant, so any caller's timeout is safe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…image A blank shell with a decoded tracking pixel (or 1x1 beacon) and an inflated height would take the fast path and print blank, skipping the white-screen check (Cursor Bugbot). Split the snapshot signals: `decoded` still counts every loaded image (load-settled), and a new `painted` counts only images rendered at a visible size (>= 16x16). The fast path now requires `painted > 0`, so a lone tracking pixel no longer passes for real content while genuinely painted pages keep the no-screenshot fast path. Adds a regression test: a decoded-but-unpainted page still runs the white check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/screenshot/src/wait-for-ready.js`:
- Around line 18-28: Update the snapshot function’s painted-image calculation to
count only images that are visibly rendered in the viewport, excluding
visibility:hidden, opacity:0, and off-screen elements before incrementing
painted. Preserve decoded counting and the existing size threshold for eligible
visible images.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8d0445bb-5b85-4887-8111-acd0c0607ca9
📒 Files selected for processing (4)
packages/browserless/test/pdf.jspackages/pdf/src/index.jspackages/screenshot/src/wait-for-ready.jspackages/screenshot/test/wait-for-ready.js
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/screenshot/test/wait-for-ready.js
- packages/pdf/src/index.js
`painted` used bounding-box area alone, which still counts visibility:hidden, opacity:0, and off-screen images — a large but invisible image on a tall blank page could short-circuit the white-screen fallback (CodeRabbit). Require the image to intersect the viewport and pass `checkVisibility` (visibility/opacity, ancestors included), so `painted` means what a screenshot would actually see. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unit tests inject snapshots, so the in-page `snapshot()` — painted vs. decoded, viewport bounds, `checkVisibility` — only ran in production. Drive `waitForReady` against live pages to validate the exact heuristic under review: a visibly rendered image counts as painted; a 1x1 tracking pixel and a visibility:hidden image decode but don't; an imageless page paints nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The six auto-mode tests each re-declared an identical `goto` stub (same body,
same `response: {}`, same `timeouts.action`), varying only the action timeout
and whether `waitUntilAuto` bumps a counter. Extract `makeGoto({ action,
onWaitUntilAuto })` and call it per test. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`snapshot` skipped any `complete` image with `naturalWidth === 0` (a broken `<img>`), so it never counted toward `decoded` while `images` still did — `decoded >= images` could never hold and the gate ran to timeout, burning half the action budget before the poll (Cursor Bugbot). Count every image that finished loading toward `decoded` (the settle signal its comment already promised); keep the `naturalWidth` gate only for `painted`. Adds a real-browser regression test: a broken image is decoded=1, painted=0, and the gate still resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A lazy image beyond Chromium's fetch threshold never starts loading, so `decoded >= images` could never hold and the gate always burned half the action budget before falling back to the screenshot poll. Count only images expected to settle; lazy images within two viewports are inside the smallest threshold, so still waited on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Text-only pages never took the painted fast path, so they paid the readiness gate floor plus a software-rasterized screenshot. 200+ visible characters inside the viewport can't be a blank shell, with one exception: a pending `font-display: block` webfont renders text invisible, exactly when a capture would be white — so the fast path also requires `document.fonts` to be loaded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`page.viewport()` is null under `defaultViewport: null`, and the `|| 0` fallback silently dropped the taller-than-viewport guard. The snapshot now reports `window.innerHeight` alongside `height`, so both sides of the comparison come from the same in-page measurement and an unknown viewport skips the fast path instead of weakening it. Also dispatch the test evaluate stub on function identity instead of argument shape, so it can't misroute if a signature changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // beyond it, the fetch never starts. | ||
| if (img.loading === 'lazy' && img.getBoundingClientRect().top > vh * 2) continue | ||
| images++ | ||
| continue |
There was a problem hiding this comment.
Auto lazy images stall gate
High Severity
The settle gate only skips below-fold images when loading === 'lazy', but Chromium also defers offscreen images with the default loading="auto". Those incomplete images are still counted toward images without ever decoding during PDF prep, so decoded stays below images, the quiet condition never holds, and waitForReady burns its full budget on every long page before falling back to the screenshot poll.
Reviewed by Cursor Bugbot for commit 9003b31. Configure here.
There was a problem hiding this comment.
Not applicable to the Chromium versions this project ships. Automatic lazy-loading of loading="auto" images only ever existed behind Lite Mode / Data Saver on Android (LazyImageLoading feature), and was removed when Lite Mode was discontinued (M100, 2022). In current headless Chromium the default loading behavior is eager: offscreen images without an explicit loading="lazy" do start fetching immediately, flip complete, and satisfy decoded >= images. The gate's live test suite exercises exactly this (eager offscreen images resolve; only an explicit below-fold loading="lazy" image is excluded, wait-for-ready-live.js). Even in the hypothetical, the failure mode is bounded: the gate times out at 50% of the action budget and falls back to the screenshot poll — degraded latency, not a correctness break.
quietMs 600 put a ~750ms floor on every auto-mode PDF (~28% of the gate budget at the typical 30s timeout). Height stability, images decoded, and readyState carry most of the settle signal, so holding quiet for 300ms (floor ~450ms) loses little while cutting the floor by 40%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b496542. Configure here.
Two budget leaks flagged by review: the gate's poll sleep could overshoot its deadline by up to one full poll interval, and each screenshot capture handed `captureWithNavigationRetry` a fresh full action timeout instead of what remained of the shared budget — letting a navigation-racing capture push worst-case prepare to ~1.5x the action budget. Clamp the sleep to the deadline and pass the remaining budget to the retry loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hot fast path (#839) * fix(pdf): verify probe screenshot before skipping blank-SPA poll The #837 painted/text fast path trusted in-page DOM signals alone. A page with 200+ chars of SSR text (or a decoded hero image) under a fixed white loading overlay passed waitForReady and skipped the white-screen poll, shipping a blank PDF. Take one cheap probe capture when the gate reports painted content; only skip the poll loop when that capture is non-white. Adds a regression test for the overlay case. * fix(screenshot): detect covered content in readiness gate, keep zero-screenshot fast path A fixed opaque overlay (or white-on-white text) passes every DOM paint signal while a capture stays white, so PR #837's fast path could ship a blank PDF. PR #838 fixed it with a probe screenshot on every painted page, which pays a capture on the fast path and makes the branch equivalent to the poll loop's first iteration. Instead, detect the cover in the in-page snapshot itself: - `covered`: hit-test the counted content samples for an unrelated opaque viewport-filling layer, plus a root-level scan for `pointer-events: none` overlays hit-testing can't see - skip text whose color matches its effective background (invisible on capture) when counting the `text` paint signal - pdf fast path requires `!ready.covered`; the probe screenshot is removed, so a clean painted page takes zero captures again Covered or suspicious pages fall through to the existing blank-SPA screenshot poll, where a capture verifies ground truth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>


Problem
prepare()'sautoreadiness gates on a screenshot poll (waitUntilAuto): screenshot →isWhite?→ repeat. In the GPU-less fleet (llvmpipe) this has two failure modes:Execution context was destroyed, most likely because of a navigation/Page.captureScreenshot: Target closedwhen a page client-navigates under it. Many pages (webtoon readers, scribd) re-commit their own URL right afterload, so the capture races the re-nav and the whole render fails.Downstream (microlink-api) this surfaces as PDF requests silently falling back to a full single-shot render and timing out at 28s.
Change
Add
waitForReadyto@browserless/screenshot: a navigation-tolerant gate that resolves once the page is visually quiet — document height stable, every image decoded,readyState === 'complete'— held forquietMs, taking no screenshots. Whenpage.evaluatethrows on a destroyed context it resets the quiet window and keeps polling instead of failing.prepare()now runswaitForReadyfirst. Real painted content (decoded images in a document taller than the viewport) returns immediately; only a page that settles still-blank falls back to the original screenshot poll, so the blank-SPA protection is preserved.Validation
waitForReadyis additive.🤖 Generated with Claude Code
Note
Medium Risk
Changes core PDF readiness timing and fast-path heuristics for all
waitUntil: 'auto'renders; misclassification could skip blank checks or still time out, but behavior is heavily tested and the screenshot fallback remains.Overview
Adds
waitForReadyin@browserless/screenshot: a screenshot-free gate that polls in-page paint/settle signals (stable height, images decoded,complete, visible images/text/fonts) and absorbs SPA navigations by resetting on destroyed execution contexts instead of failing.PDF
prepare()inautomode now runswaitForReadyfirst (half the action timeout), then skips the white-screenshot poll when the gate settles with real painted content (visible images, ≥200 chars of visible text with fonts loaded, tall document)—with guards for tracking pixels, pending webfonts, and timed-out gates. Still-blank pages keep the existing screenshot poll; gate + poll + capture retries share one timeout budget so worst-case prepare cannot exceed a singletimeout.Exports
waitForReadyfor reuse. Tests cover the gate (unit + live DOM), PDF fast-path/skip cases, and shared-budget behavior.Reviewed by Cursor Bugbot for commit e2b9d4f. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit