From 2dcb963257d878409c7a049452236b57742404e4 Mon Sep 17 00:00:00 2001 From: Iskren Slavov Date: Wed, 12 Aug 2026 18:41:57 +0300 Subject: [PATCH] fix(layout): don't leave a wrapping parent at the end of a page with only its fixed child --- .changeset/ripe-aliens-melt.md | 5 + .../layout/src/steps/resolvePagination.ts | 5 +- .../tests/steps/resolvePagination.test.ts | 120 ++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 .changeset/ripe-aliens-melt.md diff --git a/.changeset/ripe-aliens-melt.md b/.changeset/ripe-aliens-melt.md new file mode 100644 index 000000000..a00474413 --- /dev/null +++ b/.changeset/ripe-aliens-melt.md @@ -0,0 +1,5 @@ +--- +'@react-pdf/layout': patch +--- + +Don't leave a wrapping parent at the end of a page with only its fixed child \ No newline at end of file diff --git a/packages/layout/src/steps/resolvePagination.ts b/packages/layout/src/steps/resolvePagination.ts index f029d7619..9b21821ca 100644 --- a/packages/layout/src/steps/resolvePagination.ts +++ b/packages/layout/src/steps/resolvePagination.ts @@ -114,7 +114,10 @@ const splitNodes = (height: number, contentArea: number, nodes: SafeNode[]) => { const [currentChild, nextChild] = split(child, height, contentArea); // All children are moved to the next page, it doesn't make sense to show the parent on the current page - if (child.children.length > 0 && currentChild.children.length === 0) { + if ( + child.children.length > 0 && + currentChild.children.filter((node) => !isFixed(node)).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.push(child, ...futureFixedNodes); diff --git a/packages/layout/tests/steps/resolvePagination.test.ts b/packages/layout/tests/steps/resolvePagination.test.ts index 2466aaf4b..0189988fc 100644 --- a/packages/layout/tests/steps/resolvePagination.test.ts +++ b/packages/layout/tests/steps/resolvePagination.test.ts @@ -458,4 +458,124 @@ describe('pagination step', () => { expect(subChapter3.props!.bookmark).toEqual(bookmarkSubChapter3); }); + + test('should not leave a wrapping parent alone with its fixed child on the previous page', async () => { + const yoga = await loadYoga(); + + const result = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + style: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 200, height: 300 }, + children: [ + { + type: 'VIEW', + props: { id: 'filler' }, + style: { height: 255 }, + children: [], + }, + { + type: 'VIEW', + props: { id: 'block' }, + style: {}, + children: [ + { + type: 'VIEW', + props: { id: 'header', fixed: true }, + style: { height: 20 }, + children: [], + }, + { + type: 'VIEW', + props: { id: 'item-1', wrap: false }, + style: { height: 40 }, + children: [], + }, + { + type: 'VIEW', + props: { id: 'item-2', wrap: false }, + style: { height: 40 }, + children: [], + }, + ], + }, + ], + }, + ], + }); + + const page1 = result.children[0]; + const page2 = result.children[1]; + + expect(page1.children!.map((child) => child.props.id)).toEqual(['filler']); + expect(page2.children!.map((child) => child.props.id)).toEqual(['block']); + expect(page2.children![0].children!.map((child) => child.props.id)).toEqual( + ['header', 'item-1', 'item-2'], + ); + }); + + test('should repeat fixed child on every page its parent spans', async () => { + const yoga = await loadYoga(); + + const result = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + style: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 200, height: 300 }, + children: [ + { + type: 'VIEW', + props: { id: 'block' }, + style: {}, + children: [ + { + type: 'VIEW', + props: { id: 'header', fixed: true }, + style: { height: 20 }, + children: [], + }, + ...Array.from({ length: 10 }, (_, i) => ({ + type: 'VIEW' as const, + props: { id: `item-${i + 1}`, wrap: false }, + style: { height: 40 }, + children: [], + })), + ], + }, + ], + }, + ], + }); + + const page1 = result.children[0]; + const page2 = result.children[1]; + + expect(result.children).toHaveLength(2); + expect(page1.children![0].props.id).toBe('block'); + expect(page2.children![0].props.id).toBe('block'); + expect(page1.children![0].children![0].props.id).toBe('header'); + expect(page2.children![0].children![0].props.id).toBe('header'); + + const itemsOnPage1 = page1 + .children![0].children!.slice(1) + .map((child) => child.props.id); + const itemsOnPage2 = page2 + .children![0].children!.slice(1) + .map((child) => child.props.id); + + expect(itemsOnPage1.length).toBeGreaterThan(0); + expect([...itemsOnPage1, ...itemsOnPage2]).toEqual( + Array.from({ length: 10 }, (_, i) => `item-${i + 1}`), + ); + }); });