fix(pdf): consume oversized list marker presence hints

This commit is contained in:
Amruth Pillai
2026-09-05 18:23:09 +02:00
parent 63613ba257
commit 898f0267b9
3 changed files with 67 additions and 7 deletions
@@ -1,3 +1,8 @@
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
@@ -54,6 +59,54 @@ async function listPages(
}
describe("list marker pagination (#3344)", () => {
it("terminates when an authored marker presence hint exceeds a whole page", async () => {
if (process.env.RR_LIST_PRESENCE_PROBE !== "1") {
// The renderer's pagination loop is synchronous. A test timeout cannot
// interrupt it, so run this probe in a killable process with thread workers.
const vitest = join(dirname(createRequire(import.meta.url).resolve("vitest/package.json")), "vitest.mjs");
await promisify(execFile)(
process.execPath,
[
vitest,
"run",
"src/templates/shared/list-pagination.test.tsx",
"-t",
"terminates when an authored marker presence hint exceeds a whole page",
"--pool=threads",
"--maxWorkers=1",
"--passWithNoTests=false",
],
{
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
env: { ...process.env, RR_LIST_PRESENCE_PROBE: "1" },
timeout: 30000,
killSignal: "SIGKILL",
},
);
return;
}
for (const rtl of [false, true]) {
for (const first of ["list-item-content", "list-marker"]) {
const result = await listPages(
180,
30,
`${first} { order: -1; } list-marker { -resume-min-presence-ahead: 1000pt; }`,
{ rtl },
);
expect(result.first).toBe(1);
expect(result.marker).toBe(result.first);
expect(result.last).toBeGreaterThan(result.first);
expect(result.pages.length).toBeLessThanOrEqual(4);
expect(result.pages.flat().join(" ").match(/•/g)).toHaveLength(1);
expect(
result.pages
.flat()
.join(" ")
.match(/\bSome\b/g),
).toHaveLength(30);
}
}
}, 40000);
it("moves a bullet with its first paragraph when the paragraph cannot start on this page", async () => {
const result = await listPages(194);
expect(result.first).toBe(1);
+11 -4
View File
@@ -1,5 +1,5 @@
diff --git a/lib/index.js b/lib/index.js
index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..d4471eda64ee99695bedf01f7a0098806d2fc961 100644
index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..79e1898aadcbbfc78a206e235d868cb178683a07 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -3163,7 +3163,10 @@ const splitNodes = (height, contentArea, nodes) => {
@@ -14,7 +14,7 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..d4471eda64ee99695bedf01f7a009880
// 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);
@@ -3196,7 +3199,45 @@ const splitChildren = (height, contentArea, node) => {
@@ -3196,7 +3199,52 @@ const splitChildren = (height, contentArea, node) => {
};
const splitView = (node, height, contentArea) => {
const [currentNode, nextNode] = splitNode(node, height);
@@ -32,6 +32,13 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..d4471eda64ee99695bedf01f7a009880
+ const contentIndex = node.children.findIndex((child) => child.props['data-resume-list-content']);
+ const hasMarker = (children) => children.some((child) => child.props['data-resume-list-marker']);
+ const deferredMarker = nextChildren.find((child) => child.props['data-resume-list-marker']);
+ // Presence is advisory once the whole row moves to a fresh page. Consume
+ // the hint on continuation so an impossible window cannot rewind the
+ // same unsplit content indefinitely (including content-first rows).
+ const continuedMarkerProps = (marker) => ({
+ ...(deferredMarker?.props || marker.props),
+ minPresenceAhead: 0,
+ });
+ if (markerIndex !== -1 && !hasMarker(currentChilds) && hasMarker(nextChildren) && hasText(content(currentChilds))) {
+ // A presence hint or page break can defer a marker after content.
+ // Defer unsplit content while retaining the marker's consumed flags.
@@ -39,7 +46,7 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..d4471eda64ee99695bedf01f7a009880
+ nextChildren = node.children
+ .filter((child) => child.props['data-resume-list-content'] || child.props['data-resume-list-marker'])
+ .map((child) => child.props['data-resume-list-marker']
+ ? { ...child, props: deferredMarker.props }
+ ? { ...child, props: continuedMarkerProps(child) }
+ : child);
+ } else if (markerIndex !== -1 && !hasText(content(currentChilds)) && hasText(content(nextChildren))) {
+ // The first text did not fit. Preserve the authored marker/content
@@ -49,7 +56,7 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..d4471eda64ee99695bedf01f7a009880
+ // page break; restoring the original props would replay it.
+ const nextMarker = {
+ ...marker,
+ props: deferredMarker?.props || marker.props,
+ props: continuedMarkerProps(marker),
+ box: { ...marker.box, top: 0 },
+ };
+ currentChilds = currentChilds.filter((child) => !child.props['data-resume-list-marker']);
+3 -3
View File
@@ -12,7 +12,7 @@ overrides:
uuid@<11.1.1: ^11.1.1
patchedDependencies:
'@react-pdf/layout@5.2.0': add03fa9efd8cf4ab004a49c4c37bdf11f6d82109bfd3f6a2c48b7473aad29ae
'@react-pdf/layout@5.2.0': 75dac7cb8260f9c96cd9ab5522d095a3aa047881a9e9d1d2a597409b45944494
'@react-pdf/textkit': 092a6fe8baf3c472a81cdf2ab52a00ec98ef3364e1b8e744005dbcf219a8a213
importers:
@@ -11198,7 +11198,7 @@ snapshots:
jay-peg: 1.1.1
png-js: 2.0.0
'@react-pdf/layout@5.2.0(patch_hash=add03fa9efd8cf4ab004a49c4c37bdf11f6d82109bfd3f6a2c48b7473aad29ae)':
'@react-pdf/layout@5.2.0(patch_hash=75dac7cb8260f9c96cd9ab5522d095a3aa047881a9e9d1d2a597409b45944494)':
dependencies:
'@react-pdf/fns': 3.1.3
'@react-pdf/image': 3.1.2
@@ -11239,7 +11239,7 @@ snapshots:
'@babel/runtime': 7.29.7
'@react-pdf/fns': 3.1.3
'@react-pdf/font': 4.1.2
'@react-pdf/layout': 5.2.0(patch_hash=add03fa9efd8cf4ab004a49c4c37bdf11f6d82109bfd3f6a2c48b7473aad29ae)
'@react-pdf/layout': 5.2.0(patch_hash=75dac7cb8260f9c96cd9ab5522d095a3aa047881a9e9d1d2a597409b45944494)
'@react-pdf/primitives': 4.4.0
'@react-pdf/reconciler': 2.0.0(react@19.2.8)
'@react-pdf/render': 4.7.0