Skip to content

feat: new pagination engine and Page layout prop - #3502

Merged
diegomura merged 40 commits into
masterfrom
dm/new-pagination
Aug 23, 2026
Merged

feat: new pagination engine and Page layout prop#3502
diegomura merged 40 commits into
masterfrom
dm/new-pagination

Conversation

@diegomura

@diegomura diegomura commented Aug 23, 2026

Copy link
Copy Markdown
Owner

What

Closes: #3499

Replaces the pagination step with the new @react-pdf/paginate engine and adds a layout prop to Page for per-page chrome (headers, footers, sidebars — anything that repeats around the content).

  • Content is measured once; the engine packs it into pages. No more relayout on every split.
  • <Page layout={Layout}>: the layout component receives { pageNumber, totalPages, ... } and the page content as children, and runs once per output page.
  • One fixed semantic: in-flow fixed elements repeat at the top of every page they span. Footers are the layout's job.
  • Legacy engine stays behind a flag for now; it gets deleted before release.

Performance

The old engine relayouts on every split, so cost grows quadratically with document size. The new engine is linear. Same documents, same output page counts:

Document Legacy New Speedup
300 pages of blocks 36.5 s 158 ms 231×
300 pages + page numbers 60.7 s 254 ms 238×
300 pages of text 17.7 s 1.8 s 10×
100 pages of blocks 2.2 s 49 ms 44×
10 pages of blocks 24 ms 6 ms

With a layout on every page: ~1 ms per page, flat at every size.

Breaking

Ships as a major. fixed suffix elements no longer reserve footer space (use layout), flex justify at the page root follows the new stream semantics, and render props handed to @react-pdf/layout directly must return instances (the renderer does this for you). minPresenceAhead is not ported yet — pending decision.

Testing

  • 473 layout tests, including a parity suite against the legacy engine and a 150-seed fuzzer comparing both engines
  • 103 engine tests with PNG visual regression
  • Renderer snapshots byte-identical (98 tests + browser project)

🤖 Generated with Claude Code

Supersedes

Open PRs made obsolete by this engine (closed manually, since the merge predates this note):

Issues resolved

Each of these was verified by running its repro against the new engine (closed manually):

Squashed / overlapping content at page breaks — the engine defers or splits instead of force-fitting:

Dynamic nodes — pagination re-runs with real page numbers instead of freezing a partition and relayouting:

minPresenceAhead — reimplemented as a forbid window (#3504):

Other:

Enabled by the layout prop (the supported pattern for what these asked for):

@changeset-bot

changeset-bot Bot commented Aug 23, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 758509d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

diegomura and others added 27 commits August 23, 2026 02:52
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep fixed nodes nested inside flow containers in the item stream instead
of stripping them at every depth, and mark their items repeat: true so
the paginate engine re-emits a fresh copy on every continuation of the
enclosing container. The top-level filter (direct page children) is
unchanged: those still go through the flowTop band in index.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… sketch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atePaginator ships independently

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eights

paginate(root, height) becomes the constant-height loop over the sealed
iterator, keeping its MAX_PAGES backstop. Iterator callers pass a height
per next() call, own their termination, and cannot mutate in-flight state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Names what comes back — fresh Fragments of the completed repeat items —
instead of where the caller happens to put them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The slot renders empty and the page's instance children graft in directly
(they already passed createInstances), so content measures at slot width
inside the chrome on the unconstrained first pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Template pages route through createPaginator: chrome is instantiated and
measured per page (render props resolved with that page's number), the
slot's measured box is the flow region, and each page is built by filling
the measured chrome's slot with that page's fragment. Width variance and
zero-height slots fail loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Template pages always take the totals round (a layout may read totalPages
from params without any render prop), width variance fails naming the
page, and mirrored asides pass since the invariant binds width only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pages without a layout prop get one synthesized from their own children:
in-flow fixed stays as chrome around the slot, flow content goes inside,
absolutes ride to page 1. splitPage becomes the one per-page loop, and the
flowTop band arithmetic is gone — the slot's measured box is the flow
region for every page. The slot always stretches (alignment belongs to
its children), inherits the page's flow styles, and stays transparent to
percent-height resolution and test walkers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…opped

The unified template model reserves its space by construction, so the
prefix-only policy and its staged deprecation are unnecessary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: layout prop on Page for repeating page chrome Performance problem with creating multiple pages

1 participant