mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Fix PDF viewer crash on older macOS WebKit (#730)
This commit is contained in:
1 parent
7890281db3
commit
b4417add33
6 files changed
+352
-7
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
@@ -31,6 +31,7 @@
|
||||
<div id="cm-portal" class="cm-portal"></div>
|
||||
<div id="react-portal"></div>
|
||||
<div id="radix-portal" class="cm-portal"></div>
|
||||
<script type="module" src="/polyfills.ts"></script>
|
||||
<script type="module" src="/theme.ts"></script>
|
||||
<script type="module" src="/font-size.ts"></script>
|
||||
<script type="module" src="/font.ts"></script>
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<object, number>();
|
||||
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<string, string>();
|
||||
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<string, number>();
|
||||
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<number, number>();
|
||||
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<string, number[]>();
|
||||
map.getOrInsertComputed("a", () => []).push(1);
|
||||
map.getOrInsertComputed("a", () => []).push(2);
|
||||
expect(map.get("a")).toEqual([1, 2]);
|
||||
|
||||
const key = {};
|
||||
const weakMap = new WeakMap<object, number>();
|
||||
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<number>;
|
||||
|
||||
function streamOf(chunks: number[], onCancel?: () => void) {
|
||||
return new ReadableStream<number>({
|
||||
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<number>({
|
||||
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<number>({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<K, V> = {
|
||||
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<K, V>(this: KeyedCollection<K, V>, 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<K, V>(
|
||||
this: KeyedCollection<K, V>,
|
||||
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>): 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<R>(
|
||||
this: ReadableStream<R>,
|
||||
{ preventCancel = false }: { preventCancel?: boolean } = {},
|
||||
): AsyncGenerator<R, undefined, undefined> {
|
||||
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<K, V> {
|
||||
getOrInsert(key: K, value: V): V;
|
||||
getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
|
||||
}
|
||||
interface WeakMap<K extends WeakKey, V> {
|
||||
getOrInsert(key: K, value: V): V;
|
||||
getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
|
||||
}
|
||||
interface Math {
|
||||
sumPrecise(items: Iterable<number>): number;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
Reference in new issue
Block a user