Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/two-boats-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-pdf/layout": minor
---

perf: Optimize page splitting and relayout, so both per-iteration costs drop from O(remaining-children) to O(children-per-page)
5 changes: 5 additions & 0 deletions .changeset/two-pane-page-relative-top.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-pdf/layout": patch
---

fix: synthesize page-relative `box.top` for nodes pushed to the next page during pagination so subsequent iterations match what a yoga relayout would have produced. This accounts for normal-flow fixed siblings (e.g., a fixed header above a flex:1 body) and for sibling margins (`marginTop`/`marginBottom`). Without these, common two-pane report layouts packed too much content onto subsequent pages, causing yoga to flex-shrink content and overflow page borders.
67 changes: 43 additions & 24 deletions packages/layout/src/node/shouldBreak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,42 +22,61 @@ const getEndOfMinPresenceAhead = (child: SafeNode) => {
);
};

const getEndOfPresence = (child: SafeNode, futureElements: SafeNode[]) => {
const afterMinPresenceAhead = getEndOfMinPresenceAhead(child);
const nonFixedFuture = futureElements.filter(
(node) => !('fixed' in node.props),
);
const endOfFurthestFutureElement = getFurthestEnd(nonFixedFuture);
/**
* Determines whether a node should break to the next page.
* Accepts pre-computed values to avoid O(N) array scans per call.
*
* @param child - The node to evaluate
* @param furthestEndOfNonFixedFuture - Pre-computed max(top+height) of future non-fixed siblings, or null if none
* @param height - Available page height
* @param hasNonFixedPrevious - Whether any non-fixed sibling precedes this node
*/
export const shouldBreakOptimized = (
child: SafeNode,
furthestEndOfNonFixedFuture: number | null,
height: number,
hasNonFixedPrevious: boolean,
) => {
if (isFixed(child)) return false;

// When there are no future non-fixed siblings, use only minPresenceAhead
if (endOfFurthestFutureElement === null) return afterMinPresenceAhead;
const shouldSplit = height < child.box.top + child.box.height;
const canWrap = getWrap(child);

const afterMinPresenceAhead = getEndOfMinPresenceAhead(child);
const endOfPresence =
furthestEndOfNonFixedFuture === null
? afterMinPresenceAhead
: Math.min(afterMinPresenceAhead, furthestEndOfNonFixedFuture);

return Math.min(afterMinPresenceAhead, endOfFurthestFutureElement);
return (
getBreak(child) ||
(shouldSplit && !canWrap) ||
(!shouldSplit && endOfPresence > height && hasNonFixedPrevious)
);
};

/**
* Array-based wrapper around shouldBreakOptimized.
* Computes the pre-computed values from raw arrays for convenience in tests.
*/
const shouldBreak = (
child: SafeNode,
futureElements: SafeNode[],
height: number,
previousElements: SafeNode[],
) => {
if ('fixed' in child.props) return false;

const shouldSplit = height < child.box.top + child.box.height;
const canWrap = getWrap(child);

// Calculate the y coordinate where the desired presence of the child ends
const endOfPresence = getEndOfPresence(child, futureElements);

// If the child is already at the top of the page, breaking won't improve its presence
// (as long as react-pdf does not support breaking into differently sized containers)
const breakingImprovesPresence =
const nonFixedFuture = futureElements.filter(
(node) => !('fixed' in node.props),
);
const furthestEndOfNonFixedFuture = getFurthestEnd(nonFixedFuture);
const hasNonFixedPrevious =
previousElements.filter((node: SafeNode) => !isFixed(node)).length > 0;

return (
getBreak(child) ||
(shouldSplit && !canWrap) ||
(!shouldSplit && endOfPresence > height && breakingImprovesPresence)
return shouldBreakOptimized(
child,
furthestEndOfNonFixedFuture,
height,
hasNonFixedPrevious,
);
};

Expand Down
10 changes: 6 additions & 4 deletions packages/layout/src/node/splitNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,17 @@ const splitNode = (node: SafeNode, height: number) => {

current.style.height = height - nodeTop;

const nextHeight = hasFixedHeight(node)
? node.box.height - (height - nodeTop)
: null;
const nextBoxHeight = node.box.height - (height - nodeTop);

const nextHeight = hasFixedHeight(node) ? nextBoxHeight : null;

const next: SafeNode = Object.assign({}, node, {
box: {
...node.box,
top: 0,
marginTop: 0,
borderTopWidth: 0,
height: nextBoxHeight,
},
style: {
...node.style,
Expand All @@ -52,7 +54,7 @@ const splitNode = (node: SafeNode, height: number) => {
},
});

if (nextHeight) {
if (nextHeight !== null) {
next.style.height = nextHeight;
}

Expand Down
Loading
Loading