Skip to content

Commit ae9a998

Browse files
authored
feat: experimentalPagination page prop — new engine opt-in per document (#3505)
1 parent bc4a36d commit ae9a998

5 files changed

Lines changed: 111 additions & 4 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@react-pdf/layout': minor
3+
'@react-pdf/renderer': minor
4+
'@react-pdf/primitives': minor
5+
'@react-pdf/types': minor
6+
---
7+
8+
Experimental pagination engine, opt-in per page
9+
10+
A new pagination engine ships alongside the current one: content is
11+
measured once and packed into pages instead of relayouting on every split,
12+
making long documents paginate orders of magnitude faster (a 300-page
13+
document drops from ~40s to ~200ms).
14+
15+
Opt in with `<Page experimentalPagination>` — any page opting in switches
16+
the whole document. The default behavior is unchanged.
17+
18+
Under the new engine:
19+
20+
- `<Page layout={Layout}>` renders per-page chrome (headers, footers,
21+
sidebars) around the content. The layout component receives
22+
`{ pageNumber, totalPages, subPageNumber, subPageTotalPages }` and the
23+
page content as `children`, and runs once per output page. Using `layout`
24+
implies `experimentalPagination`.
25+
- One `fixed` semantic: in-flow fixed elements repeat at the top of every
26+
page they span; footers are the layout's job.
27+
- `minPresenceAhead` is supported, with one refinement: a trailing element
28+
with nothing after it stays in place instead of moving to its own page.
29+
30+
The current engine remains the default until the next major, when the new
31+
engine takes over.

packages/layout/src/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@ import resolveLinkSubstitution from './steps/resolveLinkSubstitution';
2020
import resolvePaginationNext from './paginate';
2121
import { SafeDocumentNode } from './types';
2222

23-
// The new pagination engine, shipped dark: flip to true to enable.
24-
const NEXT_PAGINATION: boolean = true;
23+
// The new engine is opt-in until the cutover major. Engines work on whole
24+
// documents, so any page asking for it — or using `layout`, which requires
25+
// it — switches the document.
26+
const wantsNextPagination = (root: SafeDocumentNode) =>
27+
root.children.some(
28+
(page) =>
29+
(page.props as any)?.experimentalPagination ||
30+
(page.props as any)?.layout,
31+
);
2532

2633
const paginationStep = (root: SafeDocumentNode, fontStore) =>
27-
NEXT_PAGINATION
34+
wantsNextPagination(root)
2835
? resolvePaginationNext(root, fontStore)
2936
: resolvePagination(root, fontStore);
3037

packages/layout/src/types/page.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ interface PageProps extends NodeProps {
8888
* @see https://react-pdf.org/components#page-wrapping
8989
*/
9090
wrap?: boolean;
91+
/**
92+
* Opt the document into the new pagination engine. Implied by `layout`.
93+
*/
94+
experimentalPagination?: boolean;
9195
size?: PageSize;
9296
orientation?: Orientation;
9397
dpi?: number;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, test } from 'vitest';
2+
import FontStore from '@react-pdf/font';
3+
4+
import layout from '../src';
5+
6+
const fontStore = new FontStore();
7+
8+
const view = (style: any, props: any = {}): any => ({
9+
type: 'VIEW',
10+
props,
11+
style,
12+
box: {},
13+
children: [],
14+
});
15+
16+
// A trailing minPresenceAhead is the one place the engines diverge: legacy
17+
// moves the node to its own page, the new engine leaves it in place. That
18+
// difference tells us which engine ran.
19+
const doc = (pageProps: any = {}): any => ({
20+
type: 'DOCUMENT',
21+
props: {},
22+
children: [
23+
{
24+
type: 'PAGE',
25+
props: { size: [100, 100], ...pageProps },
26+
style: {},
27+
box: {},
28+
children: [
29+
view({ height: 60 }),
30+
view({ height: 30 }, { minPresenceAhead: 500 }),
31+
],
32+
},
33+
],
34+
});
35+
36+
describe('pagination engine selection', () => {
37+
test('legacy by default', async () => {
38+
const result = await layout(doc(), fontStore);
39+
40+
expect(result.children).toHaveLength(2);
41+
});
42+
43+
test('experimentalPagination opts into the new engine', async () => {
44+
const result = await layout(
45+
doc({ experimentalPagination: true }),
46+
fontStore,
47+
);
48+
49+
expect(result.children).toHaveLength(1);
50+
});
51+
52+
test('a page layout implies the new engine', async () => {
53+
const pageLayout = (_: any, children: any[]) => children;
54+
const result = await layout(doc({ layout: pageLayout }), fontStore);
55+
56+
expect(result.children).toHaveLength(1);
57+
});
58+
});

packages/renderer/index.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,16 @@ declare namespace ReactPDF {
112112
* A template component rendered around every page this Page produces.
113113
* Where it renders `children` is where page content flows; everything
114114
* else repeats as page chrome with its space reserved. Like render
115-
* props, layout components may not use hooks.
115+
* props, layout components may not use hooks. Implies
116+
* `experimentalPagination`.
116117
*/
117118
layout?: (props: PageLayoutProps) => React.ReactNode;
119+
/**
120+
* Opt the document into the new pagination engine: content is measured
121+
* once and packed into pages, dramatically faster on long documents.
122+
* Any page opting in switches the whole document. Implied by `layout`.
123+
*/
124+
experimentalPagination?: boolean;
118125
/**
119126
* Enables debug mode on page bounding box.
120127
* @see https://react-pdf.org/advanced#debugging

0 commit comments

Comments
 (0)