Skip to content

fix(pdf): wait for the document to render before printing it#851

Closed
Kikobeats wants to merge 1 commit into
masterfrom
fix/pdf-empty-shell
Closed

fix(pdf): wait for the document to render before printing it#851
Kikobeats wants to merge 1 commit into
masterfrom
fix/pdf-empty-shell

Conversation

@Kikobeats

@Kikobeats Kikobeats commented Jul 23, 2026

Copy link
Copy Markdown
Member

An app shell that has not rendered anything yet satisfies every settle signal the readiness gate has, so prepare returns and prints the placeholders.

On a report that renders nothing for ~1.5s, the gate resolved at 458ms:

ready height=800 viewport=800 images=0 decoded=0 painted=0 text=0
      covered=false fonts=true complete=true resets=0 timedOut=false

Why every signal says "ready"

packages/screenshot/src/wait-for-ready.js:

const quiet = snap.complete && imagesDecoded && snap.height === lastHeight
signal value why
complete true the shell's HTML arrived; the data is still in flight
imagesDecoded true zero images, so vacuously true
height === lastHeight true pinned at 633 forever

The third one is the trap. This app scrolls an inner overflow:auto pane rather than the document, so the document height never moves — not while loading, not after. Height stability, the strongest settle signal, holds from the first poll and never stops holding.

The blank-page fallback does not catch it either: it asks only isWhiteScreenshot, and grey skeletons on white are not white.

Why painted / text cannot be the fix

They did report zero, and my first attempt added them to the gate's quiet condition. That is wrong: both are viewport-scoped by design, so a page whose content merely starts below the fold reads zero for the same reason while being perfectly ready. The existing suite says so directly, and turned red in five places:

✘ below-viewport text does not count toward the text signal
✘ white-on-white text does not count toward the text signal
✘ hidden text does not count toward the text signal
✘ an imageless page is ready on height/readyState quiet alone
✘ no document root: the snapshot degrades to zeros

Requiring viewport-scoped content in the shared gate makes every such page burn its budget. Reverted.

What this does instead

Ask a different question — document-wide rather than viewport-scoped — and ask it before the gate: does the document carry anything at all, anywhere?

const hasDocumentContent = () => {
  const body = document.body
  if (!body) return false
  if (body.querySelector('img,svg,canvas,video,table,input,button,picture')) return true
  return (body.innerText || '').trim().length > 0
}

Poll until it does or the budget runs out. Cheap element probe first, innerText only if that misses.

page content wait
ordinary page 1ms (answers on the first evaluate)
the report 1565ms, then prints what it actually renders

Tests

The pdf package had test: exit 0 and no test directory, so it gains one here. packages/pdf/test/empty-shell.js:

  • waits for a shell to render before printing it — a shell laid out to defeat every settle signal (complete HTML, no images, inner-pane scroll so height is fixed). Asserts the document height provably cannot signal arrival, then that prepare returned only once real content existed. Disabling the wait turns this red.
  • gives up on a shell that never renders — bounded by the budget, does not hang.
  • does not delay a page that already has content — no tax on ordinary renders.

Screenshot suite unchanged: 44 passing. Lint clean.

Half of a fix

This is one of the two things that report needs. The other is #850, walking it to load what it defers until scrolled into view. Measured in isolation:

neither          0 skeletons,  0 svg,      0 chars   (blank page)
scroll only     38 skeletons,  0 svg,      0 chars
this PR only   120 skeletons, 22 svg,  2,091 chars
both             0 skeletons, 97 svg, 11,479 chars   (correct)

Verified together on the real report: 8 pages, 729 kB, no placeholders, in 4.3s.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DdKhHYtYcxYyxaGnZnuJxU


Note

Medium Risk
Changes core PDF prepare timing for all waitUntil: 'auto' renders; bounded budget limits hang risk but could still affect slow or minimal-DOM pages.

Overview
Fixes PDFs printing empty app shells when waitUntil: 'auto' treats the page as settled before real UI appears (e.g. complete HTML, no images, fixed document height with inner scroll).

prepare / waitUntilAuto now runs a document-wide content poll (hasDocumentContent + waitForDocumentContent) before waitForReady, using ~25% of the load budget and 150ms polls. It looks for substantive elements or any trimmed innerText anywhere in the body—not viewport-scoped painted/text signals—so below-the-fold-ready pages are not broken.

The @browserless/pdf package switches from a no-op test script to AVA with empty-shell.js: wait for delayed shell content, bounded timeout when content never arrives, and no extra delay when content is already present.

Reviewed by Cursor Bugbot for commit 9d07766. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Bug Fixes

    • Improved PDF generation for pages that load content dynamically, helping ensure rendered documents include the finished content rather than placeholders or blank sections.
    • Added safeguards so PDF creation continues within a reasonable time when page content never becomes available.
    • Pages that already contain content continue without unnecessary waiting.
  • Tests

    • Added coverage for delayed content, incomplete pages, and immediately available content.

An app shell that has not rendered anything yet satisfies every settle signal
the readiness gate has, so `prepare` returned and printed the placeholders.

On a report that renders nothing for ~1.5s the gate resolved at 458ms:

  ready height=800 viewport=800 images=0 decoded=0 painted=0 text=0
        covered=false fonts=true complete=true resets=0 timedOut=false

