Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/App/components/Tooltip/getTooltipPosition.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export function getTooltipPosition (triggerEl, containerEl) {

// Step 1: Find the actual smallest space
const entries = Object.entries(space)
const sorted = entries.toSorted(([, a], [, b]) => a - b)
const [leastSide, leastValue] = sorted[0]
entries.sort(([, a], [, b]) => a - b)
const [leastSide, leastValue] = entries[0]

// Step 2: Prefer horizontal if it's close in smallness
if ((leastSide === 'top' || leastSide === 'bottom')) {
Expand Down
43 changes: 37 additions & 6 deletions src/InteractiveMap/polyfills.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ if (typeof crypto !== 'undefined' && !crypto.randomUUID) {
}
}

// Object.hasOwn — capture before polyfilling so the worker injection condition below is correct
const needsHasOwn = !Object.hasOwn
if (needsHasOwn) {
Object.hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) // NOSONAR
}

// AbortSignal.throwIfAborted
const needsThrowIfAborted = typeof AbortController !== 'undefined' &&
!Object.getPrototypeOf(new AbortController().signal).throwIfAborted
Expand All @@ -25,15 +31,40 @@ if (needsThrowIfAborted) {
}
}

// Inject polyfill into web workers created from blob URLs (e.g. MapLibre GL)
// Workers have their own global scope so main thread polyfills don't apply
if (needsThrowIfAborted && typeof URL !== 'undefined' && URL.createObjectURL) {
// Inject polyfills into web workers — workers have their own global scope so
// main thread polyfills don't apply. Two cases:
// 1. Blob URL workers (MapLibre default): intercept URL.createObjectURL
// 2. String URL workers (MapLibre workerUrl, used in CSP environments): intercept Worker constructor
const needsWorkerPolyfills = (needsThrowIfAborted || needsHasOwn) && typeof URL !== 'undefined' && URL.createObjectURL

if (needsWorkerPolyfills) {
const JS_MIME = 'text/javascript'
const workerPolyfillCode = [
'if(!Object.hasOwn){Object.hasOwn=function(o,k){return Object.prototype.hasOwnProperty.call(o,k)}}\n',
needsThrowIfAborted ? 'if(typeof AbortController!=="undefined"){var _p=Object.getPrototypeOf(new AbortController().signal);if(!_p.throwIfAborted){_p.throwIfAborted=function(){if(this.aborted){var e=new Error("The operation was aborted.");e.name="AbortError";throw e}}}}\n' : ''
].join('')

// Save original before overriding, so the Worker override below can use it directly
// and avoid double-injecting the polyfill code
const _createObjectURL = URL.createObjectURL.bind(URL)

URL.createObjectURL = (blob) => {
if (blob instanceof Blob && blob.type === 'text/javascript') {
const p = 'if(typeof AbortController!=="undefined"){var _p=Object.getPrototypeOf(new AbortController().signal);if(!_p.throwIfAborted){_p.throwIfAborted=function(){if(this.aborted){var e=new Error("The operation was aborted.");e.name="AbortError";throw e}}}}\n'
blob = new Blob([p, blob], { type: 'text/javascript' })
if (blob instanceof Blob && blob.type === JS_MIME) {
blob = new Blob([workerPolyfillCode, blob], { type: JS_MIME })
}
return _createObjectURL(blob)
}

if (typeof Worker !== 'undefined') {
const NativeWorker = Worker
// eslint-disable-next-line no-global-assign
Worker = function (url, options) {
if (typeof url === 'string' && !url.startsWith('blob:')) {
const blob = new Blob([`${workerPolyfillCode}importScripts(${JSON.stringify(url)})`], { type: JS_MIME })
url = _createObjectURL(blob)
}
return new NativeWorker(url, options)
}
Worker.prototype = NativeWorker.prototype
}
}
233 changes: 132 additions & 101 deletions src/InteractiveMap/polyfills.test.js
Original file line number Diff line number Diff line change
@@ -1,127 +1,158 @@
describe('Polyfills', () => {
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const originalCryptoUUID = crypto.randomUUID
const originalCreateObjectURL = URL.createObjectURL
const originalHasOwn = Object.hasOwn
const originalWorker = globalThis.Worker
const signalProto = Object.getPrototypeOf(new AbortController().signal)
const originalThrowIfAborted = signalProto.throwIfAborted

beforeEach(() => { jest.resetModules() })

afterEach(() => {
Object.defineProperty(crypto, 'randomUUID', { value: originalCryptoUUID, configurable: true, writable: true })
URL.createObjectURL = originalCreateObjectURL
Object.hasOwn = originalHasOwn
globalThis.Worker = originalWorker
if (originalThrowIfAborted) {
signalProto.throwIfAborted = originalThrowIfAborted
} else {
delete signalProto.throwIfAborted
}
})

const originalCryptoUUID = crypto.randomUUID
const originalCreateObjectURL = URL.createObjectURL
const signalProto = Object.getPrototypeOf(new AbortController().signal)
const originalThrowIfAborted = signalProto.throwIfAborted
const load = () => jest.isolateModules(() => require('./polyfills.js'))

beforeEach(() => {
jest.resetModules()
})
const readBlobText = (blob) => new Promise((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.readAsText(blob) // NOSONAR: Blob#text() is not available in this jsdom version
})

// ─── crypto.randomUUID ───────────────────────────────────────────────────────

afterEach(() => {
Object.defineProperty(crypto, 'randomUUID', {
value: originalCryptoUUID,
configurable: true,
writable: true
})
describe('crypto.randomUUID', () => {
const nullifyUUID = () => Object.defineProperty(crypto, 'randomUUID', { value: null, configurable: true, writable: true })

URL.createObjectURL = originalCreateObjectURL
test('polyfills crypto.randomUUID when missing', () => {
nullifyUUID()
load()
expect(typeof crypto.randomUUID).toBe('function')
expect(crypto.randomUUID()).toMatch(UUID_RE)
})

test('generates unique UUIDs', () => {
nullifyUUID()
load()
const ids = new Set(Array.from({ length: 100 }, () => crypto.randomUUID()))
expect(ids.size).toBe(100)
})

if (originalThrowIfAborted) {
signalProto.throwIfAborted = originalThrowIfAborted
} else {
delete signalProto.throwIfAborted
}
test('does not overwrite existing crypto.randomUUID', () => {
load()
expect(crypto.randomUUID).toBe(originalCryptoUUID)
})
})

const load = () => jest.isolateModules(() => require('./polyfills.js'))
// ─── Object.hasOwn ───────────────────────────────────────────────────────────

// Helper to read Blob text in environments without blob.text()
const readBlobText = (blob) => new Promise((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.readAsText(blob)
describe('Object.hasOwn', () => {
test('polyfills Object.hasOwn when missing', () => {
delete Object.hasOwn
load()
expect(typeof Object.hasOwn).toBe('function')
expect(Object.hasOwn({ a: 1 }, 'a')).toBe(true)
expect(Object.hasOwn({ a: 1 }, 'b')).toBe(false)
})

describe('crypto.randomUUID', () => {
test('polyfills crypto.randomUUID when missing (Lines 3-8)', () => {
Object.defineProperty(crypto, 'randomUUID', {
value: undefined,
configurable: true,
writable: true
})

load()

expect(typeof crypto.randomUUID).toBe('function')
expect(crypto.randomUUID()).toMatch(UUID_RE)
})

test('crypto.randomUUID generates unique UUIDs', () => {
Object.defineProperty(crypto, 'randomUUID', {
value: undefined,
configurable: true,
writable: true
})
load()
const ids = new Set(Array.from({ length: 100 }, () => crypto.randomUUID()))
expect(ids.size).toBe(100)
})

test('does not overwrite existing crypto.randomUUID', () => {
const fake = jest.fn(() => 'fake')
Object.defineProperty(crypto, 'randomUUID', {
value: fake,
configurable: true,
writable: true
})
load()
expect(crypto.randomUUID).toBe(fake)
})
test('does not overwrite existing Object.hasOwn', () => {
load()
expect(Object.hasOwn).toBe(originalHasOwn)
})
})

describe('AbortSignal.throwIfAborted', () => {
test('throws AbortError when aborted (True branch)', () => {
delete signalProto.throwIfAborted
load()
const ac = new AbortController()
ac.abort()
expect(() => ac.signal.throwIfAborted()).toThrow('The operation was aborted.')
})
// ─── AbortSignal.throwIfAborted ───────────────────────────────────────────────

test('does nothing when not aborted (False branch)', () => {
delete signalProto.throwIfAborted
load()
const ac = new AbortController()
// This call should execute line 20, see that aborted is false, and return undefined
expect(() => ac.signal.throwIfAborted()).not.toThrow()
})
describe('AbortSignal.throwIfAborted', () => {
beforeEach(() => { delete signalProto.throwIfAborted })

test('wraps URL.createObjectURL for JS blobs', async () => {
delete signalProto.throwIfAborted
test('throws AbortError when aborted', () => {
load()
const ac = new AbortController()
ac.abort()
expect(() => ac.signal.throwIfAborted()).toThrow('The operation was aborted.')
})

const mockCreate = jest.fn(() => 'blob:mock')
URL.createObjectURL = mockCreate
test('does nothing when not aborted', () => {
load()
expect(() => new AbortController().signal.throwIfAborted()).not.toThrow()
})

load()
test('wraps URL.createObjectURL for JS blobs', async () => {
const mockCreate = jest.fn(() => 'blob:mock')
URL.createObjectURL = mockCreate
load()

const content = 'console.log(1)'
const blob = new Blob([content], { type: 'text/javascript' })
const result = URL.createObjectURL(blob)
URL.createObjectURL(new Blob(['console.log(1)'], { type: 'text/javascript' }))
const text = await readBlobText(mockCreate.mock.calls[0][0])
expect(text).toContain('throwIfAborted')
expect(text).toContain('console.log(1)')
})

expect(result).toBe('blob:mock')
expect(mockCreate).toHaveBeenCalled()
test('does not wrap URL.createObjectURL for non-JS blobs', () => {
const mockCreate = jest.fn(() => 'blob:mock')
URL.createObjectURL = mockCreate
load()

const passedBlob = mockCreate.mock.calls[0][0]
const text = await readBlobText(passedBlob)
const blob = new Blob(['{}'], { type: 'application/json' })
URL.createObjectURL(blob)
expect(mockCreate).toHaveBeenCalledWith(blob)
})
})

expect(text).toContain('throwIfAborted')
expect(text).toContain(content)
})
// ─── Worker constructor (string workerUrl) ────────────────────────────────────

describe('Worker constructor (string workerUrl)', () => {
beforeEach(() => { delete Object.hasOwn })

const setupWorkerMock = () => {
const MockWorker = jest.fn()
MockWorker.prototype = {}
globalThis.Worker = MockWorker
return MockWorker
}

test('wraps Worker with string URL to inject polyfill via importScripts', async () => {
const MockWorker = setupWorkerMock()
const mockCreate = jest.fn(() => 'blob:injected')
URL.createObjectURL = mockCreate
load()

// eslint-disable-next-line no-new
new Worker('/worker.js') // NOSONAR
const text = await readBlobText(mockCreate.mock.calls[0][0])
expect(text).toContain('Object.hasOwn')
expect(text).toContain('importScripts("/worker.js")')
expect(MockWorker).toHaveBeenCalledWith('blob:injected', undefined)
})

test('does not wrap URL.createObjectURL for non-JS blobs', () => {
delete signalProto.throwIfAborted
const mockCreate = jest.fn(() => 'blob:mock')
URL.createObjectURL = mockCreate
test('passes blob URLs through without wrapping', () => {
const MockWorker = setupWorkerMock()
const mockCreate = jest.fn(() => 'blob:new')
URL.createObjectURL = mockCreate
load()

load()
// eslint-disable-next-line no-new
new Worker('blob:http://localhost/existing') // NOSONAR
expect(mockCreate).not.toHaveBeenCalled()
expect(MockWorker).toHaveBeenCalledWith('blob:http://localhost/existing', undefined)
})

const blob = new Blob(['{}'], { type: 'application/json' })
URL.createObjectURL(blob)
test('preserves the Worker prototype', () => {
const MockWorker = setupWorkerMock()
const mockProto = { foo: 'bar' }
MockWorker.prototype = mockProto
load()

expect(mockCreate).toHaveBeenCalledWith(blob)
})
expect(Worker.prototype).toBe(mockProto)
})
})
Loading