Plugin studio embeds real elements, and a rooted URL is no longer double-prefixed
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
EVERY TAB FAILED TO LOAD. Introducing tree-base for the dev server prefixed it onto URLs that were already rooted, producing /__plugin-gallery//plugin-designs/<b>/index.js and a 404 on all 29 tabs. Both capability-5 shells had byte-identical resolve() implementations, so both carried the same defect; both now return an already-absolute ref untouched and join through exactly one slash. The built tree hands relative refs and keeps its base, so this is one rule for both trees rather than a dev-only branch. THE STUDIO NO LONGER FRAMES ANYTHING. Map elements were nested in iframes pointing at their standalone leaf pages, to dodge a one-shot customElements.define collision. That collision cannot occur here: 19 bundles claim 19 distinct tags, none shared, and two map elements were proven to coexist in one document with the first unaffected by the second. Every pane now mounts the plugin's real custom element, which is what makes a row both reviewable and nestable. Panes keep two lifetimes, and the difference is the WebGL context budget rather than taste. Light rows stay mounted so switching back is instant and state survives. Map rows are destroyed on deactivate, because a hidden map holds its GPU context: the browser caps live contexts, then silently reclaims the OLDEST, so a map visited ten tabs ago would go blank with no error anywhere. getContext never returns null and contextlost never fires on the new canvas, which is what makes that failure so hard to attribute. The dev server serves a leaf route for one element with no chrome, and passes the tree base to nested shells so they discover the live tree instead of a baked fallback.
This commit is contained in:
parent
8124708d12
commit
50937cf766
3 changed files with 161 additions and 109 deletions
|
|
@ -191,7 +191,14 @@ export function autoSpaDevPlugin(token = '') {
|
|||
'<style>html,body{margin:0;padding:0;height:100%;background:#f3f3f1;}',
|
||||
`${tag}{display:block;width:100%;height:100vh;}</style>`,
|
||||
'</head><body>',
|
||||
`<${tag} profile-config='${pc}'></${tag}>`,
|
||||
// tree-base ON THE LEAF TOO — cap-5 shells nested here fetch a manifest of their own.
|
||||
// doxa-maps-spa (like plugin-studio) discovers its tabs from the tree's manifest.json and
|
||||
// resolves it RELATIVE to its own page when no base is given. On a /__leaf/ URL that
|
||||
// resolved to /__leaf/manifest.json and 400'd. Harmless (both shells carry a baked
|
||||
// fallback list) but it silently degrades the tab list to the fallback instead of the
|
||||
// live 19-bundle tree. Passing the base makes a nested shell see exactly what the top
|
||||
// level sees. Elements that ignore tree-base simply drop an unknown attribute.
|
||||
`<${tag} tree-base="/__plugin-gallery" profile-config='${pc}'></${tag}>`,
|
||||
`<script type="module" src="/plugin-designs/${bundle}/index.js"></script>`,
|
||||
'</body></html>',
|
||||
].filter(Boolean).join('\n'))
|
||||
|
|
|
|||
|
|
@ -724,8 +724,21 @@ export default defineComponent({
|
|||
// relative to THIS document — but this bundle's own folder is <base>/doxa-maps-spa/, so a
|
||||
// co-located leaf at <base>/big-picture/... is one level UP: prefix '../'.
|
||||
resolve(ref) {
|
||||
const r = String(ref).replace(/^\.\//, '')
|
||||
if (this.base) return this.base + r
|
||||
const raw = String(ref)
|
||||
// AN ALREADY-ABSOLUTE REF IS RETURNED UNTOUCHED. Identical rule (and identical prior bug) to
|
||||
// plugin-studio.vue's resolve — the two cap-5 shells had byte-identical implementations, so
|
||||
// they inherited the same defect. Measured 2026-09-22 with this SPA nested inside the studio
|
||||
// under the dev server: 404 /doxa-research-map/doxa-research-map.js — a BUILT-tree path, the
|
||||
// shape this file's own note at :255 describes ("leaf/bundleJs are TREE-RELATIVE"). Under the
|
||||
// built tree that is correct; under dev the manifest hands ROOTED refs (/plugin-designs/...),
|
||||
// and prefixing a rooted ref is what produced a path that exists nowhere.
|
||||
// A URL already anchored at the root needs no base by definition, so this is right for BOTH
|
||||
// trees rather than a dev-only special case.
|
||||
if (/^(https?:)?\/\//.test(raw) || raw.startsWith('/')) return raw
|
||||
const r = raw.replace(/^\.\//, '')
|
||||
// Join through exactly one slash: base() normalises to a trailing "/", but a caller-supplied
|
||||
// tree-base may not, and a double slash 404s just as surely as a wrong prefix.
|
||||
if (this.base) return this.base.replace(/\/+$/, '') + '/' + r
|
||||
return '../' + r // relative fallback: sibling bundle folders are one level up
|
||||
},
|
||||
select(i) {
|
||||
|
|
|
|||
|
|
@ -18,13 +18,17 @@
|
|||
plugin, so it too is a real element (<plugin-studio>), NOT an index.html page. A page could
|
||||
only be iframed (one method) and could never be nested as a tab of another cap-5 shell.
|
||||
|
||||
HOW IT NESTS EACH ELEMENT. Same two-mode strategy proven in doxa-maps-spa:
|
||||
mode 'element' → inject the bundle's built .js once (guarded), then create its real tag in an
|
||||
empty Vue-unmanaged slot. Used for token-free composites/elements (no Mapbox peer + no
|
||||
one-shot-define collision risk when isolated).
|
||||
mode 'iframe' → point a lazy iframe at that element's standalone leaf page (its own full SPA
|
||||
with its own peer-engine + token ladder). Used for map elements to sidestep the one-shot
|
||||
customElements.define hazard (each map owns its own document).
|
||||
HOW IT NESTS EACH ELEMENT. ONE way, for every row: inject the bundle's .js once (guarded), then
|
||||
create its REAL tag in an empty Vue-unmanaged slot. There is NO iframe mode. The studio protects
|
||||
the bundles and shows the ELEMENTS — a framed page would be a different deployment method wearing
|
||||
the studio's clothes, and could never demonstrate nesting.
|
||||
|
||||
★ THE IFRAME MODE THAT USED TO LIVE HERE, AND WHY IT IS GONE. Map rows were framed to dodge a
|
||||
one-shot customElements.define collision. MEASURED 2026-09-22: 19 distinct tags across 19
|
||||
bundles, ZERO claimed twice — the collision cannot occur in this tree, and two map elements were
|
||||
proven to coexist in one document. What IS real is the browser's WebGL context budget, so that
|
||||
is what the code now guards (see HEAVY_ELEMENTS): heavy rows are DESTROYED on deactivate
|
||||
instead of being hidden, keeping at most one live map context.
|
||||
|
||||
The tab manifest is fetched at runtime from the built tree's manifest.json (so the studio stays
|
||||
correct as bundles change), grouped by capability via the manifest's baked tier; a compact baked
|
||||
|
|
@ -34,7 +38,7 @@
|
|||
★ SHARED SHELL. The top bar + capability-grouped vertical rail + content frame are the ONE shared
|
||||
beautiful layout, VerticalTabsLayout (library/layouts) — the same shell doxa-maps-spa uses. The
|
||||
studio supplies the grouped rail model + owns the content area (the embed toolbar + the panes
|
||||
with their delicate direct-JS / lazy-iframe mount machinery).
|
||||
with their direct-element mount machinery).
|
||||
|
||||
NO re-import of any nested plugin's components and NO re-registration of a tag another bundle
|
||||
owns — the only tag this bundle defines is <plugin-studio> (see index.js).
|
||||
|
|
@ -43,7 +47,7 @@
|
|||
<!-- The SHELL (top bar + capability-grouped vertical rail + content frame) is the ONE shared
|
||||
beautiful layout, VerticalTabsLayout (library/layouts). The studio supplies the grouped rail
|
||||
model + owns everything in the content area: the embed bar (as #toolbar) and the per-element
|
||||
panes with their delicate direct-JS / lazy-iframe mount machinery (UNCHANGED). -->
|
||||
panes with their direct-element mount + heavy-row teardown machinery. -->
|
||||
<VerticalTabsLayout
|
||||
:title="title"
|
||||
nav-label="Plugin elements · by capability 5 → 1"
|
||||
|
|
@ -109,39 +113,30 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- All panes stay mounted once loaded so switching back is instant + state survives. -->
|
||||
<!-- EVERY pane mounts its plugin's REAL custom element — there is no iframe mode. Each plugin
|
||||
is a compiled Vue custom element, so the studio embeds the element itself rather than a
|
||||
page wrapped around it; that is what makes a row reviewable AND nestable.
|
||||
|
||||
TWO LIFETIMES, and the difference is the WebGL context budget, not cosmetics:
|
||||
light rows (v-show) stay mounted once loaded, so switching back is instant and state
|
||||
survives. This is deliberate — do not "simplify" it to v-if.
|
||||
heavy rows (v-if, row.heavy — the map elements) are DESTROYED on deactivate. Removing
|
||||
the host node runs the element's onBeforeUnmount → map.remove(), which releases the
|
||||
GPU context (measured: canvas count 1→0 and webglcontextlost fired). Without this,
|
||||
every map ever visited would hold a live context until the browser silently dropped
|
||||
the OLDEST one and an old tab went blank with no error. -->
|
||||
<div class="ps-stage">
|
||||
<section
|
||||
v-for="row in flatRows" :key="row.key" class="ps-pane"
|
||||
v-show="row.key === activeKey" :hidden="row.key !== activeKey">
|
||||
|
||||
<template v-if="row.mode === 'element'">
|
||||
<template v-if="!row.heavy || row.key === activeKey">
|
||||
<div class="ps-slot" :ref="el => setSlot(row.key, el)"></div>
|
||||
<div v-if="row.state === 'loading'" class="ps-hint">Loading {{ row.label }}…</div>
|
||||
<div v-else-if="row.state === 'error'" class="ps-hint err">
|
||||
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- iframe panes: a broken leaf page (404 / script error inside the frame) used to show a
|
||||
silent blank frame. @load flips a per-row flag; a short watchdog after the src is set
|
||||
marks the pane errored if it never fired — so ONE bad nested plugin shows an error
|
||||
overlay in ITS pane, never a blank studio. The @error alone is unreliable for cross-
|
||||
document frames, hence the watchdog. -->
|
||||
<iframe
|
||||
class="ps-frame"
|
||||
:src="row.loaded ? row.src : 'about:blank'"
|
||||
:title="row.label"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
@load="onFrameLoad(row)"
|
||||
@error="onFrameError(row)"></iframe>
|
||||
<div v-if="row.state === 'error'" class="ps-hint err">
|
||||
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
|
||||
</div>
|
||||
|
|
@ -186,7 +181,6 @@ const PS_SHADOW_CSS = `
|
|||
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #d1d4cd; border-radius:10px; overflow:hidden; background:#f3f3f1; }
|
||||
.ps-pane { position:absolute; inset:0; }
|
||||
.ps-slot { position:absolute; inset:0; }
|
||||
.ps-frame { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; background:#f3f3f1; }
|
||||
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#6d766e; font-size:13px; padding:20px; }
|
||||
.ps-hint.err { color:#f87171; }
|
||||
.ps-hint small { color:#6d766e; }
|
||||
|
|
@ -206,21 +200,46 @@ const PT = { 'nested-mfe-spa': 0, 'nested-elements-mfe': 1, 'monolithic': 2, 'pa
|
|||
const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
|
||||
const CAP_EMOJI = { 5: '🧩', 4: '🔗', 3: '🗂️', 2: '🎛️', 1: '🔹' }
|
||||
|
||||
// Map elements need the Mapbox peer + do their own one-shot customElements.define, so nest them
|
||||
// as their own SPA in a lazy iframe (their leaf page). Everything else is token-free and safe to
|
||||
// mount directly by its real tag. A profile whose element name matches one of these → iframe.
|
||||
const IFRAME_ELEMENTS = new Set([
|
||||
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
|
||||
// deactivate (see HEAVY_ELEMENTS use in the template + select()). They are NOT iframed: every
|
||||
// element in this tree mounts directly by its real tag, which is the whole point of the studio.
|
||||
//
|
||||
// ★ WHY THERE IS NO LONGER AN IFRAME MODE. Map rows used to render in a lazy iframe to dodge a
|
||||
// one-shot customElements.define collision. That hazard was MEASURED away (2026-09-22): the tree
|
||||
// has 19 distinct tags across 19 bundles and ZERO tags claimed by two bundles, so injecting every
|
||||
// bundle into one document registers 19 distinct tags and collides with nothing. Two map elements
|
||||
// were then proven to coexist in one real document (both defined, both populated, first map
|
||||
// unaffected). The guard was right when written; the naming discipline made it unnecessary.
|
||||
//
|
||||
// ★ WHAT REPLACED IT. The real cost of direct mounting is the browser's WebGL context budget
|
||||
// (commonly ~8-16). Exceeding it prints "Too many active WebGL contexts" and silently kills the
|
||||
// OLDEST context — getContext() never returns null and no event fires on the NEW canvas, so a
|
||||
// previously-visited map would go blank with no error anywhere. Panes are kept alive by v-show,
|
||||
// so 13 directly-mounted maps would hit that ceiling. Hence these rows use v-if and are torn down
|
||||
// on deactivate; everything else keeps v-show so tab switching stays instant and state survives.
|
||||
const HEAVY_ELEMENTS = new Set([
|
||||
'doxa-research-map', 'doxa-simple-map', 'cities-internet-map', 'gospel-resources-map',
|
||||
'my-upg-100-list-map', 'doxa-map', 'doxa-maps-spa'
|
||||
])
|
||||
|
||||
// Load a script exactly once per URL; resolves after the IIFE has executed (its define() ran).
|
||||
// Load a bundle exactly once per URL; resolves after its define() has run.
|
||||
//
|
||||
// ★ A CLASSIC SCRIPT FIRES onload AND THEN FAILS, for a dev-server bundle. Measured 2026-09-22:
|
||||
// pointing a plain <script> at the dev entry (/plugin-designs/<bundle>/index.js, a real ES
|
||||
// module) fires onload — so the old loader resolved "success" — and only THEN throws
|
||||
// "Cannot use import statement outside a module" as an async page error. The tag is never
|
||||
// defined, createElement yields an inert HTMLUnknownElement, and the pane sits empty while its
|
||||
// state reads 'ready'. onload is structurally incapable of seeing this failure, so it is not a
|
||||
// usable success signal on its own. A built tree ships IIFEs, where a classic tag is correct, so
|
||||
// this only ever bites under the dev server — exactly where the studio is developed.
|
||||
// Rule: a rooted /plugin-designs/ entry is a dev ES module; everything else is a built IIFE.
|
||||
const _loaded = {}
|
||||
function loadScriptOnce(url, attrs) {
|
||||
if (_loaded[url]) return _loaded[url]
|
||||
_loaded[url] = 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 _loaded[url]; fail(new Error('failed to load ' + url)) }
|
||||
|
|
@ -229,6 +248,22 @@ function loadScriptOnce(url, attrs) {
|
|||
return _loaded[url]
|
||||
}
|
||||
|
||||
// Wait for a tag to actually register. loadScriptOnce resolving is NOT proof the element exists
|
||||
// (see above), and a module script's define() runs after its import graph settles, so the tag can
|
||||
// appear a tick or two after onload. whenDefined is the browser's own authoritative answer; the
|
||||
// timeout turns a never-registered tag into THIS row's error overlay instead of a silent blank pane.
|
||||
function whenTagDefined(tag, ms) {
|
||||
if (typeof customElements === 'undefined') return Promise.resolve()
|
||||
if (customElements.get(tag)) return 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))
|
||||
])
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PluginStudio',
|
||||
components: { VerticalTabsLayout },
|
||||
|
|
@ -311,34 +346,39 @@ export default defineComponent({
|
|||
methods: {
|
||||
setSlot(key, el) { if (el) this._slots = Object.assign(this._slots || {}, { [key]: el }) },
|
||||
// Switch preview language. languages.js's contract: switching = re-setting profile-config.locale
|
||||
// on the mounted element. Persist + best-effort re-set the active element (direct-JS panes carry a
|
||||
// real element; iframe panes carry their own locale ladder). No router, no dep.
|
||||
// on the mounted element. Persist, then re-set it on the element this pane already holds. Every
|
||||
// pane now carries a real element, so there is ONE path here — no per-mode branch.
|
||||
setLocale(code) {
|
||||
this.locale = code
|
||||
this.localeOpen = false
|
||||
saveLocale(code)
|
||||
try {
|
||||
const row = this.active
|
||||
if (row && row.mode === 'iframe') {
|
||||
// iframe panes carry locale as a URL param; re-point the src so the nested SPA re-mounts in
|
||||
// the new language. Re-arm the load watchdog (via loaded=false → select) so a broken
|
||||
// re-load still shows this pane's error overlay, not a blank frame.
|
||||
row.loaded = false
|
||||
this.select(this.activeKey)
|
||||
} else {
|
||||
const slot = this._slots && this._slots[this.activeKey]
|
||||
const el = slot && slot.firstElementChild
|
||||
if (el && el.setAttribute && row) {
|
||||
const pc = { profile: row.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
}
|
||||
const slot = this._slots && this._slots[this.activeKey]
|
||||
const el = slot && slot.firstElementChild
|
||||
if (el && el.setAttribute && row) {
|
||||
const pc = { profile: row.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
}
|
||||
} catch { /* non-fatal: the switcher still persists + updates the UI */ }
|
||||
},
|
||||
resolve(ref) {
|
||||
const r = String(ref).replace(/^\.\//, '')
|
||||
if (this.base) return this.base + r
|
||||
const raw = String(ref)
|
||||
// AN ALREADY-ABSOLUTE REF IS RETURNED UNTOUCHED — prefixing it produced a double slash and
|
||||
// 404'd EVERY tab. Measured 2026-09-22 after tree-base was introduced for the dev server:
|
||||
// 404 /__plugin-gallery//plugin-designs/big-picture/index.js
|
||||
// ^^ base + an already-rooted path
|
||||
// and the studio showed "Could not load <every plugin>" on all 29 tabs. The built tree hands
|
||||
// RELATIVE refs (bundle/profile/index.html), so concatenation was always safe there and the
|
||||
// assumption went unnoticed; the dev manifest hands ROOTED ones (/plugin-designs/..., /__leaf/...).
|
||||
// A URL that already starts at the root needs no base by definition, so this is the correct
|
||||
// rule for both trees rather than a dev-only special case.
|
||||
if (/^(https?:)?\/\//.test(raw) || raw.startsWith('/')) return raw
|
||||
const r = raw.replace(/^\.\//, '')
|
||||
// Join through exactly one slash: `base` is normalised to a trailing "/" by base(), but a
|
||||
// caller-supplied tree-base may not be, and that is precisely how the 404 above was born.
|
||||
if (this.base) return this.base.replace(/\/+$/, '') + '/' + r
|
||||
return '../' + r // sibling bundle folders are one level up from this bundle's folder
|
||||
},
|
||||
// Build the studio rows from the built tree's manifest.json. Each profile becomes one row,
|
||||
|
|
@ -348,7 +388,9 @@ export default defineComponent({
|
|||
const useModel = (m) => { this.rows = this.rowsFromManifest(m); this.route() }
|
||||
const baked = (typeof window !== 'undefined' && window.__psBaked) || null
|
||||
if (typeof fetch === 'function' && typeof location !== 'undefined' && location.protocol !== 'file:') {
|
||||
const url = this.base ? this.base + 'manifest.json' : '../manifest.json'
|
||||
// Route through resolve() so the single-slash join + absolute-ref rule apply here too;
|
||||
// building this URL independently is how the two paths drifted apart in the first place.
|
||||
const url = this.base ? this.resolve('manifest.json') : '../manifest.json'
|
||||
fetch(url, { cache: 'no-cache' })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(m => useModel(m || baked || { bundles: [] }))
|
||||
|
|
@ -365,7 +407,9 @@ export default defineComponent({
|
|||
for (const p of (b.profiles || [])) {
|
||||
const tag = p.element || (b.elements && b.elements[0]) || b.bundle
|
||||
const cap = capFor(p.type || 'single')
|
||||
const iframe = IFRAME_ELEMENTS.has(tag) || IFRAME_ELEMENTS.has(b.bundle)
|
||||
// A heavy row owns a live WebGL context, so it is torn down on deactivate rather than
|
||||
// hidden. Keyed by tag OR bundle so a bundle whose profiles differ still resolves.
|
||||
const heavy = HEAVY_ELEMENTS.has(tag) || HEAVY_ELEMENTS.has(b.bundle)
|
||||
out.push({
|
||||
key: b.bundle + '/' + p.profile,
|
||||
bundle: b.bundle,
|
||||
|
|
@ -374,12 +418,11 @@ export default defineComponent({
|
|||
cap,
|
||||
emoji: CAP_EMOJI[cap] || '🔹',
|
||||
description: p.description || '',
|
||||
mode: iframe ? 'iframe' : 'element',
|
||||
heavy,
|
||||
bundleJs: b.scriptUrl || (b.bundle + '/' + b.bundle + '.js'),
|
||||
leaf: p.iframeUrl || (b.bundle + '/' + p.profile + '/index.html'),
|
||||
embed: (p.embed && p.embed.script) || '',
|
||||
// runtime nesting state
|
||||
loaded: false, mounted: false, state: 'idle', error: '', src: ''
|
||||
mounted: false, state: 'idle', error: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -392,57 +435,64 @@ export default defineComponent({
|
|||
if (h && this.rows.some(r => r.key === h)) this.select(h)
|
||||
},
|
||||
select(key) {
|
||||
const prevKey = this.activeKey
|
||||
this.activeKey = key
|
||||
this.embedOpen = false
|
||||
// Release the outgoing HEAVY pane first, so at most one live map exists at a time. Its v-if
|
||||
// subtree is about to be torn down by the re-render anyway; resetting the row's state here is
|
||||
// what makes it re-mount correctly next time it is selected (a stale mounted:true would leave
|
||||
// an empty slot forever, because mountElement is skipped for an already-mounted row).
|
||||
if (prevKey && prevKey !== key) {
|
||||
const prev = this.rows.find(r => r.key === prevKey)
|
||||
if (prev && prev.heavy && prev.mounted) this.releaseRow(prev)
|
||||
}
|
||||
const row = this.rows.find(r => r.key === key)
|
||||
if (!row) return
|
||||
if (row.mode === 'iframe') {
|
||||
if (!row.loaded) {
|
||||
row.src = this.withToken(this.resolve(row.leaf))
|
||||
row.loaded = true
|
||||
row.state = 'loading'
|
||||
// Watchdog: if no load event arrives in 20s, show this pane's error overlay rather than a
|
||||
// silent blank frame. onFrameLoad clears it (and un-errors a late load).
|
||||
if (row._frameTimer) clearTimeout(row._frameTimer)
|
||||
row._frameTimer = setTimeout(() => {
|
||||
if (row.state !== 'ready') { row.state = 'error'; row.error = 'nested plugin did not load in time' }
|
||||
}, 20000)
|
||||
}
|
||||
} else if (!row.mounted) {
|
||||
this.mountElement(row)
|
||||
}
|
||||
// A heavy row's slot only exists after the v-if subtree renders, so wait a tick before
|
||||
// mounting into it — on the first activation the ref is null at this point.
|
||||
if (!row.mounted) this.$nextTick(() => { if (this.activeKey === row.key) this.mountElement(row) })
|
||||
},
|
||||
withToken(url) {
|
||||
const tokenUrl = (this.tokenUrl || this.cfg.tokenUrl || '').trim()
|
||||
const tk = (this.tk || this.cfg.tk || '').trim()
|
||||
const q = []
|
||||
if (tk) q.push('tk=' + encodeURIComponent(tk))
|
||||
else if (tokenUrl) q.push('tokenUrl=' + encodeURIComponent(tokenUrl))
|
||||
// Push the studio's current preview language down to the nested SPA as a URL param — the
|
||||
// leaf pages read `?locale=` and inject it into their element's profile-config.locale. The
|
||||
// studio is the "simulate DX-life" surface, so it always carries a concrete locale.
|
||||
if (this.locale) q.push('locale=' + encodeURIComponent(this.locale))
|
||||
if (!q.length) return url
|
||||
return url + (url.indexOf('?') === -1 ? '?' : '&') + q.join('&')
|
||||
// Destroy a pane's nested element. Removing the host node runs the element's disconnected
|
||||
// lifecycle (onBeforeUnmount → map.remove()), which is what actually frees the GPU context —
|
||||
// verified by canvas count 1→0 and a webglcontextlost event, not merely by the node vanishing.
|
||||
releaseRow(row) {
|
||||
try {
|
||||
const slot = this._slots && this._slots[row.key]
|
||||
if (slot) slot.innerHTML = ''
|
||||
} catch { /* the row is reset either way; a failed cleanup must not wedge navigation */ }
|
||||
if (this._slots) delete this._slots[row.key]
|
||||
row.mounted = false
|
||||
row.state = 'idle'
|
||||
row.error = ''
|
||||
},
|
||||
mountElement(row) {
|
||||
row.mounted = true
|
||||
row.state = 'loading'
|
||||
const jsUrl = this.resolve(row.bundleJs)
|
||||
loadScriptOnce(jsUrl).then(() => {
|
||||
// Gate the mount on the TAG being registered, not on the script's onload — onload fires even
|
||||
// when the bundle then fails to execute (see loadScriptOnce), which would hand us an inert
|
||||
// HTMLUnknownElement and an empty pane reporting 'ready'.
|
||||
loadScriptOnce(jsUrl).then(() => whenTagDefined(row.tag)).then(() => {
|
||||
// Everything below is per-nested-plugin machinery; a throw here (bad tag name, a nested
|
||||
// element that blows up in its connectedCallback, a serialise failure) must NOT crash the
|
||||
// studio — it lands in THIS row's error pane. Hence the try/catch around the DOM mount, on
|
||||
// top of the .catch() that already handles a failed SCRIPT load.
|
||||
try {
|
||||
// A heavy row may have been deactivated (and released) while its bundle loaded. Mounting
|
||||
// into a torn-down pane would leak exactly the context this design exists to bound, so
|
||||
// drop the mount and let the next select() redo it cleanly.
|
||||
if (row.heavy && this.activeKey !== row.key) { row.mounted = false; row.state = 'idle'; return }
|
||||
const slot = this._slots && this._slots[row.key]
|
||||
if (!slot) { row.state = 'error'; row.error = 'no slot'; return }
|
||||
slot.innerHTML = ''
|
||||
const el = document.createElement(row.tag)
|
||||
// Include the current preview locale so a pane mounted before any language switch already
|
||||
// renders in the studio's active language (ProfileLoader reads profile-config.locale).
|
||||
// tk/tokenUrl ride along here now that map rows mount directly — this is the token ladder
|
||||
// the removed iframe src used to carry as a query param.
|
||||
const pc = { profile: row.label, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale: this.locale }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
|
||||
slot.appendChild(el)
|
||||
|
|
@ -456,23 +506,6 @@ export default defineComponent({
|
|||
row.error = (e && e.message) || String(e)
|
||||
})
|
||||
},
|
||||
// iframe panes: a leaf that 404s or errors inside its own document would otherwise show a
|
||||
// silent blank frame. onload fires for BOTH a good load and an error document, so we can't tell
|
||||
// them apart cross-origin — instead we arm a watchdog when the src is set (in select) and treat
|
||||
// a load that arrives as success (clear the watchdog + any error). onFrameError catches the rare
|
||||
// same-origin error event. Either way ONE bad nested plugin is contained to its own pane.
|
||||
onFrameLoad(row) {
|
||||
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
|
||||
// A load event means the frame document resolved; leave any deeper in-frame failure to the
|
||||
// leaf's own error UI. Only clear a watchdog-set error if the frame genuinely loaded late.
|
||||
if (row.state === 'error' && row.error === 'nested plugin did not load in time') { row.state = 'ready'; row.error = '' }
|
||||
else if (row.state !== 'error') row.state = 'ready'
|
||||
},
|
||||
onFrameError(row) {
|
||||
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
|
||||
row.state = 'error'
|
||||
row.error = 'the nested plugin failed to load'
|
||||
},
|
||||
copyText(text, btn) {
|
||||
const ok = () => { const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t }, 1200) }
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, () => {})
|
||||
|
|
@ -516,7 +549,6 @@ export default defineComponent({
|
|||
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #d1d4cd; border-radius: 10px; overflow: hidden; background: #f3f3f1; }
|
||||
.ps-pane { position: absolute; inset: 0; }
|
||||
.ps-slot { position: absolute; inset: 0; }
|
||||
.ps-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #f3f3f1; }
|
||||
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #6d766e; font-size: 13px; padding: 20px; }
|
||||
.ps-hint.err { color: #f87171; }
|
||||
.ps-hint small { color: #6d766e; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue