Plugin Studio: the open tab is in the address, so a reload keeps it and a sent link opens it

Plugin Studio read its tab from #<bundle/profile> or from localStorage, and
never wrote it, so a reload worked for one reader but a sent link did not. It
now writes the view address through useViewAddress: ?tab=<bundle>__<profile>
(a row key's "/" is not a plain id; the double underscore keeps a sent link
meaning one row even if another bundle later adopts the profile's name). A
reader's tab click is a push, the first paint a replace, and Back/Forward
re-open the named tab without writing. route() reads ?tab= first (or a bare
profile name while it names exactly one row), then the old #bundle/profile
(rewritten to ?tab= on first paint, its hash dropped), then the remembered
tab, then the first row. The listener is removed before the hot-update return.

generate-tree bakes urlOwner 'app' into our own leaf pages only (never into an
embed snippet), and a framed leaf drops it at runtime, so a page embedded in a
partner's iframe reads its address but never adds entries to the partner's
Back button.
This commit is contained in:
Doxa Maps 2026-10-03 05:26:13 +00:00
parent 30dc9a1457
commit 4241609572
2 changed files with 51 additions and 8 deletions

View file

@ -925,7 +925,10 @@ function mapPageHtml(opts) {
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>` ? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
: '' : ''
// Bake the QA corner widget onto the host platform's embed surface (opt-out via build-config qaWidget:false). // Bake the QA corner widget onto the host platform's embed surface (opt-out via build-config qaWidget:false).
const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config // urlOwner 'app' (library/composables/useViewAddress.js): this generated page is OURS, so its shell may write the
// view address (?tab=…) for a reload and a sent link. Only the page's own config gets it, never an embed snippet
// (embedCfg), so a partner's page is never written to; and a FRAMED page drops it at runtime (below).
const pageConfig = { ...(DEST_CONFIG.qaWidget ? { ...config, qa: true } : config), urlOwner: 'app' }
// PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or // PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or
// instance whose config says provider:'maplibre' gets the MapLibre peer (token- // instance whose config says provider:'maplibre' gets the MapLibre peer (token-
// free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts // free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts
@ -1099,6 +1102,9 @@ ${apiScript}
var slot = document.getElementById('slot'); var slot = document.getElementById('slot');
var status = document.getElementById('status'); var status = document.getElementById('status');
var BASE_CONFIG = ${JSON.stringify(pageConfig)}; var BASE_CONFIG = ${JSON.stringify(pageConfig)};
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
// entries to the partner page's own Back button (the joint session history).
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token: // MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately. // the ladder below is skipped entirely and the element mounts immediately.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.) // (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)

View file

@ -225,6 +225,7 @@ import StudioTree from './src/StudioTree.vue'
import { LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocale } from '@map/layouts/app-wrapper-material/languages.js' import { LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocale } from '@map/layouts/app-wrapper-material/languages.js'
import { publishMapToken, clearMapToken } from '@map/composables/useSharedMapToken.js' import { publishMapToken, clearMapToken } from '@map/composables/useSharedMapToken.js'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js' import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf, commitUrl } from '@map/composables/useViewAddress.js'
// The reader's own map token / token-service URL. localStorage only: never written into the build, // The reader's own map token / token-service URL. localStorage only: never written into the build,
// never sent anywhere but Mapbox (the token) or the URL the reader typed (the service). // never sent anywhere but Mapbox (the token) or the URL the reader typed (the service).
@ -762,11 +763,20 @@ export default defineComponent({
.catch(() => {}) .catch(() => {})
} }
this.loadManifest() this.loadManifest()
// Back / Forward re-open the tab the address names; they never write (useViewAddress.js says why).
this._offAddress = onAddressChange(({ tab }) => {
const key = this.keyOfTab(tab)
if (key) this.select(key, { fromHistory: true })
else if (this.rows.length) this.select(this.rows[0].key, { fromHistory: true })
})
}, },
// The corner-widget hosts live on window.__doxaStudioWidgets so a HOT UPDATE remounts Studio onto // The corner-widget hosts live on window.__doxaStudioWidgets so a HOT UPDATE remounts Studio onto
// the same hosts and a live widget (Rudder) keeps its state. A real unmount must release them, or // the same hosts and a live widget (Rudder) keeps its state. A real unmount must release them, or
// the hosts and every widget element in them stay pinned on the page after Studio is gone. // the hosts and every widget element in them stay pinned on the page after Studio is gone.
beforeUnmount() { beforeUnmount() {
// FIRST, before the hot-update return below: a remounted Studio adds its own listener, and a kept one would
// re-apply every Back twice.
if (this._offAddress) { this._offAddress(); this._offAddress = null }
if (import.meta.hot) return if (import.meta.hot) return
const W = window.__doxaStudioWidgets const W = window.__doxaStudioWidgets
if (W && W.hosts) for (const h of Object.values(W.hosts)) { try { h.remove() } catch (_) {} } if (W && W.hosts) for (const h of Object.values(W.hosts)) { try { h.remove() } catch (_) {} }
@ -1003,20 +1013,45 @@ export default defineComponent({
// list" instead of a tab. It opens the first tab, remembers in localStorage the tab you were on, // list" instead of a tab. It opens the first tab, remembers in localStorage the tab you were on,
// and a reload goes back to that tab. // and a reload goes back to that tab.
// //
// 1. the URL hash — an explicit, shareable request always wins over a remembered one // 1. ?tab= — the view address (useViewAddress.js): a reload and a SENT link open it
// 2. localStorage — what you were last looking at, so a reload is not a fresh start // 2. the URL hash — the old shareable form (#bundle/profile); rewritten to ?tab= on first paint
// 3. the first row — never leave the pane empty // 3. localStorage — what you were last looking at, so a reload is not a fresh start
// 4. the first row — never leave the pane empty
//
// The first select() is a REPLACE (first paint); a reader's click is a PUSH (one Back step), written only
// where this page owns its address (urlOwner 'app', baked into our own leaf page; a partner's page and a
// framed leaf only read).
// //
// Called once after the manifest lands, so `rows` is populated and every candidate can be // Called once after the manifest lands, so `rows` is populated and every candidate can be
// checked against it. A remembered key whose bundle was renamed or removed simply fails that // checked against it. A remembered key whose bundle was renamed or removed simply fails that
// check and falls through to the first row — a stale key must never render an empty pane, which // check and falls through to the first row — a stale key must never render an empty pane, which
// is the very state this replaces. // is the very state this replaces.
route() { route() {
const first = { firstPaint: true }
const asked = this.keyOfTab(readViewKeys(typeof location !== 'undefined' ? location.search : '').tab)
if (asked) { this.select(asked, first); return }
const h = (typeof location !== 'undefined' && location.hash || '').replace(/^#\/?/, '') const h = (typeof location !== 'undefined' && location.hash || '').replace(/^#\/?/, '')
if (h && this.rows.some(r => r.key === h)) { this.select(h); return } if (h && this.rows.some(r => r.key === h)) {
this.select(h, first)
// The old #bundle/profile link now reads as ?tab=; its hash goes, so the address has ONE form.
if (urlOwnerOf(this.cfg) === 'app') commitUrl({ hash: '' })
return
}
const remembered = this.readLastTab() const remembered = this.readLastTab()
if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered); return } if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered, first); return }
if (this.rows.length) this.select(this.rows[0].key) if (this.rows.length) this.select(this.rows[0].key, first)
},
// ★ A ROW'S TAB ID. A row key is "<bundle>/<profile>", and the address takes plain ids only (no "/"), so the tab
// WRITTEN is "<bundle>__<profile>": stable, since a profile name another bundle adopts later cannot make a sent
// link mean a different row. READ: that form, or a bare profile name while it names exactly one row (a typed
// ?tab=poster-styles), else '' (the default tab opens).
tabIdOf(key) { return String(key || '').replace('/', '__') },
keyOfTab(id) {
if (!id) return ''
const full = id.includes('__') ? id.replace('__', '/') : ''
if (full && this.rows.some(r => r.key === full)) return full
const byProfile = this.rows.filter(r => r.key.split('/')[1] === id)
return byProfile.length === 1 ? byProfile[0].key : ''
}, },
// Storage is keyed per HOST (pathname), because two cap-5 shells served from one origin would // Storage is keyed per HOST (pathname), because two cap-5 shells served from one origin would
// otherwise fight over a single key and each reload would yank the other's tab. // otherwise fight over a single key and each reload would yank the other's tab.
@ -1034,7 +1069,7 @@ export default defineComponent({
writeLastTab(key) { writeLastTab(key) {
try { window.localStorage.setItem(this.storageKey(), key) } catch (_) { /* storage unavailable */ } try { window.localStorage.setItem(this.storageKey(), key) } catch (_) { /* storage unavailable */ }
}, },
select(key) { select(key, { fromHistory = false, firstPaint = false } = {}) {
// ★ BAIL BEFORE TOUCHING STATE if `key` names no real row. A composition-folder's TOP row can be // ★ BAIL BEFORE TOUCHING STATE if `key` names no real row. A composition-folder's TOP row can be
// a SYNTHETIC head (see compositionTrees) for a composite that cannot list itself as one of its own // a SYNTHETIC head (see compositionTrees) for a composite that cannot list itself as one of its own
// tabs (plugin-studio nesting <plugin-studio> would be infinite regress — same reasoning // tabs (plugin-studio nesting <plugin-studio> would be infinite regress — same reasoning
@ -1062,6 +1097,8 @@ export default defineComponent({
// Remember it AFTER confirming the row exists, so a bad key is never written back — otherwise // Remember it AFTER confirming the row exists, so a bad key is never written back — otherwise
// one stray select() would poison the next reload with a key that fails route()'s check. // one stray select() would poison the next reload with a key that fails route()'s check.
this.writeLastTab(key) this.writeLastTab(key)
// The view address: a reader's choice is a push, the first paint a replace, Back/Forward never writes.
if (!fromHistory) writeViewKeys({ tab: this.tabIdOf(key) }, { owner: urlOwnerOf(this.cfg), push: !firstPaint, el: this.$el })
// A heavy row's slot only exists after the v-if subtree renders, so wait a tick before // 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. // 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) }) if (!row.mounted) this.$nextTick(() => { if (this.activeKey === row.key) this.mountElement(row) })