Skip to content

fix(pdf): navigation-tolerant readiness gate; skip screenshot poll for painted pages#837

Merged
Kikobeats merged 17 commits into
masterfrom
fix/pdf-readiness-gate
Jul 15, 2026
Merged

fix(pdf): navigation-tolerant readiness gate; skip screenshot poll for painted pages#837
Kikobeats merged 17 commits into
masterfrom
fix/pdf-readiness-gate

Conversation

@Kikobeats

@Kikobeats Kikobeats commented Jul 14, 2026

Copy link
Copy Markdown
Member

Problem

prepare()'s auto readiness gates on a screenshot poll (waitUntilAuto): screenshot → isWhite? → repeat. In the GPU-less fleet (llvmpipe) this has two failure modes:

  • Slow. Screenshots are software-rasterized (seconds each), so readiness alone burns 7–14s even for tiny documents.
  • Fragile. The poll throws Execution context was destroyed, most likely because of a navigation / Page.captureScreenshot: Target closed when a page client-navigates under it. Many pages (webtoon readers, scribd) re-commit their own URL right after load, 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 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; only a page that settles still-blank falls back to the original screenshot poll, so the blank-SPA protection is preserved.

Validation

  • 4 deterministic unit tests for the gate (quiet→resolve, nav-reset→resolve, never-settles→timeout, imageless page).
  • Real-seam A/B on a problem URL: the new path yields a complete, non-blank, deterministic PDF (25 pages both runs) where the old poll captured early and varied (23 pages, size swinging run-to-run).
  • No API-surface change; waitForReady is 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 waitForReady in @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() in auto mode now runs waitForReady first (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 single timeout.

Exports waitForReady for 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

  • Improvements
    • Added a navigation-tolerant “visual quiet” readiness gate to determine when screenshot-based blank-page protection is necessary.
    • Unified readiness and fallback timing so the overall timeout budget is respected.
    • Exposed the readiness helper for reuse by other components.
  • Bug Fixes
    • Reduced unnecessary screenshot polling when meaningful painted content is already stable.
  • Tests
    • Expanded readiness and PDF flow coverage (SPA navigation recovery, timeout handling, imageless readiness, and painted fast-path screenshot skipping).

…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>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a navigation-tolerant waitForReady polling helper, exports it from the screenshot package, and integrates it into PDF waitUntil: 'auto' handling before blank-page screenshot retries. Tests cover readiness states, navigation resets, timeouts, and painted-content fast paths.

Changes

Readiness gate and PDF integration

Layer / File(s) Summary
Readiness polling contract and validation
packages/screenshot/src/wait-for-ready.js, packages/screenshot/src/index.js, packages/screenshot/test/wait-for-ready.js
Adds readiness snapshots with painted-image tracking, quiet-window polling, navigation recovery, public exports, and deterministic tests for success, resets, timeout, validation, and imageless pages.
PDF auto readiness and blank-page fallback
packages/pdf/src/index.js, packages/browserless/test/pdf.js
Uses a budgeted readiness gate for painted-content early returns, shares the timeout with white-screen retries, and tests painted, pixel-only, and timed-out readiness scenarios.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main PDF readiness-gating change and the painted-page screenshot skip.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pdf-readiness-gate

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread packages/pdf/src/index.js Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lift

Fix global timeout leakage and redundant screenshots.

This block introduces two significant issues that degrade stability and performance:

  1. Global Timeout Leakage: timePdf is initialized after waitForReady and the first screenshot check. Since both of those operations can take up to timeout milliseconds, the global timeout can be massively exceeded before the fallback loop even starts tracking time. timePdf should be initialized at the top of waitUntilAuto, and subsequent operations must use the remaining time.
  2. Redundant Screenshots: If the initial single white-screen check correctly detects a blank page, the code falls into the do-while loop 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 while loop 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

📥 Commits

Reviewing files that changed from the base of the PR and between fea7396 and 44d624d.

📒 Files selected for processing (4)
  • packages/pdf/src/index.js
  • packages/screenshot/src/index.js
  • packages/screenshot/src/wait-for-ready.js
  • packages/screenshot/test/wait-for-ready.js

Comment thread packages/screenshot/src/wait-for-ready.js Outdated
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>
Comment thread packages/screenshot/src/wait-for-ready.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 44d624d and 0c1f873.

📒 Files selected for processing (4)
  • packages/pdf/src/index.js
  • packages/screenshot/src/index.js
  • packages/screenshot/src/wait-for-ready.js
  • packages/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

Comment thread packages/screenshot/src/wait-for-ready.js Outdated
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>
Comment thread packages/pdf/src/index.js
@coveralls

coveralls commented Jul 14, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 82.052% (-1.0%) from 83.062% — fix/pdf-readiness-gate into master

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>
Comment thread packages/pdf/src/index.js
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>
Comment thread packages/pdf/src/index.js
Kikobeats and others added 2 commits July 14, 2026 22:14
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>
Comment thread packages/pdf/src/index.js Outdated
…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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8cbe46a and 5f6b7b5.

📒 Files selected for processing (4)
  • packages/browserless/test/pdf.js
  • packages/pdf/src/index.js
  • packages/screenshot/src/wait-for-ready.js
  • packages/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

Comment thread packages/screenshot/src/wait-for-ready.js
Kikobeats and others added 3 commits July 14, 2026 22:36
`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>
Comment thread packages/screenshot/src/wait-for-ready.js
Kikobeats and others added 4 commits July 15, 2026 09:20
`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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9003b31. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ 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.

Comment thread packages/screenshot/src/wait-for-ready.js Outdated
Comment thread packages/pdf/src/index.js
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>
@Kikobeats
Kikobeats merged commit b599f2e into master Jul 15, 2026
20 of 22 checks passed
@Kikobeats
Kikobeats deleted the fix/pdf-readiness-gate branch July 15, 2026 16:42
Kikobeats added a commit that referenced this pull request Jul 16, 2026
…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>
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.

2 participants