Skip to content

Custom Page size with auto height triggers infinite loop in @react-pdf/renderer #3275

@wn-kone

Description

@wn-kone

Environment

  • Package: @react-pdf/renderer
  • Version: 4.3.2
  • React Version: 19.2.1
  • Node Version: 22.14.0

Bug Description

Using custom numeric page sizes causes memory leaks and browser crashes in v4.3.2, even with the fixes from PR #3190 merged.

Root Cause Analysis

The Issue

In packages/layout/src/page/isHeightAuto.ts, the function checks page.box?.height:

// File: packages/layout/src/page/isHeightAuto.ts
import isNil from '../utils/isNil';

const isHeightAuto = (page: SafePageNode) => isNil(page.box?.height);

export default isHeightAuto;

This function is called during pagination in packages/layout/src/page/resolvePagination.ts:

// File: packages/layout/src/page/resolvePagination.ts
import isHeightAuto from './isHeightAuto';

const resolvePagination = (doc: SafeDocumentNode) => {
  const pages = doc.children || [];
  
  for (const page of pages) {
    if (isHeightAuto(page)) {  // ❌ Bug triggers here!
      // Pagination logic that can loop infinitely
      splitPage(page);
    }
  }
  
  return doc;
};

The Problem

When using custom page sizes without explicit height or with height: 'auto' (e.g., size={{ width: 170.3 }}):

  1. Step 1: Only width is set in page.style by resolvePageSizes
  2. Step 2: page.box.height should be populated by resolveDimensions after Yoga layout calculations
  3. Step 3: resolvePagination calls isHeightAuto() before page.box.height is set
  4. Result: Since page.box.height is undefined, isHeightAuto() returns true, triggering infinite pagination loops

Layout Pipeline Order

From packages/layout/src/index.ts:

const layout = asyncCompose(
  resolveZIndex,
  resolveOrigins,
  resolveAssets,
  resolvePagination,      // ❌ Calls isHeightAuto() here
  resolveTextLayout,
  resolvePercentRadius,
  resolveDimensions,      // ✅ Sets page.box.height here (too late!)
  resolveSvg,
  resolveAssets,
  resolveInheritance,
  resolvePercentHeight,
  resolvePagePaddings,
  resolveStyles,
  resolveLinkSubstitution,
  resolveBookmarks,
  resolvePageSizes,       // ✅ Sets page.style.height here (first)
  resolveYoga,
);

The race condition: resolvePagination runs before resolveDimensions, so page.box.height is still undefined when checked!

Reproduction

Minimal Example (Triggers Bug)

import { PDFViewer, Document, Page, Text } from '@react-pdf/renderer';

function QRCodePreview() {
  return (
    <PDFViewer style={{ width: 240, height: 410 }}>
      <Document>
        <Page size={{ width: 170.3 }}>  {/* ❌ No height - triggers bug */}
          <Text>Hello World</Text>
        </Page>
      </Document>
    </PDFViewer>
  );
}

Alternative Bug Triggers

// Using style height: 'auto'
<Page size={{ width: 170.3 }} style={{ height: 'auto' }}>
  <Text>Hello World</Text>
</Page>

// Custom height with many children (pagination needed)
<Page size={{ width: 170.3, height: 200 }}>
  {/* If content exceeds 200pt, pagination logic runs and checks isHeightAuto() */}
  {Array.from({ length: 50 }).map((_, i) => (
    <Text key={i}>Line {i}</Text>
  ))}
</Page>

Result

  • Browser hangs
  • Memory usage spikes
  • Eventually crashes with RangeError or browser becomes unresponsive

Working Example (Standard Sizes)

<Page size="A4">  {/* ✅ Works fine */}
  <Text>Hello World</Text>
</Page>

Expected Behavior

Custom page sizes should work identically to standard sizes like 'A4', 'Letter', etc.

Proposed Fix

Option 1: Check Both Locations

Modify packages/layout/src/page/isHeightAuto.ts:

const isHeightAuto = (page: SafePageNode) => 
  isNil(page.box?.height) && isNil(page.style?.height);

Option 2: Prioritize Style Height

const isHeightAuto = (page: SafePageNode) => {
  const height = page.box?.height ?? page.style?.height;
  return isNil(height);
};

Option 3: Use setNodeHeight Logic

Apply the same logic from resolveDimensions.ts line 77-80:

// File: packages/layout/src/node/resolveDimensions.ts (existing working code)
const setNodeHeight = (node: SafeNode) => {
  const value = isPage(node) ? node.box?.height : node.style?.height;  // ✅ Checks both!
  return setHeight(value);
};

Apply this pattern to isHeightAuto.ts:

// File: packages/layout/src/page/isHeightAuto.ts (proposed fix)
import isNil from '../utils/isNil';

const isHeightAuto = (page: SafePageNode) => {
  const height = page.box?.height ?? page.style?.height;  // Check both locations
  return isNil(height);
};

export default isHeightAuto;

Related Issues

Additional Context

This bug only affects custom numeric page sizes. Standard page sizes work because they follow a different code path that doesn't encounter this timing issue.

Impact: Affects any application using QR codes, labels, or custom-sized documents that don't match standard page sizes.

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