From 898f0267b932734f0e25f84cb75aaf24180d11cf Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 18:22:16 +0200 Subject: [PATCH] fix(pdf): consume oversized list marker presence hints --- .../templates/shared/list-pagination.test.tsx | 53 +++++++++++++++++++ patches/@react-pdf__layout@5.2.0.patch | 15 ++++-- pnpm-lock.yaml | 6 +-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/pdf/src/templates/shared/list-pagination.test.tsx b/packages/pdf/src/templates/shared/list-pagination.test.tsx index 8312f7bd9..5e910c1ec 100644 --- a/packages/pdf/src/templates/shared/list-pagination.test.tsx +++ b/packages/pdf/src/templates/shared/list-pagination.test.tsx @@ -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); diff --git a/patches/@react-pdf__layout@5.2.0.patch b/patches/@react-pdf__layout@5.2.0.patch index 2c7541996..2b9aa2324 100644 --- a/patches/@react-pdf__layout@5.2.0.patch +++ b/patches/@react-pdf__layout@5.2.0.patch @@ -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']); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbde32e0a..3eaa30910 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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