From 53e107d601379126f7c27284dc7629e04a80d815 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Wed, 11 Nov 2026 01:06:25 +0800 Subject: [PATCH 1/3] perf(web,server): a large PDF on a slow link shows its first page in a median 6.58 s instead of 23.34 s (3.5x) Co-Authored-By: Claude Opus 5.5 --- apps/server/src/http.test.ts | 8 +++ apps/server/src/http.ts | 2 + .../files/PdfViewer.browser.test.tsx | 47 +++++++++++++- apps/web/src/components/files/PdfViewer.tsx | 33 +++++++--- apps/web/src/components/files/pdfDocument.ts | 61 ++++++++++++++++++- 5 files changed, 137 insertions(+), 14 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 5f7df20c738c8..c6060d49fe839 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -136,6 +136,14 @@ describe("browser API CORS", () => { expect(allowedHeaders.has(ORCHESTRATION_PROTOCOL_HEADER)).toBe(true); expect(allowedHeaders.has("authorization")).toBe(true); expect(allowedHeaders.has("dpop")).toBe(true); + + // Range readers validate offsets and revision from these response headers. + const asset = await handler( + new Request("https://backend.example/api/environment", { + headers: { origin: "https://app.t3.codes" }, + }), + ); + expect(asset.headers.get("access-control-expose-headers")).toBe("content-range,etag"); } finally { await dispose(); } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 1427035404722..06a49d7acd2fc 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -267,6 +267,8 @@ export const layerBrowserApiCors = Layer.unwrap( : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, + // Cross-origin previews validate byte ranges against the size and revision. + exposedHeaders: ["content-range", "etag"], maxAge: 600, }); }), diff --git a/apps/web/src/components/files/PdfViewer.browser.test.tsx b/apps/web/src/components/files/PdfViewer.browser.test.tsx index dfffa2c7378eb..1a928fdcd57b2 100644 --- a/apps/web/src/components/files/PdfViewer.browser.test.tsx +++ b/apps/web/src/components/files/PdfViewer.browser.test.tsx @@ -16,7 +16,7 @@ import PdfViewer from "./PdfViewer"; // Page 1 is a short banner whose title is drawn in two runs with a font change, so pdf.js // splits it into two text spans. Page 2 is a letter page rotated a quarter turn. -function twoPagePdf() { +function twoPagePdf(padding = 0) { const pages = [ { box: "0 0 612 140", @@ -34,6 +34,15 @@ function twoPagePdf() { ]), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + // Unreferenced filler pushes the cross-reference table past the first range. + ...(padding + ? [ + `<< /Length ${padding} >> +stream +${"x".repeat(padding)} +endstream`, + ] + : []), ]; let source = "%PDF-1.4\n"; const offsets: number[] = []; @@ -110,6 +119,42 @@ it("fits the width, then finds split and rotated text and jumps to its page", as } }); +it.each([ + { server: "valid", full: false }, + { server: "ignored", full: true }, + { server: "malformed", full: true }, + { server: "revised", full: true }, +])("opens a PDF from a server with $server ranges", async ({ server, full }) => { + const bytes = new Uint8Array(await twoPagePdf(200_000).arrayBuffer()); + const url = "https://assets.example/report.pdf"; + const requests: (string | null)[] = []; + const realFetch = globalThis.fetch; + using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + if (String(input) !== url) return realFetch(input, init); + const range = new Headers(init?.headers).get("range"); + requests.push(range); + const match = /^bytes=(\d+)-(\d+)$/.exec(range ?? ""); + if (!match || server === "ignored") return Promise.resolve(new Response(bytes)); + const start = Number(match[1]); + const end = Math.min(Number(match[2]), bytes.length - 1); + const reported = server === "malformed" ? start + 1 : start; + return Promise.resolve( + new Response(bytes.slice(start, end + 1), { + status: 206, + headers: { + "content-range": `bytes ${reported}-${end}/${bytes.length}`, + etag: server === "revised" && requests.length > 1 ? '"b"' : '"a"', + }, + }), + ); + }); + await using screen = await render(); + await expect.element(screen.getByText("1 / 2 pages"), { timeout: 10_000 }).toBeVisible(); + expect(requests[0]).toBe("bytes=0-65535"); + expect(requests.includes(null)).toBe(full); + if (!full) expect(requests.length).toBeGreaterThan(1); +}); + it("restores page, within-page offset and zoom after a remount", async () => { const url = URL.createObjectURL(twoPagePdf()); const viewer = (mount: number, positionKey: string) => ( diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index 6a01bce2cfe90..6262d716b4e28 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -12,7 +12,7 @@ import { Input } from "~/components/ui/input"; import { observeResize } from "~/lib/observeResize"; import { FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading } from "./fileSurfaceChrome"; import { readBoundedDocument } from "./readBoundedDocument"; -import { openPdf } from "./pdfDocument"; +import { openPdf, openPdfRanges } from "./pdfDocument"; import { currentPdfPage, pdfFindIncludes, @@ -145,6 +145,7 @@ export default function PdfViewer(props: { const [pdf, setPdf] = useState(null); const [error, setError] = useState(null); const [revision, setRevision] = useState(0); + const [fullRead, setFullRead] = useState(false); const [ratios, setRatios] = useState([]); const [zoom, setZoom] = useState(initialView.zoom); const [pageWidth, setPageWidth] = useState(612); @@ -169,15 +170,27 @@ export default function PdfViewer(props: { controller.abort(); void task?.destroy(); }, 30_000); - void readBoundedDocument( - props.src, - controller.signal, - 50 * 1024 * 1024, - revision > 0 ? "reload" : "default", - ) - .then(async (bytes) => { + const limit = 50 * 1024 * 1024; + void (async () => { + const ranges = fullRead + ? null + : await openPdfRanges(props.src, controller.signal, limit, () => { + setPdf(null); + setFullRead(true); + }); + return ( + ranges ?? + (await readBoundedDocument( + props.src, + controller.signal, + limit, + revision > 0 ? "reload" : "default", + )) + ); + })() + .then(async (source) => { controller.signal.throwIfAborted(); - task = openPdf(bytes); + task = openPdf(source); const document = await task.promise; if (document.numPages > MAX_PAGES) throw new Error("Preview limited to 2,000 pages. Save this PDF to read it in full."); @@ -211,7 +224,7 @@ export default function PdfViewer(props: { clearTimeout(timeout); void task?.destroy(); }; - }, [props.src, revision, initialPage, savedPosition]); + }, [props.src, revision, fullRead, initialPage, savedPosition]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure the PDF viewport before paint and observe resizes for fit-width sizing and visible-page selection. useLayoutEffect(() => { const node = scroll.current; diff --git a/apps/web/src/components/files/pdfDocument.ts b/apps/web/src/components/files/pdfDocument.ts index 32e8aefbe9028..09a2d86892750 100644 --- a/apps/web/src/components/files/pdfDocument.ts +++ b/apps/web/src/components/files/pdfDocument.ts @@ -1,4 +1,4 @@ -import { getDocument, GlobalWorkerOptions, PDFWorker } from "pdfjs-dist"; +import { getDocument, GlobalWorkerOptions, PDFDataRangeTransport, PDFWorker } from "pdfjs-dist"; import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import fallbackUrl from "pdfjs-dist/wasm/openjpeg_nowasm_fallback.js?url"; @@ -34,11 +34,66 @@ export class BinaryDataFactory { // Without a shared worker, pdf.js starts a worker and recompiles its 1.3 MB script for every document. let worker: PDFWorker | undefined; -export function openPdf(bytes: ArrayBuffer) { +const RANGE_CHUNK_BYTES = 64 * 1024; + +async function readRange(src: string, begin: number, end: number, signal: AbortSignal) { + const response = await fetch(src, { + signal, + // A cached partial response makes the browser add If-Range, which the server answers in full. + cache: "no-store", + // Explicit offsets stay CORS-safelisted; suffix ranges need a preflight the server refuses. + headers: { range: `bytes=${begin}-${end - 1}` }, + }); + const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("content-range") ?? ""); + const size = Number(match?.[3]); + const bytes = response.status === 206 && match ? await response.arrayBuffer() : null; + if ( + !bytes || + Number(match![1]) !== begin || + Number(match![2]) !== Math.min(end, size) - 1 || + bytes.byteLength !== Math.min(end, size) - begin + ) { + void response.body?.cancel().catch(() => undefined); + throw new Error("Invalid PDF range response."); + } + return { bytes: new Uint8Array(bytes), size, revision: response.headers.get("etag") }; +} + +/** + * Reads only the byte ranges PDF.js asks for. Resolves null when the source cannot + * serve validated ranges; `onFailure` reports a later bad range or revision change. + */ +export async function openPdfRanges( + src: string, + signal: AbortSignal, + limit: number, + onFailure: () => void, +) { + const first = await readRange(src, 0, RANGE_CHUNK_BYTES, signal).catch(() => null); + if (!first?.revision) return null; + if (first.size > limit) throw new Error("This file is too large to preview."); + const transport = new PDFDataRangeTransport(first.size, first.bytes); + transport.requestDataRange = (begin: number, end: number) => { + void readRange(src, begin, end, signal) + .then((range) => { + if (range.size !== first.size || range.revision !== first.revision) + throw new Error("The PDF changed while loading."); + transport.onDataRange(begin, range.bytes); + }) + .catch(() => { + if (!signal.aborted) onFailure(); + }); + }; + return transport; +} + +export function openPdf(source: ArrayBuffer | PDFDataRangeTransport) { worker ??= new PDFWorker(); return getDocument({ worker, - data: new Uint8Array(bytes), + ...(source instanceof ArrayBuffer + ? { data: new Uint8Array(source) } + : { range: source, rangeChunkSize: RANGE_CHUNK_BYTES, disableAutoFetch: true }), useSystemFonts: true, useWorkerFetch: false, BinaryDataFactory, From f87a65d0606fb4889bdeefab17d00989a7ffa921 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Wed, 11 Nov 2026 01:24:40 +0800 Subject: [PATCH 2/3] fix(web): keep the PDF reading position after a range fallback Co-Authored-By: Claude Opus 5.5 --- .../files/PdfViewer.browser.test.tsx | 70 +++++++++++++++---- apps/web/src/components/files/PdfViewer.tsx | 36 +++++++--- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/files/PdfViewer.browser.test.tsx b/apps/web/src/components/files/PdfViewer.browser.test.tsx index 1a928fdcd57b2..78bc7bcf280c5 100644 --- a/apps/web/src/components/files/PdfViewer.browser.test.tsx +++ b/apps/web/src/components/files/PdfViewer.browser.test.tsx @@ -25,6 +25,7 @@ function twoPagePdf(padding = 0) { }, { box: "0 0 612 792", rotate: 90, text: "BT /F1 24 Tf 30 700 Td (Budget and risks) Tj ET" }, ]; + const filler = `<< /Length ${padding} >>\nstream\n${"x".repeat(padding)}\nendstream`; const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Count 2 /Kids [3 0 R 5 0 R] >>", @@ -34,21 +35,15 @@ function twoPagePdf(padding = 0) { ]), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", - // Unreferenced filler pushes the cross-reference table past the first range. - ...(padding - ? [ - `<< /Length ${padding} >> -stream -${"x".repeat(padding)} -endstream`, - ] - : []), + ...(padding ? [filler, filler] : []), ]; + // Unreferenced filler keeps page 2's content out of the first range and the cross-reference table's. + const order = padding ? [0, 1, 2, 3, 4, 6, 7, 8, 5, 9] : objects.map((_, index) => index); let source = "%PDF-1.4\n"; const offsets: number[] = []; - for (const [index, object] of objects.entries()) { - offsets.push(source.length); - source += `${index + 1} 0 obj\n${object}\nendobj\n`; + for (const index of order) { + offsets[index] = source.length; + source += `${index + 1} 0 obj\n${objects[index]}\nendobj\n`; } const xref = source.length; source += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets @@ -155,6 +150,57 @@ it.each([ if (!full) expect(requests.length).toBeGreaterThan(1); }); +it("keeps the reading position when a later range forces a full read", async () => { + const bytes = new Uint8Array(await twoPagePdf(200_000).arrayBuffer()); + const pageTwo = new TextDecoder().decode(bytes).indexOf("\n6 0 obj"); + const url = "https://assets.example/report.pdf"; + const requests: (string | null)[] = []; + let revise!: () => void; + const revised = new Promise((resolve) => (revise = resolve)); + const realFetch = globalThis.fetch; + using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + if (String(input) !== url) return realFetch(input, init); + const range = new Headers(init?.headers).get("range"); + requests.push(range); + const match = /^bytes=(\d+)-(\d+)$/.exec(range ?? ""); + if (!match) return new Response(bytes); + const start = Number(match[1]); + const end = Math.min(Number(match[2]), bytes.length - 1); + const changed = start <= pageTwo && end > pageTwo; + if (changed) await revised; + return new Response(bytes.slice(start, end + 1), { + status: 206, + headers: { + "content-range": `bytes ${start}-${end}/${bytes.length}`, + etag: changed ? '"b"' : '"a"', + }, + }); + }); + await using screen = await render( +
+ +
, + ); + await expect.element(screen.getByText("1 / 2 pages"), { timeout: 10_000 }).toBeVisible(); + const region = () => screen.getByRole("region", { name: "Ranged PDF" }).element(); + const top = 40; + const scrolled = new Promise((resolve) => + region().addEventListener("scroll", resolve, { once: true }), + ); + region().scrollTop = top; + await scrolled; + + revise(); + await vi.waitFor(() => { + expect(requests).toContain(null); + // Page 2 laid out at its own size means the full read replaced the ranged document. + const height = (page: number) => + region().querySelector(`canvas[aria-label="Page ${page}"]`)?.getBoundingClientRect().height; + expect(height(2)).toBeGreaterThan(height(1)! * 2); + }); + expect(region().scrollTop).toBeCloseTo(top, 0); +}); + it("restores page, within-page offset and zoom after a remount", async () => { const url = URL.createObjectURL(twoPagePdf()); const viewer = (mount: number, positionKey: string) => ( diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index 6262d716b4e28..f0b7b83a112a6 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -139,7 +139,7 @@ export default function PdfViewer(props: { const [savedPosition] = useState(() => props.positionKey === undefined ? undefined : positions.get(props.positionKey), ); - const [initialView] = useState( + const [initialView, setInitialView] = useState( () => savedPosition ?? props.initialView ?? { page: 0, offset: 0, zoom: null, left: 0 }, ); const [pdf, setPdf] = useState(null); @@ -159,6 +159,22 @@ export default function PdfViewer(props: { const scroll = useRef(null); const find = useRef(null); const zoomAnchor = useRef<{ page: number; offset: number; width: number } | null>(null); + const { scale, width } = pdfPageWidth(viewport.width, pageWidth, zoom); + const offsets = pdfPageOffsets(ratios, width); + const readView = (node: HTMLDivElement) => { + const page = currentPdfPage(offsets, ratios.length, node.scrollTop); + return { + page, + offset: Math.max(0, node.scrollTop - (offsets[page]! - 16)), + zoom, + left: node.scrollLeft, + }; + }; + // A range fallback reloads the document; the reader stays where they were. + const keepView = useEffectEvent(() => { + const node = scroll.current; + if (pdf && node) setInitialView({ ...readView(node), width }); + }); const initialPage = initialView.page; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the PDF fetch, load timeout, and pdf.js document task across source changes, retries, and unmounts. useEffect(() => { @@ -175,6 +191,7 @@ export default function PdfViewer(props: { const ranges = fullRead ? null : await openPdfRanges(props.src, controller.signal, limit, () => { + keepView(); setPdf(null); setFullRead(true); }); @@ -233,8 +250,6 @@ export default function PdfViewer(props: { update(); return observeResize(node, update); }, []); - const { scale, width } = pdfPageWidth(viewport.width, pageWidth, zoom); - const offsets = pdfPageOffsets(ratios, width); const current = currentPdfPage(offsets, ratios.length, scrollTop); const visible = visiblePdfPages(ratios, offsets, scrollTop, viewport.height); // Stable callbacks let page canvases survive scroll and find updates. @@ -248,14 +263,8 @@ export default function PdfViewer(props: { scroll.current?.scrollTo({ top: pdfPageScrollTop(offsets, index) }); }); const saveView = (node: HTMLDivElement, notify = true) => { - const page = currentPdfPage(offsets, ratios.length, node.scrollTop); if (!pdf) return; - const view = { - page, - offset: Math.max(0, node.scrollTop - (offsets[page]! - 16)), - zoom, - left: node.scrollLeft, - }; + const view = readView(node); if (notify) props.onViewChange?.(view); if (props.positionKey !== undefined) { positions.delete(props.positionKey); @@ -268,7 +277,12 @@ export default function PdfViewer(props: { const node = scroll.current; if (!pdf || !node) return; node.scrollTo({ - top: pdfRestoredScrollTop(ratios, width, initialView, savedPosition?.width), + top: pdfRestoredScrollTop( + ratios, + width, + initialView, + initialView.width ?? savedPosition?.width, + ), left: initialView.left, }); setScrollTop(node.scrollTop); From e2ebbfe053c11f5611ef450ea0aa4491fba2b804 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Wed, 11 Nov 2026 01:33:53 +0800 Subject: [PATCH 3/3] fix(web): bypass the HTTP cache when a PDF range fallback reads the full file Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/files/PdfViewer.browser.test.tsx | 8 +++++++- apps/web/src/components/files/PdfViewer.tsx | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/PdfViewer.browser.test.tsx b/apps/web/src/components/files/PdfViewer.browser.test.tsx index 78bc7bcf280c5..1592c61f571b2 100644 --- a/apps/web/src/components/files/PdfViewer.browser.test.tsx +++ b/apps/web/src/components/files/PdfViewer.browser.test.tsx @@ -155,6 +155,7 @@ it("keeps the reading position when a later range forces a full read", async () const pageTwo = new TextDecoder().decode(bytes).indexOf("\n6 0 obj"); const url = "https://assets.example/report.pdf"; const requests: (string | null)[] = []; + let fullReadCache: RequestCache | undefined; let revise!: () => void; const revised = new Promise((resolve) => (revise = resolve)); const realFetch = globalThis.fetch; @@ -163,7 +164,10 @@ it("keeps the reading position when a later range forces a full read", async () const range = new Headers(init?.headers).get("range"); requests.push(range); const match = /^bytes=(\d+)-(\d+)$/.exec(range ?? ""); - if (!match) return new Response(bytes); + if (!match) { + fullReadCache = init?.cache; + return new Response(bytes); + } const start = Number(match[1]); const end = Math.min(Number(match[2]), bytes.length - 1); const changed = start <= pageTwo && end > pageTwo; @@ -199,6 +203,8 @@ it("keeps the reading position when a later range forces a full read", async () expect(height(2)).toBeGreaterThan(height(1)! * 2); }); expect(region().scrollTop).toBeCloseTo(top, 0); + // The changed file may sit in the HTTP cache from an earlier full read. + expect(fullReadCache).toBe("reload"); }); it("restores page, within-page offset and zoom after a remount", async () => { diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index f0b7b83a112a6..aac296a8ba19b 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -201,7 +201,7 @@ export default function PdfViewer(props: { props.src, controller.signal, limit, - revision > 0 ? "reload" : "default", + revision > 0 || fullRead ? "reload" : "default", )) ); })()