From 42416095727c89cab40d59d5ce9409f19946c6ef Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 05:26:13 +0000 Subject: [PATCH] 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 # 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=__ (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. --- internal/generate-tree.mjs | 8 ++- .../plugin-studio/plugin-studio.vue | 51 ++++++++++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 73a44169c..24127a4a8 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -925,7 +925,10 @@ function mapPageHtml(opts) { ? `\n ` : '' // 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 // instance whose config says provider:'maplibre' gets the MapLibre peer (token- // free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts @@ -1099,6 +1102,9 @@ ${apiScript} var slot = document.getElementById('slot'); var status = document.getElementById('status'); 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: // the ladder below is skipped entirely and the element mounts immediately. // (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.) diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue index 85b0ee19f..6258f9459 100644 --- a/plugin-designs/plugin-studio/plugin-studio.vue +++ b/plugin-designs/plugin-studio/plugin-studio.vue @@ -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 { publishMapToken, clearMapToken } from '@map/composables/useSharedMapToken.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, // never sent anywhere but Mapbox (the token) or the URL the reader typed (the service). @@ -762,11 +763,20 @@ export default defineComponent({ .catch(() => {}) } 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 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. 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 const W = window.__doxaStudioWidgets 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, // and a reload goes back to that tab. // - // 1. the URL hash — an explicit, shareable request always wins over a remembered one - // 2. localStorage — what you were last looking at, so a reload is not a fresh start - // 3. the first row — never leave the pane empty + // 1. ?tab= — the view address (useViewAddress.js): a reload and a SENT link open it + // 2. the URL hash — the old shareable form (#bundle/profile); rewritten to ?tab= on first paint + // 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 // 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 // is the very state this replaces. 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(/^#\/?/, '') - 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() - if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered); return } - if (this.rows.length) this.select(this.rows[0].key) + if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered, first); return } + if (this.rows.length) this.select(this.rows[0].key, first) + }, + // ★ A ROW'S TAB ID. A row key is "/", and the address takes plain ids only (no "/"), so the tab + // WRITTEN is "__": 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 // otherwise fight over a single key and each reload would yank the other's tab. @@ -1034,7 +1069,7 @@ export default defineComponent({ writeLastTab(key) { 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 // a SYNTHETIC head (see compositionTrees) for a composite that cannot list itself as one of its own // tabs (plugin-studio nesting 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 // one stray select() would poison the next reload with a key that fails route()'s check. 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 // 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) })