From 115bc70ad16e567a43dee8146a9a1a2b884614dc Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:42:50 -0700 Subject: [PATCH] fix: polyfill Map.prototype.getOrInsertComputed to stop PDF.js render crash Fixes #662 --- src/js/main.ts | 1 + src/js/utils/map-upsert-polyfill.ts | 47 ++++++++++ src/tests/map-upsert-polyfill.test.ts | 121 ++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/js/utils/map-upsert-polyfill.ts create mode 100644 src/tests/map-upsert-polyfill.test.ts diff --git a/src/js/main.ts b/src/js/main.ts index 78d4c41d..000af275 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -1,3 +1,4 @@ +import './utils/map-upsert-polyfill.js'; import { categories } from './config/tools.js'; import { dom, switchView, hideAlert } from './ui.js'; import { ShortcutsManager } from './logic/shortcuts.js'; diff --git a/src/js/utils/map-upsert-polyfill.ts b/src/js/utils/map-upsert-polyfill.ts new file mode 100644 index 00000000..6bcbaad5 --- /dev/null +++ b/src/js/utils/map-upsert-polyfill.ts @@ -0,0 +1,47 @@ +type MapPrototypeWithUpsert = typeof Map.prototype & { + getOrInsert?: (key: unknown, value: unknown) => unknown; + getOrInsertComputed?: ( + key: unknown, + callback: (key: unknown) => unknown + ) => unknown; +}; + +const mapPrototype = Map.prototype as MapPrototypeWithUpsert; +const mapHas = Map.prototype.has; +const mapGet = Map.prototype.get; +const mapSet = Map.prototype.set; + +if (typeof mapPrototype.getOrInsert !== 'function') { + Object.defineProperty(mapPrototype, 'getOrInsert', { + configurable: true, + writable: true, + value: function getOrInsert(key: unknown, value: unknown) { + if (mapHas.call(this, key)) return mapGet.call(this, key); + mapSet.call(this, key, value); + return value; + }, + }); +} + +if (typeof mapPrototype.getOrInsertComputed !== 'function') { + Object.defineProperty(mapPrototype, 'getOrInsertComputed', { + configurable: true, + writable: true, + value: function getOrInsertComputed( + key: unknown, + callback: (key: unknown) => unknown + ) { + const hasKey = mapHas.call(this, key); + if (typeof callback !== 'function') { + throw new TypeError('callback must be a function'); + } + if (hasKey) return mapGet.call(this, key); + + const value = callback(key); + mapSet.call(this, key, value); + return value; + }, + }); +} + +export {}; diff --git a/src/tests/map-upsert-polyfill.test.ts b/src/tests/map-upsert-polyfill.test.ts new file mode 100644 index 00000000..35c9d4cb --- /dev/null +++ b/src/tests/map-upsert-polyfill.test.ts @@ -0,0 +1,121 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type MapWithUpsert = Map & { + getOrInsert(key: K, value: V): V; + getOrInsertComputed(key: K, callback: (key: K) => V): V; +}; + +const getOrInsertDescriptor = Object.getOwnPropertyDescriptor( + Map.prototype, + 'getOrInsert' +); +const getOrInsertComputedDescriptor = Object.getOwnPropertyDescriptor( + Map.prototype, + 'getOrInsertComputed' +); + +const restoreMethod = ( + name: 'getOrInsert' | 'getOrInsertComputed', + descriptor: PropertyDescriptor | undefined +) => { + if (descriptor) { + Object.defineProperty(Map.prototype, name, descriptor); + } else { + Reflect.deleteProperty(Map.prototype, name); + } +}; + +const importPolyfill = async () => { + await import('../js/utils/map-upsert-polyfill'); +}; + +describe('Map upsert polyfill', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + restoreMethod('getOrInsert', getOrInsertDescriptor); + restoreMethod('getOrInsertComputed', getOrInsertComputedDescriptor); + vi.resetModules(); + }); + + it('preserves an existing native implementation', async () => { + const nativeImplementation = vi.fn(); + Object.defineProperty(Map.prototype, 'getOrInsertComputed', { + configurable: true, + writable: true, + value: nativeImplementation, + }); + + await importPolyfill(); + + expect( + Object.getOwnPropertyDescriptor(Map.prototype, 'getOrInsertComputed') + ?.value + ).toBe(nativeImplementation); + }); + + it('installs getOrInsertComputed when it is missing', async () => { + Reflect.deleteProperty(Map.prototype, 'getOrInsertComputed'); + + await importPolyfill(); + + const descriptor = Object.getOwnPropertyDescriptor( + Map.prototype, + 'getOrInsertComputed' + ); + expect(typeof descriptor?.value).toBe('function'); + expect(descriptor?.enumerable).toBe(false); + }); + + it('returns an existing value without invoking the callback', async () => { + Reflect.deleteProperty(Map.prototype, 'getOrInsertComputed'); + await importPolyfill(); + const map = new Map([['key', 'cached']]) as MapWithUpsert; + const callback = vi.fn(() => 'computed'); + + expect(map.getOrInsertComputed('key', callback)).toBe('cached'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('computes, stores, and returns a missing value', async () => { + Reflect.deleteProperty(Map.prototype, 'getOrInsertComputed'); + await importPolyfill(); + const map = new Map() as MapWithUpsert; + const callback = vi.fn((key: string) => `${key}-value`); + + expect(map.getOrInsertComputed('missing', callback)).toBe('missing-value'); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith('missing'); + expect(map.get('missing')).toBe('missing-value'); + }); + + it('installs getOrInsert with matching get-or-insert behavior', async () => { + Reflect.deleteProperty(Map.prototype, 'getOrInsert'); + await importPolyfill(); + const map = new Map([['existing', 'cached']]) as MapWithUpsert< + string, + string + >; + + expect(map.getOrInsert('existing', 'replacement')).toBe('cached'); + expect(map.getOrInsert('missing', 'inserted')).toBe('inserted'); + expect(map.get('missing')).toBe('inserted'); + }); + + it('loads before PDF.js so render can use the methods', () => { + const mainSource = readFileSync( + new URL('../js/main.ts', import.meta.url), + 'utf8' + ); + const polyfillImport = "import './utils/map-upsert-polyfill.js';"; + const pdfjsImport = "import * as pdfjsLib from 'pdfjs-dist';"; + + expect(mainSource.startsWith(polyfillImport)).toBe(true); + expect(mainSource.indexOf(polyfillImport)).toBeLessThan( + mainSource.indexOf(pdfjsImport) + ); + }); +});