Skip to content

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

Open

Description

@billnbell3

Content overlaps itself at a page break instead of moving to the next page

Describe the bug

When a block lands near the bottom of a page with just barely not enough room to fit, react-pdf force-fits it into the little space left instead of moving it to the next page. Its lines are then drawn on top of each other ("overlapping / scrunched at the page break"), and the rest of the page below is wasted.

It shows up most with two-column rows and wrap={false} blocks near a page boundary (headers, footers, itinerary-style entries, table rows).

The pagination logic lives in @react-pdf/layout, which @react-pdf/renderer depends on. The renderer version can look unrelated (e.g. renderer 4.5.1 → layout 4.6.1); the bug is in layout 4.6.1 and is present on the latest published version.

How to reproduce

Paste into the react-pdf REPL and look at the bottom of page 1. (A plain long <Text> is not enough to trigger it — it paginates cleanly. The trigger is a wrap={false} block that is the sole child of its container and lands at the bottom of an already-populated page.)

const Block = ({ label, value }) => (
  <View style={{ marginTop: 5 }} wrap={false}>
    <Text style={{ fontSize: 8, fontWeight: 700 }}>{label}</Text>
    <Text style={{ fontSize: 8 }}>{value}</Text>
  </View>
);

const Entry = ({ time, title, a, w, p }) => (
  <View>
    <View style={{ flexDirection: "row", borderBottomWidth: 1, paddingVertical: 8 }} wrap={false}>
      <View style={{ width: 130, marginLeft: 20 }}>
        <Text style={{ fontSize: 11, marginBottom: 5 }}>{time}</Text>
        <Text style={{ fontSize: 7 }}>PENDING</Text>
      </View>
      <View style={{ flex: 1, marginRight: 20 }}>
        <Text style={{ fontSize: 10 }}>{title}</Text>
        <Text style={{ fontSize: 8, marginTop: 5 }}>2 Adults</Text>
        <Block label="Address" value={a} />
        <Block label="Website" value={w} />
        <Block label="Phone" value={p} />
      </View>
    </View>
  </View>
);

const Doc = () => (
  <Document>
    <Page size="A4" style={{ paddingTop: 40, paddingBottom: 90, paddingHorizontal: 40, flexDirection: "column" }}>
      <View fixed>
        <Text style={{ fontSize: 22 }}>Daily Itinerary</Text>
      </View>
      <View style={{ flex: 1 }}>
        {/* spacer: pushes the 2nd entry to the bottom of page 1 */}
        <View style={{ height: 500 }} />
        <View>
          <View minPresenceAhead={100}>
            <View style={{ marginTop: 10, marginBottom: 8 }}>
              <Text style={{ fontSize: 9, fontWeight: 700 }}>TUESDAY, APR 7</Text>
            </View>
            <Entry time="12:25 AM" title="Juvia"
              a="1111 Lincoln Road, Miami Beach, Florida, 33139"
              w="http://www.juviamiami.com/" p="+1 305-763-8272" />
          </View>
          <View>
            <Entry time="01:05 AM" title="Miami Beach Golf Club"
              a="2301 Alton Road, Miami Beach, Florida, 33140"
              w="http://www.miamibeachgolfclub.com/" p="+1 305-532-3350" />
          </View>
        </View>
      </View>
      <View style={{ position: "absolute", bottom: 0, left: 40, right: 40, height: 50 }} fixed>
        <Text style={{ fontSize: 8 }}>EXCLUSIVE RESORTS</Text>
      </View>
    </Page>
  </Document>
);

ReactPDF.render(<Doc />);

The overlap only happens when the entry lands with just barely not enough room, and the exact height that does so depends on font metrics (they differ slightly between the browser REPL and a server render). The verified window is about height: 490–510; if 500 renders cleanly for you, try 490 or 510.

Expected behavior

The entry should move to the next page and render normally — never overlap its own lines / the footer.

Screenshots

