PPLR data maps: loading d3 no longer writes into the host page's own d3

4c45d76c put the host's window.d3 back after loading the pinned d3, but d3's UMD
build writes its exports INTO an existing window.d3 ((t = globalThis).d3 =
t.d3 || {}). By the time the host's object was restored, ours had been written
into it: rega measured 577 added keys and version '7.9.0' on a host's own d3.
A host on d3 v6 would get v7 functions.

While our script executes (document.currentScript === the element we
appended), window.d3 now reads and writes a fresh slot of ours. Any other code,
at every moment, reads and writes the host's own value, so nothing is blanked
while the file loads. The host's value is then restored as a plain data
property. A non-configurable global (a page-level `var d3`) cannot be trapped,
so it is emptied for the load instead.

Headless, with the real d3@7.9.0 file (its sha384 matches the pinned SRI), run
in a vm realm with document.currentScript set as a browser does:
  before  host owns window.d3: keys 2 -> 579, version -> 7.9.0 (rega's finding)
  after   host owns window.d3: keys 2 -> 2, version host-7.0.0, same object;
          PPLR gets its own d3 7.9.0; the host sees its own d3 during the load
  after   no host d3: window.d3 absent afterwards, as before
The vm realm makes `var d3` configurable, so the non-configurable fallback was
not exercised there. The probe is kept at _qa-cycle/exp-shepherd/d3-probe/.
This commit is contained in:
Doxa Maps 2026-09-26 03:49:03 +00:00
parent 05346b1e53
commit 60faff1a48

View file

@ -24,19 +24,51 @@ export function ensureD3() {
if (d3Lib) return Promise.resolve(d3Lib)
if (typeof window === 'undefined') return Promise.reject(new Error('no window'))
if (!d3Promise) d3Promise = new Promise((res, rej) => {
const hadOwn = Object.prototype.hasOwnProperty.call(window, 'd3')
const hostD3 = window.d3
const s = document.createElement('script')
s.src = D3_URL
s.integrity = D3_SRI
s.crossOrigin = 'anonymous'
s.onload = () => {
d3Lib = window.d3
if (hadOwn) window.d3 = hostD3
else { try { delete window.d3 } catch (_) { window.d3 = undefined } }
res(d3Lib)
// ★ PUTTING THE HOST'S window.d3 BACK IS NOT ENOUGH. d3's UMD build writes its exports INTO an
// existing window.d3 — `(t = globalThis).d3 = t.d3 || {}` — so a host that owns a d3 had ours
// written over it before we restored it: rega measured 577 added keys and version '7.9.0' on a
// host's own object (a host on d3 v6 would get v7 functions). So while OUR script executes
// (document.currentScript === s), window.d3 reads and writes a fresh slot of ours; any other code,
// at every moment, reads and writes the host's own value. Nothing is blanked while the file loads.
const own = Object.getOwnPropertyDescriptor(window, 'd3')
let hostD3 = window.d3
let ours
let trapped = false
if (!own || (own.configurable && 'value' in own)) {
Object.defineProperty(window, 'd3', {
configurable: true, enumerable: own ? own.enumerable : true,
get: () => (document.currentScript === s ? ours : hostD3),
set: (v) => { if (document.currentScript === s) ours = v; else hostD3 = v },
})
trapped = true
} else if (own.writable) {
// A `var d3` global is non-configurable, so it cannot be trapped: empty it for the load instead
// (the host reads undefined until onload), which still keeps our exports out of its object.
window.d3 = undefined
}
s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load (network or integrity)')) }
const restore = () => {
if (trapped) {
delete window.d3
if (own || hostD3 !== undefined) {
Object.defineProperty(window, 'd3', { value: hostD3, writable: own ? own.writable : true,
configurable: true, enumerable: own ? own.enumerable : true })
}
} else if (own && own.writable) {
window.d3 = hostD3
}
}
s.onload = () => {
const lib = trapped ? ours : window.d3
restore()
d3Lib = lib
if (d3Lib) res(d3Lib)
else { d3Promise = null; rej(new Error('d3 loaded but exported nothing (an AMD loader on the page?)')) }
}
s.onerror = () => { restore(); d3Promise = null; rej(new Error('d3 failed to load (network or integrity)')) }
document.head.appendChild(s)
})
return d3Promise