doxa-maps/library/utils/embedScript.js
Doxa Maps c7c2c12cd0 Maps register under their own tags: <dxm-simple-map> and <dxm-research-map>
A host page can define a tag name before our bundle loads, and customElements.define is first come, first
served. DX Life's own scripts define <doxa-map>, <doxa-simple-map> and <doxa-research-map>, and our bundles
defined the same names behind a silent guard, so on those pages our define was skipped and the page drew the
host's element under our name (Prayer Progress on the Adopt page, and the Research page).
- The doxa-simple-map bundle defines <dxm-simple-map>; doxa-research-map defines <dxm-research-map>. Each is
  its bundle's first define, so the manifest, the leaf pages and the gallery mount them with no other change.
- The older names are defined only when the host asks: data-legacy-tags on the bundle's <script>, or
  window.DXM_LEGACY_TAGS = true before it loads. Off by default.
- library/elements/ownTags.js: a class is branded with its bundle; a re-load is silent; a tag held by a
  foreign class is reported (console.error), never adopted. whenTagDefined refuses a dxm- tag held by a
  foreign class rather than mounting it.
- research-map-plus-grid mounts <dxm-research-map>; the gallery and studio count the new tags as heavy
  (WebGL) elements; the studio's and the composite's composes lists name them.
- check:element-namespace (gates:pre; self-test in gates:post): no host name by default, each bundle's own
  tag first, the manifest mounts them, one bundle per tag (people-groups-grid is the one known exception),
  ownTags and whenTagDefined run against a stub registry.
2026-10-02 03:19:53 +00:00

211 lines
12 KiB
JavaScript

/**
* embedScript — load another bundle's script and wait for its element to register.
*
* ★ WHAT AN APP SHELL DOES. A capability-5 plugin nests other plugins, so it needs three things:
* work out the tree root it was served from, load a bundle's `.js` exactly once, and wait for the
* custom element to actually exist before mounting it. Both shells in this repo — the Research
* Gallery (`doxa-maps-spa`) and the Plugin Studio (`plugin-studio`) — had their own copy of all
* three, identical in code (DEDUP-AUDIT.md, 2026-09-28: 33 lines, the only cross-plugin cluster
* that is identical, closes over nothing, and spans one owner's plugins).
*
* Moved verbatim; the comments below are the originals, because each records a measurement.
*
* ★ "EXACTLY ONCE" WAS ONLY EVER TRUE INSIDE ONE BUNDLE, AND THAT IS THE BUG THIS FILE NOW FIXES.
* An earlier version of this header claimed that sharing `_loaded` here meant "two shells on one
* page ... now the second one reuses the first's promise". That was wrong, and the reason is the
* packaging, not the code: every plugin ships as a self-contained IIFE with `inlineDynamicImports`,
* so THIS MODULE IS COMPILED SEPARATELY INTO EACH BUNDLE and a module-scoped `const _loaded = {}`
* is one cache PER BUNDLE, not per page. MEASURED on origin/main's shipped tree 2026-09-28:
* 11 built bundles each carry their own copy.
*
* What that cost, and why it was invisible: every entry file guards its registration with
* `if (!customElements.get(tag)) customElements.define(tag, …)`, so the second load did NOT throw.
* It fetched from the HTTP cache (so the network panel looked innocent), then EXECUTED THE WHOLE
* BUNDLE a second time — a second Vue, a second vue-i18n, a second pinia, ~375 KiB gzip of work for
* doxa-research-map — and only at the very end declined to define the tag it had just built
* everything for. Nothing warned. The page simply ran twice as much script as it needed.
* Worse than the waste: if the two URLs are different VERSIONS of the same plugin, whichever
* executed first owns the tag and the other is silently discarded.
*
* So the cache now lives on the PAGE (`globalThis`), which is the only scope that matches the
* claim. See `registry()` for why the key is versioned.
*/
/**
* The page-wide registry. Bundles cannot share a module, but they all share one global object.
*
* ★ THE KEY CARRIES A VERSION ON PURPOSE. A page can mix bundles built weeks apart, so an old
* bundle and a new one must never read each other's registry through a shape either of them does
* not expect. A changed shape therefore gets a NEW key, and the two generations fall back to a
* cache each — today's behaviour, which merely wastes work — instead of one of them misreading the
* other's and skipping a load that never happened. Separate is recoverable; corrupt is not.
*
* `globalThis` rather than `window` so this file is loadable under node, where the gates run it.
*/
const REGISTRY_KEY = '__dxMapsScriptRegistry1'
function registry() {
const g = typeof globalThis !== 'undefined' ? globalThis : self
return g[REGISTRY_KEY] || (g[REGISTRY_KEY] = { loaded: Object.create(null), owners: Object.create(null) })
}
/**
* Resolve to the absolute URL the browser will actually request.
*
* ★ THIS IS WHAT MAKES TWO SHELLS AGREE. They do not pass the same string: StackLayout anchors a
* sibling bundle to the tree root first (resolveBundleSrc), VerticalTabsLayout passes the row's src
* untouched, and a host page may configure an absolute CDN URL. `/dx-maps/x/x.js` and
* `https://host/dx-maps/x/x.js` are one file and must be one cache entry, so the KEY is the
* resolved href — not the string the caller happened to hold.
*/
function absoluteHref(url) {
try {
if (typeof document !== 'undefined' && document.baseURI) return new URL(url, document.baseURI).href
if (typeof location !== 'undefined' && location.href) return new URL(url, location.href).href
} catch (_) { /* a non-URL string (a test stub, a bare name): key it as given */ }
return url
}
/**
* A <script> for this exact file that somebody else already put in the document.
*
* ★ WHY ADOPT ONE INSTEAD OF IGNORING IT. The registry only helps bundles that contain this
* version of this file. A page can also carry a bundle built BEFORE it, or a host page that writes
* its own <script> tag for a plugin — neither will ever touch `registry().loaded`. Reading the DOM
* is the one signal that is true regardless of who did the loading.
*
* We cannot know whether a foreign tag has finished executing (a script that already ran will never
* fire another onload), so adopting it resolves immediately. That is SAFE HERE FOR A CHECKED
* REASON, not by assumption: every caller in this repo follows loadScriptOnce with a wait on the
* custom element itself — doxa-maps-spa.vue:1405 and plugin-studio.vue:1138/1178 chain
* whenTagDefined(), StackLayout.vue:244 and VerticalTabsLayout.vue:736 chain their own
* waitForDefine() — and that wait, with its timeout, is the authoritative answer to "can I mount
* this yet". Resolving early hands the question to the component that can actually answer it.
* A NEW CALLER MUST KEEP THAT PAIRING; loadScriptOnce alone has never been proof the tag exists,
* which is the point the dev-server note below was written for.
*/
function existingScriptFor(href) {
if (typeof document === 'undefined' || !document.querySelectorAll) return null
const tags = document.querySelectorAll('script[src]')
for (let i = 0; i < tags.length; i++) if (tags[i].src === href) return tags[i]
return null
}
/**
* Load a bundle exactly once per URL PER PAGE; resolves after its script has executed.
*
* ★ A CLASSIC SCRIPT FIRES onload AND THEN FAILS, for a dev-server bundle. Measured 2026-09-22:
* pointing a plain <script> at a dev entry (/plugin-designs/<bundle>/index.js, a real ES MODULE)
* fires onload — so a loader watching onload reports SUCCESS — and only THEN throws "Cannot use
* import statement outside a module" as an ASYNC page error. The tag is never defined,
* document.createElement(tag) yields an inert HTMLUnknownElement, and the pane sits empty while its
* own state reads 'ready'. onload is structurally incapable of seeing this, so it is not a usable
* success signal by itself — which is why every mount below also waits on whenTagDefined().
*
* The built tree ships IIFEs, where a classic tag is correct, so this only ever bites under the dev
* server — exactly the surface a developer works on. Rule: a rooted /plugin-designs/ entry (or any
* .../index.js) is a dev ES module; everything else is a built IIFE.
* MEASURED a second time, independently, on stack-demo's dev leaf 2026-09-22 (StackLayout, before it
* used this file): 0 failed requests, and yet customElements.get('research-dashboard-map') === false
* with 0 rows rendered. VerticalTabsLayout's private copy LACKED this rule until it moved here, so a
* bundle nested through it on the dev server never defined its tag.
*
* ⚠ The module test runs against the URL THE CALLER GAVE, not the resolved href: `/plugin-designs/…`
* is root-relative and absolutising it first would destroy the very prefix being matched.
*
* An empty url is a no-op: a <script src=""> would re-fetch the page itself.
*
* SRI is optional (attrs.integrity). An adopted foreign tag keeps whatever integrity IT was given —
* we do not restate ours on someone else's element, because the bytes it loaded were already
* checked, or already not, before we ever saw it.
*/
export function loadScriptOnce(url, attrs) {
if (!url) return Promise.resolve()
const reg = registry()
const href = absoluteHref(url)
if (reg.loaded[href]) return reg.loaded[href]
if (existingScriptFor(href)) return (reg.loaded[href] = Promise.resolve())
reg.loaded[href] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
if (/^\/plugin-designs\//.test(url) || /\/index\.js(\?|$)/.test(url)) s.type = 'module'
if (attrs) for (const k in attrs) if (attrs[k]) s.setAttribute(k, attrs[k])
s.onload = () => ok()
s.onerror = () => { delete reg.loaded[href]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return reg.loaded[href]
}
/**
* Record which bundle a tag came from, and say so when two disagree.
*
* ★ THIS IS A DIAGNOSTIC, NOT A GUARD, and deliberately so. `customElements.define` is one-shot per
* page: by the time a second bundle gets here the first has already won, and there is nothing left
* to prevent — the damage was done at build time by two bundles shipping the same element. What was
* missing is that NOBODY SAID SO. The guarded define (`if (!customElements.get(tag))`) swallows the
* collision so completely that a page can render the wrong version of a plugin and look healthy.
*
* Call it after the tag is confirmed defined, with the URL it was loaded from. Same URL twice is the
* normal case (two shells nesting the same leaf) and is silent.
*/
export function claimTag(tag, url) {
if (!tag) return
const reg = registry()
const href = url ? absoluteHref(url) : ''
if (!href) return
const prev = reg.owners[tag]
if (!prev) { reg.owners[tag] = href; return }
if (prev === href) return
if (typeof console !== 'undefined' && console.warn) {
console.warn('[dx-maps] <' + tag + '> is shipped by two bundles on this page: ' + prev +
' defined it, ' + href + ' executed and was discarded. The page is running the first one.')
}
}
// Wait for a tag to ACTUALLY register. loadScriptOnce resolving is not proof the element exists (see
// above), and a module script's define() runs only after its import graph settles, so the tag can
// appear a tick or two after onload. customElements.whenDefined is the browser's own authoritative
// answer; the timeout turns a never-registered tag into THIS tab's error overlay rather than a silent
// blank pane that claims to be ready.
// ★ AND DEFINED BY DX MAPS. A dxm- tag is DX Maps' own name (library/elements/ownTags.js); one held by a class
// that no DX Maps bundle branded is a foreign definition, and mounting it would draw someone else's element
// under our name, so it fails here, loudly, instead of being adopted.
export function whenTagDefined(tag, ms) {
if (typeof customElements === 'undefined') return Promise.resolve()
const ours = () => (foreignDxmTag(tag)
? Promise.reject(new Error('<' + tag + '> is defined by a script that is not DX Maps'))
: undefined)
if (customElements.get(tag)) return ours() || Promise.resolve()
return Promise.race([
customElements.whenDefined(tag),
new Promise((_, fail) => setTimeout(() => {
if (customElements.get(tag)) return
fail(new Error('<' + tag + '> never registered (bundle loaded but defined no element)'))
}, ms || 15000))
]).then(() => ours())
}
// A dxm- tag held by a class no DX Maps bundle branded (ownTags.js brand()).
function foreignDxmTag(tag) {
if (!/^dxm-/.test(String(tag))) return false
const held = customElements.get(tag)
return Boolean(held) && !held.__dxmBundle
}
/**
* The tree root this shell was served from, with a trailing slash, or '' when it cannot be known.
*
* ⚠ READS document.currentScript, so it is only valid AT MODULE EVALUATION TIME. Call it once at the
* top of an entry file (`const TREE_BASE = deriveTreeBase()`), never later — `currentScript` is null
* once the script has finished executing, and a late call silently returns ''.
*/
export function deriveTreeBase() {
try {
const cur = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''
if (!cur) return ''
// Strip the trailing "<bundle>/<bundle>.js" → the tree root, with trailing slash.
const root = cur.replace(/[^/]+\/[^/]+$/, '')
return root.replace(/\/+$/, '') + '/'
} catch { return '' }
}