Before (published 4.6.1) — page 1: the "01:05 AM / Miami Beach Golf Club" entry is squashed onto the bottom of page 1; Address sits on top of the street, Website on the URL, Phone on the number.

Image

After (fix in #3450) — page 1 / page 2: page 1 ends cleanly after "Juvia"; the golf entry moves to page 2 and renders normally.

Image

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. That check is meant to mean "the page is empty," 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, not because the page is 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.

Environment

  • @react-pdf/renderer: ^4.5.1 (uses @react-pdf/layout 4.6.1)
  • Node.js server-side rendering (also reproduces in the in-browser REPL)

Proposed fix

#3450 — decide based on whether the page is actually empty above the node (threaded through the split recursion), so a node with content above it moves to the next page; it is only kept (and allowed to overflow) when the page is genuinely empty, which also avoids an infinite page loop. Includes regression tests.

Activity

  1. billinspiratobell commented on Jul 7, 2026

    @billinspiratobell

    Investigated this end to end — it reproduces on the latest published version (@react-pdf/layout 4.6.1), there's a clear root cause, and I've opened #3450 with a fix + regression tests.

    What's happening

    A block that lands near the bottom of a page is force-fit into the few points of space left — its lines drawn on top of each other — instead of moving cleanly to the next page. It shows up with two-column rows / wrap={false} blocks (headers, footers, itinerary-style entries) near a page boundary.

    Note: the pagination logic lives in @react-pdf/layout, which @react-pdf/renderer depends on. So the renderer version can look unrelated (renderer 4.5.1 → layout 4.6.1), but the bug is in layout 4.6.1 and is still present.

    Reproduction (react-pdf REPL)

    Paste into https://react-pdf.org/repl and look at the bottom of page 1:

    const Block = ({ label, value }) => (
      <View style={{ marginTop: 5 }} wrap={false}>
        <Text style={{ fontSize: 8, fontWeight: 700 }}>{label}</Text>
        <Text style={{ fontSize: 8 }}>{value}</Text>
      </View>
    );
    
    const Entry = ({ time, title, a, w, p }) => (
      <View>
        <View style={{ flexDirection: "row", borderBottomWidth: 1, paddingVertical: 8 }} wrap={false}>
          <View style={{ width: 130, marginLeft: 20 }}>
            <Text style={{ fontSize: 11, marginBottom: 5 }}>{time}</Text>
            <Text style={{ fontSize: 7 }}>PENDING</Text>
          </View>
          <View style={{ flex: 1, marginRight: 20 }}>
            <Text style={{ fontSize: 10 }}>{title}</Text>
            <Text style={{ fontSize: 8, marginTop: 5 }}>2 Adults</Text>
            <Block label="Address" value={a} />
            <Block label="Website" value={w} />
            <Block label="Phone" value={p} />
          </View>
        </View>
      </View>
    );
    
    const Doc = () => (
      <Document>
        <Page size="A4" style={{ paddingTop: 40, paddingBottom: 90, paddingHorizontal: 40, flexDirection: "column" }}>
          <View fixed>
            <Text style={{ fontSize: 22 }}>Daily Itinerary</Text>
          </View>
          <View style={{ flex: 1 }}>
            {/* spacer: pushes the 2nd entry to the bottom of page 1 */}
            <View style={{ height: 500 }} />
            <View>
              <View minPresenceAhead={100}>
                <View style={{ marginTop: 10, marginBottom: 8 }}>
                  <Text style={{ fontSize: 9, fontWeight: 700 }}>TUESDAY, APR 7</Text>
                </View>
                <Entry time="12:25 AM" title="Juvia"
                  a="1111 Lincoln Road, Miami Beach, Florida, 33139"
                  w="http://www.juviamiami.com/" p="+1 305-763-8272" />
              </View>
              <View>
                <Entry time="01:05 AM" title="Miami Beach Golf Club"
                  a="2301 Alton Road, Miami Beach, Florida, 33140"
                  w="http://www.miamibeachgolfclub.com/" p="+1 305-532-3350" />
              </View>
            </View>
          </View>
          <View style={{ position: "absolute", bottom: 0, left: 40, right: 40, height: 50 }} fixed>
            <Text style={{ fontSize: 8 }}>EXCLUSIVE RESORTS</Text>
          </View>
        </Page>
      </Document>
    );
    
    ReactPDF.render(<Doc />);

    Expected (bug): the "01:05 AM / Miami Beach Golf Club" entry at the bottom of page 1 has Address drawn on top of 2301 Alton Road…, Website on top of the URL, and Phone on top of the number — every field overlapping the next.

    With the fix: that entry moves to page 2 intact; page 1 ends cleanly after "Juvia".

    The overlap only happens when the entry lands with just barely not enough room, and the exact height that does so depends on font metrics (they differ slightly between the browser REPL and a server render). The verified window is about height: 490–510; if 500 renders cleanly for you, try 490 or 510.

    Root cause

    In resolvePagination's splitNodes, when a node is split and all 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 current page
      } else {
        // move to next page
      }
    }

    currentChildren.length === 0 is meant to mean "the page is empty, keep it here to avoid an empty page / infinite loop." But currentChildren is the local list for the node's own container — it's 0 just 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 (#3450)

    Base the decision on whether the page is actually empty above the node (threaded through the split recursion). Page has content above → move the node to the next page; page genuinely empty and the node can't fit → keep it (allow overflow), which preserves termination and avoids an infinite page loop. Includes regression tests (overlap sweep across page heights + a tall-fixed-header loop guard) and a REPL.md write-up.

  2. billnbell3 commented on Jul 7, 2026

    @billnbell3
    Author
  3. santosch commented on Aug 5, 2026

    @santosch

    Thanks @billinspiratobell for tracking this down and for the fix in #3450 - we hit exactly this in production, and your diagnosis matched what we'd independently traced.

    Disclaimer: The following wrapup was written by Claude Code Opus 5, which did most of the investigation and testing automatically, but I (human) have guided, overseen and verified this in person.

    @diegomura This is quite the big problem to be honest, would love to get this merged! 🙏


    We're hitting this too

    Our customer-facing site-survey PDFs came out corrupted after upgrading @react-pdf/layout 3.6.2 → 4.6.1: a block that lands near the bottom of a page gets squeezed into whatever space is left instead of moving to the next page. Worst real case: a photo group rendered at roughly 3% of its intended size — images drawn at 6.1 × 8.1pt instead of 188.7 × 251.5pt — with the heading, description and checkbox lines all printed on top of each other.

    We bisected the exact release that introduced it; details below.

    Minimal reproduction

    Self-contained, no assets, prints its own verdict:

    import React from "react";
    import ReactPDF, { Document, Page, Text, View } from "@react-pdf/renderer";
    import fs from "node:fs";
    import zlib from "node:zlib";
    
    const h = React.createElement;
    const OUT = "repro-3449.pdf";
    const BLOCK_HEIGHT = 300;
    
    const doc = h(
      Document,
      null,
      h(
        Page,
        { size: "A4", style: { padding: 40 } },
        // leaves 162pt of the 762pt content area
        h(View, { style: { height: 600, backgroundColor: "#eeeeee" } }, h(Text, null, "filler: 600pt")),
    
        // The trigger: containers whose only child is the non-wrappable block, so the
        // innermost container has no earlier sibling placed on this page.
        h(
          View,
          null,
          h(
            View,
            null,
            h(
              View,
              { wrap: false, style: { height: BLOCK_HEIGHT, backgroundColor: "#cfe3f7" } },
              h(Text, null, `${BLOCK_HEIGHT}pt block - only 162pt left, so this must move to page 2`)
            )
          )
        )
      )
    );
    
    const drawnHeights = (file) => {
      const data = fs.readFileSync(file);
      const out = [];
      for (const m of data.toString("latin1").matchAll(/stream\r?\n/g)) {
        const start = m.index + m[0].length;
        const end = data.indexOf(Buffer.from("endstream"), start);
        let content;
        try {
          content = zlib.inflateSync(data.subarray(start, end)).toString("latin1");
        } catch {
          continue;
        }
        for (const r of content.matchAll(/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) re/g)) {
          const [w, hh] = [parseFloat(r[3]), parseFloat(r[4])];
          if (w > 100 && hh > 20) out.push(Math.round(hh * 10) / 10);
        }
      }
      return out;
    };
    
    const main = async () => {
      await ReactPDF.render(doc, OUT);
      const heights = drawnHeights(OUT);
      const block = heights.find((x) => x !== 600);
      console.log("drawn box heights:", heights);
      console.log(
        block === BLOCK_HEIGHT
          ? `OK - block kept its ${BLOCK_HEIGHT}pt and moved to the next page`
          : `BUG - block was squashed from ${BLOCK_HEIGHT}pt to ${block}pt to fit the leftover space`
      );
    };
    
    void main();

    On @react-pdf/renderer 4.5.1 (@react-pdf/layout 4.6.1):

    drawn box heights: [ 600, 161.9 ]
    BUG - block was squashed from 300pt to 161.9pt to fit the leftover space
    

    With #3450's approach applied:

    drawn box heights: [ 600, 300 ]
    OK - block kept its 300pt and moved to the next page
    

    Note that page count is not a usable signal — the forced placement still emits a nearly empty continuation page, so the document looks plausible until you measure the boxes.

    Root cause

    Instrumenting splitNodes on the real failing document:

    [splitNodes] type=VIEW top=0.0 h=567.9 avail=159.6 contentArea=761.9 fits=true shouldBreak=true prevPlaced=0
       --> FORCE-PLACED whole node (VIEW h=567.9) because no earlier sibling was placed at this level
    

    Pagination correctly decides the 567.9pt block must break out of a page with 159.6pt left, and then this overrides that decision:

    if (child.children.length > 0 && currentChild.children.length === 0) {
        // But if the current page is empty then we can just include the parent on the current page
        if (currentChildren.length === 0) {

    currentChildren.length === 0 is meant to mean "the page is empty", but it is evaluated per parent, not per page — so any sole child looks like an empty page, at any depth. The node is pinned onto a full page, and the relayout in splitPage (which pins page.style.height) then shrinks it to fit.

    Regression point: @react-pdf/layout 3.12.0 → 3.13.0

    We bisected it. Holding @react-pdf/renderer at 3.4.5 and changing only the layout version:

    renderer 3.4.5 + layout 3.12.0  ->  drawn box heights: [ 600, 300 ]    OK
    renderer 3.4.5 + layout 3.13.0  ->  drawn box heights: [ 600, 161.9 ]  BUG
    

    Up to and including 3.12.0, the branch moved the parent to the next page unconditionally:

    // layout 3.6.2 (and through 3.12.0)
    if (child.children.length > 0 && currentChild.children.length === 0) {
        // no page-empty check at all - always move the parent on
        currentChildren.push(...futureFixedNodes);
        nextChildren.push(next, ...futureNodes);
        break;
    }

    3.13.0 added the if (currentChildren.length === 0) { keep it on this page } escape, and that escape is what squashes. The intent looks sound — an un-splittable node that is taller than any page has to be rendered somewhere, otherwise pagination would loop forever. The problem is only that currentChildren.length === 0 is a per-container test standing in for a per-page question, so it fires constantly for ordinary sole children on pages that are actually full.

    That is exactly what #3450 fixes, and it keeps the guard the escape was added for. Worth calling out because a naive "just always break" revert would reintroduce the loop that 3.13.0 was presumably guarding against.

    We came from layout 3.6.2 and upgraded to 4.6.1, which crossed that boundary — so for us this arrived as a silent regression in an otherwise routine major upgrade.

    Why we'd consider it severe

    The trigger cannot be avoided by convention. It is any single-child container, at any depth. "Never render a View with one child" is not a rule a real codebase can hold to — wrappers carry styles, and lists sometimes happen to have exactly one entry. We tried the component-level fix first and rejected it for exactly this reason, and patched the library instead.

    The fix works

    We applied #3450's approach — threading a real page-empty flag through the split recursion instead of using currentChildren.length === 0 as a proxy — as a local pnpm patch against @react-pdf/layout@4.6.1, and verified it against a reproduction built from the real failing document: every image back to its correct 188.7 × 251.5pt, no overlapping text, block flows onto the next page.

    We also checked the opposite case, since that is the risk of any fix here: a non-wrappable block taller than an entire page still renders on a fresh page rather than being moved forever. That is exactly what gating on "everything placed above is fixed" buys.

  4. billnbell3 commented on Aug 9, 2026

    @billnbell3
    Author

    Thanks @billinspiratobell for tracking this down and for the fix in #3450 - we hit exactly this in production, and your diagnosis matched what we'd independently traced.

    Disclaimer: The following wrapup was written by Claude Code Opus 5, which did most of the investigation and testing automatically, but I (human) have guided, overseen and verified this in person.

    @diegomura This is quite the big problem to be honest, would love to get this merged! 🙏

    We're hitting this too

    Our customer-facing site-survey PDFs came out corrupted after upgrading @react-pdf/layout 3.6.2 → 4.6.1: a block that lands near the bottom of a page gets squeezed into whatever space is left instead of moving to the next page. Worst real case: a photo group rendered at roughly 3% of its intended size — images drawn at 6.1 × 8.1pt instead of 188.7 × 251.5pt — with the heading, description and checkbox lines all printed on top of each other.

    We bisected the exact release that introduced it; details below.

    Minimal reproduction

    Self-contained, no assets, prints its own verdict:

    import React from "react";
    import ReactPDF, { Document, Page, Text, View } from "@react-pdf/renderer";
    import fs from "node:fs";
    import zlib from "node:zlib";

    const h = React.createElement;
    const OUT = "repro-3449.pdf";
    const BLOCK_HEIGHT = 300;

    const doc = h(
    Document,
    null,
    h(
    Page,
    { size: "A4", style: { padding: 40 } },
    // leaves 162pt of the 762pt content area
    h(View, { style: { height: 600, backgroundColor: "#eeeeee" } }, h(Text, null, "filler: 600pt")),

    // The trigger: containers whose only child is the non-wrappable block, so the
    // innermost container has no earlier sibling placed on this page.
    h(
      View,
      null,
      h(
        View,
        null,
        h(
          View,
          { wrap: false, style: { height: BLOCK_HEIGHT, backgroundColor: "#cfe3f7" } },
          h(Text, null, `${BLOCK_HEIGHT}pt block - only 162pt left, so this must move to page 2`)
        )
      )
    )
    

    )
    );

    const drawnHeights = (file) => {
    const data = fs.readFileSync(file);
    const out = [];
    for (const m of data.toString("latin1").matchAll(/stream\r?\n/g)) {
    const start = m.index + m[0].length;
    const end = data.indexOf(Buffer.from("endstream"), start);
    let content;
    try {
    content = zlib.inflateSync(data.subarray(start, end)).toString("latin1");
    } catch {
    continue;
    }
    for (const r of content.matchAll(/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) re/g)) {
    const [w, hh] = [parseFloat(r[3]), parseFloat(r[4])];
    if (w > 100 && hh > 20) out.push(Math.round(hh * 10) / 10);
    }
    }
    return out;
    };

    const main = async () => {
    await ReactPDF.render(doc, OUT);
    const heights = drawnHeights(OUT);
    const block = heights.find((x) => x !== 600);
    console.log("drawn box heights:", heights);
    console.log(
    block === BLOCK_HEIGHT
    ? OK - block kept its ${BLOCK_HEIGHT}pt and moved to the next page
    : BUG - block was squashed from ${BLOCK_HEIGHT}pt to ${block}pt to fit the leftover space
    );
    };

    void main();
    On @react-pdf/renderer 4.5.1 (@react-pdf/layout 4.6.1):

    drawn box heights: [ 600, 161.9 ]
    BUG - block was squashed from 300pt to 161.9pt to fit the leftover space
    

    With #3450's approach applied:

    drawn box heights: [ 600, 300 ]
    OK - block kept its 300pt and moved to the next page
    

    Note that page count is not a usable signal — the forced placement still emits a nearly empty continuation page, so the document looks plausible until you measure the boxes.

    Root cause

    Instrumenting splitNodes on the real failing document:

    [splitNodes] type=VIEW top=0.0 h=567.9 avail=159.6 contentArea=761.9 fits=true shouldBreak=true prevPlaced=0
       --> FORCE-PLACED whole node (VIEW h=567.9) because no earlier sibling was placed at this level
    

    Pagination correctly decides the 567.9pt block must break out of a page with 159.6pt left, and then this overrides that decision:

    if (child.children.length > 0 && currentChild.children.length === 0) {
    // But if the current page is empty then we can just include the parent on the current page
    if (currentChildren.length === 0) {
    currentChildren.length === 0 is meant to mean "the page is empty", but it is evaluated per parent, not per page — so any sole child looks like an empty page, at any depth. The node is pinned onto a full page, and the relayout in splitPage (which pins page.style.height) then shrinks it to fit.

    Regression point: @react-pdf/layout 3.12.0 → 3.13.0

    We bisected it. Holding @react-pdf/renderer at 3.4.5 and changing only the layout version:

    renderer 3.4.5 + layout 3.12.0  ->  drawn box heights: [ 600, 300 ]    OK
    renderer 3.4.5 + layout 3.13.0  ->  drawn box heights: [ 600, 161.9 ]  BUG
    

    Up to and including 3.12.0, the branch moved the parent to the next page unconditionally:

    // layout 3.6.2 (and through 3.12.0)
    if (child.children.length > 0 && currentChild.children.length === 0) {
    // no page-empty check at all - always move the parent on
    currentChildren.push(...futureFixedNodes);
    nextChildren.push(next, ...futureNodes);
    break;
    }
    3.13.0 added the if (currentChildren.length === 0) { keep it on this page } escape, and that escape is what squashes. The intent looks sound — an un-splittable node that is taller than any page has to be rendered somewhere, otherwise pagination would loop forever. The problem is only that currentChildren.length === 0 is a per-container test standing in for a per-page question, so it fires constantly for ordinary sole children on pages that are actually full.

    That is exactly what #3450 fixes, and it keeps the guard the escape was added for. Worth calling out because a naive "just always break" revert would reintroduce the loop that 3.13.0 was presumably guarding against.

    We came from layout 3.6.2 and upgraded to 4.6.1, which crossed that boundary — so for us this arrived as a silent regression in an otherwise routine major upgrade.

    Why we'd consider it severe

    The trigger cannot be avoided by convention. It is any single-child container, at any depth. "Never render a View with one child" is not a rule a real codebase can hold to — wrappers carry styles, and lists sometimes happen to have exactly one entry. We tried the component-level fix first and rejected it for exactly this reason, and patched the library instead.

    The fix works

    We applied #3450's approach — threading a real page-empty flag through the split recursion instead of using currentChildren.length === 0 as a proxy — as a local pnpm patch against @react-pdf/layout@4.6.1, and verified it against a reproduction built from the real failing document: every image back to its correct 188.7 × 251.5pt, no overlapping text, block flows onto the next page.

    We also checked the opposite case, since that is the risk of any fix here: a non-wrappable block taller than an entire page still renders on a fresh page rather than being moved forever. That is exactly what gating on "everything placed above is fixed" buys.

    the code is in production for us, and works 100%.

    Can we get this merged?

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions