Skip to content

fix(layout): don't squash a container's sole child at a page break (#3449)#3450

Open
billinspiratobell wants to merge 2 commits into
diegomura:masterfrom
billinspiratobell:fix/pagination-page-break-overlap
Open

fix(layout): don't squash a container's sole child at a page break (#3449)#3450
billinspiratobell wants to merge 2 commits into
diegomura:masterfrom
billinspiratobell:fix/pagination-page-break-overlap

Conversation

@billinspiratobell

@billinspiratobell billinspiratobell commented Jul 7, 2026

Copy link
Copy Markdown

Fixes #3449.

Problem

Content overlaps itself at a page break — a block that lands near the bottom of a page is force-fit into the few points of space left, drawing its lines on top of each other, instead of moving to the next page. It shows up with two-column rows / wrap={false} blocks (headers, footers, itinerary-style entries) near a page boundary, and is present on the latest published @react-pdf/layout (4.6.1).

A full step-by-step reproduction (paste-into-REPL) is in REPL.md.

Root cause

In resolvePagination's splitNodes, when a node is split and all of its children move to the next page (leaving an empty shell), the parent is kept on the current page whenever currentChildren.length === 0:

if (child.children.length > 0 && currentChild.children.length === 0) {
  if (currentChildren.length === 0) {
    currentChildren.push(child, ...futureFixedNodes); // kept on the current page
  } else {
    // move to the next page
  }
}

currentChildren.length === 0 is meant to mean "the page is empty, so keep this here (even if it overflows) rather than emit an empty page / loop forever." But currentChildren is the local list for the node's own container — it is 0 simply because the node is the first child of its container, which is not the same as the page being empty. So a node that is the sole child of its container but lands on an already-populated page is force-fit into the space left, and Yoga compresses its lines on top of each other.

Fix

Decide based on whether the page is actually empty above the node, threaded through the split recursion as pageEmpty:

  • Page already has content above the node → move the whole node to the next page.
  • Page is genuinely empty and the node cannot fit → keep it (allow overflow), which preserves termination and avoids an infinite page loop.

The change is contained to splitNodes / split / splitView / splitChildren.

Tests

Added to packages/layout/tests/steps/resolvePagination.test.ts:

  • Overlap regression — asserts zero text-on-text overlap for a wrap={false} two-column entry that is the sole child of its container, swept across several page heights. Fails before this change (7/6/5/3 overlapping spans), passes after.
  • Loop guard — a tall fixed-header case where a node fits the content area but never fits the space left under the header; verifies pagination still terminates (no infinite page loop).

All 416 layout tests pass. Also verified end-to-end by rendering a real document that reproduced the overlap: 8 overlapping spans → 0 after the fix (the entry moves cleanly to the next page).

🤖 Generated with Claude Code

…iegomura#3449)

When splitting a node whose children all move to the next page, splitNodes
kept the (now empty) parent on the current page whenever `currentChildren`
was empty. That is a per-container signal, not proof the page is empty, so an
element that is the only child of its container but lands on an already-
populated page (a `wrap={false}` block, an itinerary row, etc.) was force-fit
into the little space left and its lines were drawn on top of each other
("overlapping at the page break").

Base the keep-vs-move decision on whether the page is empty *above* the node,
threaded through the split recursion. When the page already has content the
node now moves to the next page; it is only kept (and allowed to overflow)
when the page is genuinely empty, which also preserves termination and avoids
an infinite page loop.

Adds regression tests covering the overlap across several page heights and the
tall-fixed-header loop guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 38a3154

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@react-pdf/layout Patch
@react-pdf/renderer Patch
@react-pdf/math Patch
@react-pdf/mermaid Patch
next-14 Patch
next-15 Patch
@react-pdf/vite-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@billnbell3

Copy link
Copy Markdown

OK this is ready. See my https://react-pdf.org/repl paste in the code above. It also fixes the recursion. test case is there too.

@billnbell3

Copy link
Copy Markdown

CAN WE GET THIS MERGED?

@billnbell3 billnbell3 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.

ANYONE ELSE look at this?

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.

Content taller than one page is squashed/overlapped at the bottom instead of flowing onto the next page

3 participants