From b4417add3307fa9e1314c330a09fb2c3439946db Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 6 Oct 2026 20:50:07 -0700 Subject: [PATCH] Fix PDF viewer crash on older macOS WebKit (#730) --- .../components/responseViewers/PdfViewer.tsx | 6 +- .../components/responseViewers/pdfWorker.ts | 3 + apps/yaak-client/index.html | 1 + apps/yaak-client/polyfills.test.ts | 167 +++++++++++++++++ apps/yaak-client/polyfills.ts | 172 ++++++++++++++++++ tests/pdfViewer.test.ts | 10 +- 6 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 apps/yaak-client/components/responseViewers/pdfWorker.ts create mode 100644 apps/yaak-client/polyfills.test.ts create mode 100644 apps/yaak-client/polyfills.ts diff --git a/apps/yaak-client/components/responseViewers/PdfViewer.tsx b/apps/yaak-client/components/responseViewers/PdfViewer.tsx index 54a3174b3..87831318e 100644 --- a/apps/yaak-client/components/responseViewers/PdfViewer.tsx +++ b/apps/yaak-client/components/responseViewers/PdfViewer.tsx @@ -5,13 +5,11 @@ import type { PDFDocumentProxy } from "pdfjs-dist"; import { useMemo, useRef, useState } from "react"; import { Document, Page, pdfjs } from "react-pdf"; import { useContainerSize } from "@yaakapp-internal/ui"; +import pdfWorkerUrl from "./pdfWorker?worker&url"; // Document can start loading during render, so configure its worker synchronously. // Vite bundles the installed PDF.js worker as a local asset, including offline builds. -pdfjs.GlobalWorkerOptions.workerSrc = new URL( - "pdfjs-dist/build/pdf.worker.min.mjs", - import.meta.url, -).toString(); +pdfjs.GlobalWorkerOptions.workerSrc = new URL(pdfWorkerUrl, import.meta.url).toString(); interface Props { /** A URL for the body the host already stored. */ diff --git a/apps/yaak-client/components/responseViewers/pdfWorker.ts b/apps/yaak-client/components/responseViewers/pdfWorker.ts new file mode 100644 index 000000000..25ded5e42 --- /dev/null +++ b/apps/yaak-client/components/responseViewers/pdfWorker.ts @@ -0,0 +1,3 @@ +// The worker has its own globals, so it needs the polyfills before PDF.js evaluates +import "../../polyfills"; +import "pdfjs-dist/build/pdf.worker.min.mjs"; diff --git a/apps/yaak-client/index.html b/apps/yaak-client/index.html index deaeb4359..b59379cc7 100644 --- a/apps/yaak-client/index.html +++ b/apps/yaak-client/index.html @@ -31,6 +31,7 @@
+ diff --git a/apps/yaak-client/polyfills.test.ts b/apps/yaak-client/polyfills.test.ts new file mode 100644 index 000000000..a15b56383 --- /dev/null +++ b/apps/yaak-client/polyfills.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import { getOrInsert, getOrInsertComputed, readableStreamValues, sumPrecise } from "./polyfills"; + +describe("getOrInsert", () => { + test("returns the existing value without replacing it", () => { + const map = new Map([["a", 1]]); + expect(getOrInsert.call(map, "a", 2)).toBe(1); + expect(map.get("a")).toBe(1); + }); + + test("inserts and returns the value for a missing key", () => { + const map = new Map(); + expect(getOrInsert.call(map, "a", 2)).toBe(2); + expect(map.get("a")).toBe(2); + }); + + test("works on a WeakMap", () => { + const key = {}; + const map = new WeakMap(); + expect(getOrInsert.call(map, key, 1)).toBe(1); + expect(getOrInsert.call(map, key, 2)).toBe(1); + }); +}); + +describe("getOrInsertComputed", () => { + test("returns the existing value without calling the callback", () => { + const map = new Map([["a", 1]]); + const callback = vi.fn(() => 2); + expect(getOrInsertComputed.call(map, "a", callback)).toBe(1); + expect(callback).not.toHaveBeenCalled(); + }); + + test("calls the callback with the key and inserts its result", () => { + const map = new Map(); + expect(getOrInsertComputed.call(map, "a", (key) => `${String(key)}!`)).toBe("a!"); + expect(map.get("a")).toBe("a!"); + }); + + test("stores what the callback returns even if the callback set the key", () => { + const map = new Map(); + const result = getOrInsertComputed.call(map, "a", () => { + map.set("a", 1); + return 2; + }); + expect(result).toBe(2); + expect(map.get("a")).toBe(2); + }); + + test("canonicalizes -0 to +0", () => { + const map = new Map(); + getOrInsertComputed.call(map, -0, (key) => { + expect(Object.is(key, 0)).toBe(true); + return 1; + }); + expect(Object.is([...map.keys()][0], 0)).toBe(true); + }); + + test("throws when the callback is not a function, even for an existing key", () => { + const map = new Map([["a", 1]]); + // @ts-expect-error testing a non-callable callback + expect(() => getOrInsertComputed.call(map, "a", 1)).toThrow(TypeError); + }); + + test("is installed on Map and WeakMap", () => { + const map = new Map(); + map.getOrInsertComputed("a", () => []).push(1); + map.getOrInsertComputed("a", () => []).push(2); + expect(map.get("a")).toEqual([1, 2]); + + const key = {}; + const weakMap = new WeakMap(); + expect(weakMap.getOrInsertComputed(key, () => 1)).toBe(1); + expect(weakMap.getOrInsert(key, 2)).toBe(1); + }); +}); + +describe("sumPrecise", () => { + test("sums without intermediate rounding", () => { + expect(sumPrecise([1e20, 0.1, -1e20])).toBe(0.1); + expect(sumPrecise([0.1, 0.2, 0.3])).toBe(0.6); + expect(sumPrecise(Array.from({ length: 10 }, () => 0.1))).toBe(1); + }); + + test("rounds half to even on the final step", () => { + expect(sumPrecise([1, 1e-16, 1e-16])).toBe(1.0000000000000002); + expect(sumPrecise([1, 2 ** -53])).toBe(1); + }); + + test("handles zeroes, infinities and NaN like the spec", () => { + expect(Object.is(sumPrecise([]), -0)).toBe(true); + expect(Object.is(sumPrecise([-0, -0]), -0)).toBe(true); + expect(Object.is(sumPrecise([-0, 0]), 0)).toBe(true); + expect(Object.is(sumPrecise([1, -1]), 0)).toBe(true); + expect(sumPrecise([1, Infinity])).toBe(Infinity); + expect(sumPrecise([1, -Infinity])).toBe(-Infinity); + expect(sumPrecise([Infinity, -Infinity])).toBeNaN(); + expect(sumPrecise([1, NaN])).toBeNaN(); + }); + + test("accepts any iterable and rejects non-numbers", () => { + expect(sumPrecise(new Set([1, 2, 3]))).toBe(6); + // @ts-expect-error testing a non-number item + expect(() => sumPrecise([1, "2"])).toThrow(TypeError); + }); + + test("is installed on Math", () => { + expect(Math.sumPrecise([1, 2, 3])).toBe(6); + }); +}); + +describe("readableStreamValues", () => { + const values = readableStreamValues; + + function streamOf(chunks: number[], onCancel?: () => void) { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + cancel: onCancel, + }); + } + + test("yields every chunk and releases the lock", async () => { + const stream = streamOf([1, 2, 3]); + const seen: number[] = []; + for await (const chunk of values.call(stream)) seen.push(chunk); + expect(seen).toEqual([1, 2, 3]); + expect(stream.locked).toBe(false); + }); + + test("cancels the stream when the loop exits early", async () => { + const onCancel = vi.fn(); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(1); + }, + cancel: onCancel, + }); + for await (const _ of values.call(stream)) break; + expect(onCancel).toHaveBeenCalled(); + expect(stream.locked).toBe(false); + }); + + test("leaves the stream open with preventCancel", async () => { + const onCancel = vi.fn(); + const stream = streamOf([1, 2], onCancel); + for await (const _ of values.call(stream, { preventCancel: true })) break; + expect(onCancel).not.toHaveBeenCalled(); + expect(stream.locked).toBe(false); + }); + + test("releases the lock and rethrows when cancelling rejects", async () => { + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(1); + }, + cancel() { + throw new Error("cancel failed"); + }, + }); + await expect(async () => { + for await (const _ of values.call(stream)) break; + }).rejects.toThrow("cancel failed"); + expect(stream.locked).toBe(false); + }); +}); diff --git a/apps/yaak-client/polyfills.ts b/apps/yaak-client/polyfills.ts new file mode 100644 index 000000000..7f6171dfc --- /dev/null +++ b/apps/yaak-client/polyfills.ts @@ -0,0 +1,172 @@ +/** + * Built-ins that PDF.js's modern build calls unconditionally but the system WebKit on + * older macOS lacks: the upsert methods and `Math.sumPrecise` arrived in Safari 26.2, + * and async iteration of `ReadableStream` in Safari 27. Without them the PDF viewer + * throws as soon as it renders. Loaded first by `index.html`, and by `pdfWorker.ts` + * because the worker has its own globals. + * + * Each is defined only when missing, so newer engines keep their native versions. + */ + +type KeyedCollection = { + has(key: K): boolean; + get(key: K): V | undefined; + set(key: K, value: V): unknown; +}; + +/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsert */ +export function getOrInsert(this: KeyedCollection, key: K, value: V): V { + if (this.has(key)) return this.get(key) as V; + this.set(key, value); + return value; +} + +/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsertComputed */ +export function getOrInsertComputed( + this: KeyedCollection, + key: K, + callbackfn: (key: K) => V, +): V { + if (typeof callbackfn !== "function") { + throw new TypeError("getOrInsertComputed callback is not a function"); + } + if (this.has(key)) return this.get(key) as V; + // Map keys are canonicalized, so -0 is stored and passed on as +0 + const k = (Object.is(key, -0) ? 0 : key) as K; + const value = callbackfn(k); + // The spec sets after the callback even if the callback inserted the key itself + this.set(k, value); + return value; +} + +/** + * https://tc39.es/proposal-math-sum/ + * + * Shewchuk's exact summation with round-half-even on the final step (the algorithm + * behind Python's `math.fsum`). A finite input set whose running sum overflows + * partway through is not handled exactly, which PDF.js's byte lengths and widths + * never come near. + */ +export function sumPrecise(items: Iterable): number { + const partials: number[] = []; + let allNegativeZero = true; + let sawNaN = false; + let sawPositiveInfinity = false; + let sawNegativeInfinity = false; + + for (const item of items) { + if (typeof item !== "number") { + throw new TypeError("Math.sumPrecise requires an iterable of numbers"); + } + if (!Object.is(item, -0)) allNegativeZero = false; + if (Number.isNaN(item)) { + sawNaN = true; + } else if (item === Infinity) { + sawPositiveInfinity = true; + } else if (item === -Infinity) { + sawNegativeInfinity = true; + } else { + let x = item; + let i = 0; + for (let y of partials) { + if (Math.abs(x) < Math.abs(y)) [x, y] = [y, x]; + const hi = x + y; + const lo = y - (hi - x); + if (lo !== 0) partials[i++] = lo; + x = hi; + } + partials.length = i; + partials.push(x); + } + } + + if (sawNaN || (sawPositiveInfinity && sawNegativeInfinity)) return NaN; + if (sawPositiveInfinity) return Infinity; + if (sawNegativeInfinity) return -Infinity; + if (allNegativeZero) return -0; + + let n = partials.length; + let hi = 0; + let lo = 0; + if (n > 0) { + hi = partials[--n] as number; + while (n > 0) { + const x = hi; + const y = partials[--n] as number; + hi = x + y; + lo = y - (hi - x); + if (lo !== 0) break; + } + const next = partials[n - 1]; + if (n > 0 && next != null && ((lo < 0 && next < 0) || (lo > 0 && next > 0))) { + const y = lo * 2; + const x = hi + y; + if (y === x - hi) hi = x; + } + } + return hi === 0 ? 0 : hi; +} + +/** https://streams.spec.whatwg.org/#rs-asynciterator */ +export async function* readableStreamValues( + this: ReadableStream, + { preventCancel = false }: { preventCancel?: boolean } = {}, +): AsyncGenerator { + const reader = this.getReader(); + let finished = false; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + finished = true; + return; + } + yield value; + } + } catch (err) { + finished = true; + throw err; + } finally { + // Leaving the loop early (break, return, throw in the body) cancels the stream. The + // lock is released even if cancelling rejects, which then propagates as in the spec. + try { + if (!finished && !preventCancel) await reader.cancel(); + } finally { + reader.releaseLock(); + } + } +} + +function define(target: object, key: PropertyKey, value: unknown) { + if (key in target) return; + Object.defineProperty(target, key, { value, writable: true, configurable: true }); +} + +define(Map.prototype, "getOrInsert", getOrInsert); +define(Map.prototype, "getOrInsertComputed", getOrInsertComputed); +define(WeakMap.prototype, "getOrInsert", getOrInsert); +define(WeakMap.prototype, "getOrInsertComputed", getOrInsertComputed); +define(Math, "sumPrecise", sumPrecise); +if (typeof ReadableStream !== "undefined") { + define(ReadableStream.prototype, "values", readableStreamValues); + define( + ReadableStream.prototype, + Symbol.asyncIterator, + Reflect.get(ReadableStream.prototype, "values"), + ); +} + +// TypeScript's lib does not describe these yet +declare global { + interface Map { + getOrInsert(key: K, value: V): V; + getOrInsertComputed(key: K, callbackfn: (key: K) => V): V; + } + interface WeakMap { + getOrInsert(key: K, value: V): V; + getOrInsertComputed(key: K, callbackfn: (key: K) => V): V; + } + interface Math { + sumPrecise(items: Iterable): number; + } +} diff --git a/tests/pdfViewer.test.ts b/tests/pdfViewer.test.ts index 79e53f5ca..95a6e6de6 100644 --- a/tests/pdfViewer.test.ts +++ b/tests/pdfViewer.test.ts @@ -93,9 +93,13 @@ test.each(["http://tauri.localhost", "tauri://localhost"])( const pdfjsDir = path.dirname(reactPdfRequire.resolve("pdfjs-dist/package.json")); const installed = JSON.parse(await readFile(path.join(pdfjsDir, "package.json"), "utf8")); expect(version).toBe(installed.version); - expect(Buffer.from(worker.source)).toEqual( - await readFile(path.join(pdfjsDir, "build/pdf.worker.min.mjs")), - ); + // The worker is bundled with the polyfills older WebKit needs, ahead of PDF.js itself. + const source = Buffer.from(worker.source).toString("utf8"); + const escapedVersion = installed.version.replaceAll(".", "\\."); + expect(source).toMatch(new RegExp(`["'\`]${escapedVersion}["'\`]`)); + const polyfillAt = source.indexOf("getOrInsertComputed callback is not a function"); + expect(polyfillAt).toBeGreaterThanOrEqual(0); + expect(polyfillAt).toBeLessThan(source.indexOf("pdfjs_internal_editor_")); }, 30_000, );