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,
);