Each signal is individually correct and collectively useless here. The shell's
HTML arrived, so `readyState` is `complete`. It ships no images, so there is
nothing left to decode. And the app scrolls an inner pane rather than the
document, so the document height is pinned to the viewport and stays there
forever — height stability, the strongest settle signal, holds from the first
poll and never stops holding.

`painted` and `text` did report zero, but they cannot be used to catch this:
both are viewport-scoped by design, and a page whose content merely starts below
the fold reads zero for exactly the same reason while being perfectly ready.
Requiring them in the gate makes every such page burn its budget, which its own
tests assert against.

So ask a different question, document-wide rather than viewport-scoped, and ask
it before the gate: does the document carry anything at all? Poll until it does
or the budget runs out. A page that already has content answers on the first
evaluate — measured at 1ms — while the report waits 1.565s and then prints what
it actually renders.

This is one half of that report printing correctly; the other is walking it to
load what it defers until scrolled into view. With both, it goes from 120
skeleton placeholders to 0, and from 2,091 to 11,479 characters of real content.

The pdf package had no test setup, so it gains one here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdKhHYtYcxYyxaGnZnuJxU
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PDF package now detects meaningful DOM content during waitUntilAuto, polling within a bounded budget before continuing readiness handling. AVA-based tests cover delayed shells, empty shells, and pages with immediate content.

Changes

PDF content readiness

Layer / File(s) Summary
Document-content polling and readiness wiring
packages/pdf/src/index.js
Adds DOM content detection, bounded polling with sleep, and integration into waitUntilAuto.
Shell readiness tests and test command
packages/pdf/test/empty-shell.js, packages/pdf/package.json
Adds AVA coverage for delayed, empty, and immediately populated pages, and configures the package test script and development dependencies.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant createPdf
  participant waitUntilAuto
  participant PageDOM
  createPdf->>waitUntilAuto: automatic readiness check
  waitUntilAuto->>PageDOM: evaluate document content
  PageDOM-->>waitUntilAuto: content detected or timeout
  waitUntilAuto-->>createPdf: continue PDF generation
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: PDF printing now waits for document content before rendering.
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.
✨ 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-empty-shell

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
packages/pdf/package.json

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'


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.

@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

🧹 Nitpick comments (1)
packages/pdf/test/empty-shell.js (1)

57-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the timing assertions prove the polling contract.

The current bounds allow the prior premature-empty-shell behavior and a full content-budget delay for immediately populated pages to pass.

  • packages/pdf/test/empty-shell.js#L57-L64: assert that the empty shell waits for a meaningful portion of its configured content budget, as well as completing within a bounded upper limit.
  • packages/pdf/test/empty-shell.js#L69-L79: use an upper bound below the full content-wait budget so a false-negative initial probe cannot pass after waiting unnecessarily.
🤖 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/test/empty-shell.js` around lines 57 - 64, Strengthen the timing
assertions in the empty-shell tests: at packages/pdf/test/empty-shell.js lines
57-64, require elapsed time to exceed a meaningful portion of the configured
content budget while retaining the bounded upper limit; at lines 69-79, set the
upper-bound assertion below the full content-wait budget so an unnecessary
false-negative initial probe cannot pass. Use the existing timing variables and
test setup without changing the rendering behavior.
🤖 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/pdf/src/index.js`:
- Around line 62-66: Update hasDocumentContent to ignore hidden or non-rendered
elements returned by the body querySelector check. Only treat a matched input,
button, svg, or other candidate element as content when it is renderable, while
preserving the existing innerText fallback for visible textual content.

---

Nitpick comments:
In `@packages/pdf/test/empty-shell.js`:
- Around line 57-64: Strengthen the timing assertions in the empty-shell tests:
at packages/pdf/test/empty-shell.js lines 57-64, require elapsed time to exceed
a meaningful portion of the configured content budget while retaining the
bounded upper limit; at lines 69-79, set the upper-bound assertion below the
full content-wait budget so an unnecessary false-negative initial probe cannot
pass. Use the existing timing variables and test setup without changing the
rendering behavior.
🪄 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: 461fd973-4543-4427-9856-8847e085b42f

📥 Commits

Reviewing files that changed from the base of the PR and between b460374 and 9d07766.

📒 Files selected for processing (3)
  • packages/pdf/package.json
  • packages/pdf/src/index.js
  • packages/pdf/test/empty-shell.js

Comment thread packages/pdf/src/index.js
Comment on lines +62 to +66
const hasDocumentContent = () => {
const body = document.body
if (!body) return false
if (body.querySelector('img,svg,canvas,video,table,input,button,picture')) return true
return (body.innerText || '').trim().length > 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore non-rendered elements in the content predicate.

A shell containing a hidden input, button, or decorative svg immediately returns true, so it can still bypass the wait and print before actual report content exists. Require matched elements to be renderable before treating them as document content.

🤖 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 62 - 66, Update hasDocumentContent to
ignore hidden or non-rendered elements returned by the body querySelector check.
Only treat a matched input, button, svg, or other candidate element as content
when it is renderable, while preserving the existing innerText fallback for
visible textual content.

@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 80.564% (-0.06%) from 80.619% — fix/pdf-empty-shell into master

@Kikobeats

Copy link
Copy Markdown
Member Author

Superseded by #852 (merged as v13.6.9).

That PR covers the same empty-shell / SPA report case with the full readiness + overflow prepare path (waitForReady / isPageReady / hydrate scroll / prepareFullDocument), so this narrower waitForDocumentContent approach should not be merged on top.

@Kikobeats Kikobeats closed this Jul 25, 2026
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