From 1dd4e7c54fd90b8728defd7a6ae9c6bca9df8064 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 00:44:15 +0000 Subject: [PATCH] wip-uprops --- internal/check-map-props-unit.mjs | 82 +++++++++++++++++++ library/ProfileLoader.vue | 7 +- library/composables/useMapInstance.js | 19 ++++- library/composables/useMapProvider.js | 13 ++- library/composables/useSharedMapToken.js | 47 ++++++++++- .../layouts/app-wrapper-material/languages.js | 7 +- .../plugin-studio/plugin-studio.vue | 42 +++++++--- 7 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 internal/check-map-props-unit.mjs diff --git a/internal/check-map-props-unit.mjs b/internal/check-map-props-unit.mjs new file mode 100644 index 000000000..371bc73eb --- /dev/null +++ b/internal/check-map-props-unit.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * check-map-props-unit — the engine and token props, decided in the library, measured WITHOUT a browser. + * + * resolveMapProvider ('auto': Mapbox when a token is available, MapLibre otherwise; an explicit provider + * wins), and the page-wide token (useSharedMapToken): publish, CLEAR, and a token-service URL. Runs the + * real library modules in Node against a stand-in window/location, so it is fast and needs no browser. + * + * Each case names the defect it guards (U3 = Clear left the old token live, U1 = tokenUrl read by nothing, + * U7 = mixed-case provider dropped). + * + * USAGE node internal/check-map-props-unit.mjs EXIT 0 pass · 1 fail + */ +const listeners = {} +globalThis.window = globalThis +globalThis.CustomEvent = class { constructor(type, o) { this.type = type; this.detail = o && o.detail } } +window.addEventListener = (t, f) => { (listeners[t] = listeners[t] || []).push(f) } +window.removeEventListener = (t, f) => { listeners[t] = (listeners[t] || []).filter((x) => x !== f) } +window.dispatchEvent = (e) => { for (const f of listeners[e.type] || []) f(e); return true } +let search = '' +Object.defineProperty(globalThis, 'location', { configurable: true, get: () => ({ search }) }) + +const { resolveMapProvider, hasMapToken, explicitMapProvider, DEFAULT_PROVIDER } = await import('../library/composables/useMapProvider.js') +const shared = await import('../library/composables/useSharedMapToken.js') + +let bad = 0 +const eq = (name, got, want) => { const ok = got === want; if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name.padEnd(64)} ${JSON.stringify(got)}${ok ? '' : ` (want ${JSON.stringify(want)})`}`) } +const reset = () => { search = ''; delete window.MAP_PROVIDER; shared.clearMapToken(); window.__doxaMapTokenPending = null } +const TK = ['pk', 'eyJ1IjoidGVzdCJ9', 'c2ln'].join('.') + +console.log('MAP-PROPS: the library, in Node (no browser)') +eq('default is auto', DEFAULT_PROVIDER, 'auto') +reset(); eq('auto, no token anywhere → maplibre', resolveMapProvider({}), 'maplibre') +reset(); eq('auto, tk in profile-config → mapbox', resolveMapProvider({ tk: TK }), 'mapbox') +reset(); eq('auto, placeholder tk YOUR_MAPBOX_TOKEN → maplibre', resolveMapProvider({ tk: 'YOUR_MAPBOX_TOKEN' }), 'maplibre') +reset(); window.MAP_TOKEN = TK; eq('auto, host window.MAP_TOKEN → mapbox', resolveMapProvider({}), 'mapbox') +reset(); eq('auto, a token the caller holds (useMapInstance) → mapbox', resolveMapProvider({}, { token: TK }), 'mapbox') +reset(); eq('auto, tokenUrl in profile-config → mapbox (U1)', resolveMapProvider({ tokenUrl: 'https://example.org/token' }), 'mapbox') +reset(); eq('explicit maplibre with a token → maplibre', resolveMapProvider({ provider: 'maplibre', tk: TK }), 'maplibre') +reset(); eq('explicit mapbox without a token → mapbox', resolveMapProvider({ provider: 'mapbox' }), 'mapbox') +reset(); eq('mixed case "MapLibre" is honoured (U7)', resolveMapProvider({ provider: 'MapLibre', tk: TK }), 'maplibre') +reset(); search = '?provider=maplibre'; window.MAP_TOKEN = TK; eq('?provider=maplibre wins over a host token', resolveMapProvider({}), 'maplibre') +reset(); window.MAP_PROVIDER = 'mapbox'; eq('window.MAP_PROVIDER is explicit', explicitMapProvider({}), 'mapbox') +reset(); eq('"auto" is not explicit', explicitMapProvider({ provider: 'auto' }), '') + +// ── the page-wide token ── +reset() +const tok = shared.useSharedMapToken() +shared.publishMapToken(TK) +eq('publish reaches window.MAP_TOKEN', window.MAP_TOKEN, TK) +eq('publish reaches a live element (ref)', tok.value, TK) +shared.clearMapToken() +eq('CLEAR empties window.MAP_TOKEN (U3)', window.MAP_TOKEN || '', '') +eq('CLEAR reaches a live element (U3)', tok.value, '') +eq('after CLEAR, auto → maplibre (U3)', resolveMapProvider({}), 'maplibre') +window.MAP_TOKEN = TK +eq('after CLEAR, a host assignment is adopted again (bridge intact, U3)', tok.value, TK) +eq('hasMapToken sees it', hasMapToken({}), true) + +// The OLD Clear path: `delete window.MAP_TOKEN` + publishMapToken('') leaves the old token live (publishing +// '' is a no-op by design, which is why Clear needs clearMapToken). It also used to remove the bridge for +// good; the bridge now re-installs itself, so a later host token is adopted again. +reset(); shared.publishMapToken(TK) +delete window.MAP_TOKEN; shared.publishMapToken('') +eq('reproduction: the old Clear left the OLD token live on elements', tok.value, TK) +window.MAP_TOKEN = 'pk.other' +eq('self-heal: after that delete, a later host token reaches elements again', tok.value, 'pk.other') +window.__doxaMapToken = ''; window.MAP_TOKEN = '' + +// ── token-service URL (U1) ── +reset() +globalThis.fetch = async (url) => ({ ok: true, headers: { get: () => 'application/json' }, json: async () => ({ token: url.includes('good') ? TK : 'sk.secret' }) }) +const got = await shared.fetchMapToken('https://example.org/good') +eq('tokenUrl answer is published page-wide (U1)', window.MAP_TOKEN, TK) +eq('fetchMapToken resolves with it', got, TK) +reset() +const secret = await shared.fetchMapToken('https://example.org/bad') +eq('a non-public (sk.) answer is refused', secret, '') +eq('…and nothing is published', window.MAP_TOKEN || '', '') + +if (bad) { console.log(`MAP-PROPS: FAIL — ${bad} case(s).`); process.exit(1) } +console.log('MAP-PROPS: PASS — auto picks the engine from the token ladder, explicit wins, Clear clears, tokenUrl is honoured.') diff --git a/library/ProfileLoader.vue b/library/ProfileLoader.vue index 83ea6d63c..0e64263e8 100644 --- a/library/ProfileLoader.vue +++ b/library/ProfileLoader.vue @@ -15,7 +15,7 @@ */ import { computed, provide, inject, watchEffect, watch } from 'vue' -import { useSharedMapToken, publishMapToken } from './composables/useSharedMapToken.js' +import { useSharedMapToken, publishMapToken, fetchMapToken } from './composables/useSharedMapToken.js' import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js' import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale } from './i18n/index.js' // QA corner: COMPILE-TIME gated (__DOXA_QA__, vite define). In public builds @@ -127,6 +127,11 @@ const mapboxToken = computed(() => { // that a token belongs to the page, not to whichever component happened to receive it. Publishing // is idempotent and ignores empties, so this cannot clobber a good token with a blank one. watch(() => config.value?.tk, (tk) => { if (typeof tk === 'string' && tk) publishMapToken(tk) }, { immediate: true }) +// A token-SERVICE URL in the profile-config (tokenUrl) is asked only when no token is at hand; its answer +// is published page-wide, so mapboxToken above (and every sibling) picks it up. +watch(() => config.value?.tokenUrl, (url) => { + if (url && !config.value?.tk && !sharedToken.value) fetchMapToken(url) +}, { immediate: true }) // Default to the CONFIGURED active source from sources.json (e.g. 'pray-tools', the live API) // — NOT a hardcoded 'doxa-csv'. A host/gem-frame that embeds the bundle without an explicit // profile-config.dataSource (the platform case) was silently falling back to 'doxa-csv', whose diff --git a/library/composables/useMapInstance.js b/library/composables/useMapInstance.js index 7395b810e..cf040bafb 100644 --- a/library/composables/useMapInstance.js +++ b/library/composables/useMapInstance.js @@ -75,7 +75,7 @@ const OPENFREEMAP_DARK_STYLE = 'https://tiles.openfreemap.org/styles/fiord' // Named styles: the 12-style pack. A profile-config style of 'ink' / // 'midnight-glow' / … resolves per engine here; raw URLs pass through. import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js' -import { resolveMapProvider } from './useMapProvider.js' +import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js' import { installRtlTextPlugin } from '../maps/rtl-text.js' import { localizeBasemapLabels } from '../maps/localize-labels.js' @@ -364,8 +364,11 @@ export function useMapInstance({ // degrades to the page switch instead of throwing. let _pcForProvider = null try { _pcForProvider = inject('profileConfig', null) } catch { /* called outside setup */ } + // 'auto' decided it (nobody asked for an engine): it may still change once, below, if the token it + // counted on was a token SERVICE that answers with nothing. + const autoProvider = !provider && !explicitMapProvider(_pcForProvider) provider = provider || resolveMapProvider(_pcForProvider, { token: accessToken }) - const isMaplibre = provider === 'maplibre' + let isMaplibre = provider === 'maplibre' // THE READER'S LANGUAGE, for the basemap's place names (maps/localize-labels.js). ProfileLoader // provides it as a ref ('lang'); an element without ProfileLoader falls back to its profile-config @@ -386,6 +389,13 @@ export function useMapInstance({ async function initializeMap() { // Resolve a NAMED style ('ink', 'satellite', …) for this engine; raw URLs // and undefined keep their existing behavior (see library/styles/registry.js). + // 'auto' picked Mapbox on a token service still answering: wait for it. No token after all → MapLibre, + // keyless, which is what 'auto' means without a token (a map that cannot draw is not a fallback). + if (autoProvider && !isMaplibre && !accessToken && typeof window !== 'undefined' && window.__doxaMapTokenPending) { + await window.__doxaMapTokenPending + if (!containerRef.value) return + if (!(typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN)) { provider = 'maplibre'; isMaplibre = true } + } const _named = resolveNamedStyle(style, provider) const effectiveStyle = _named.style || style if (!containerRef.value) { @@ -422,6 +432,11 @@ export function useMapInstance({ // mounts a map component directly, with no ProfileLoader / profile-config wrapper) may expose the // token once as `window.MAP_TOKEN`. Any map on the page then finds it. Per-instance accessToken // still wins; this only fills the gap when none was passed. + // A token service still answering (profile-config.tokenUrl, see fetchMapToken): wait for it. + if (!accessToken && typeof window !== 'undefined' && window.__doxaMapTokenPending) { + await window.__doxaMapTokenPending + if (gone()) return + } if (!accessToken && typeof window !== 'undefined' && typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) { accessToken = window.MAP_TOKEN } diff --git a/library/composables/useMapProvider.js b/library/composables/useMapProvider.js index 8f1cf07b9..cfd99d974 100644 --- a/library/composables/useMapProvider.js +++ b/library/composables/useMapProvider.js @@ -47,8 +47,10 @@ export function hasMapToken(cfg) { // The embed snippets ship tk:"YOUR_MAPBOX_TOKEN" as a placeholder; an unfilled one is not a token. const real = (v) => typeof v === 'string' && !!v.trim() && !/^YOUR_/.test(v.trim()) if (real(cfg?.tk)) return true + // A token service to ask (profile-config.tokenUrl) counts: the element fetches it before the map starts. + if (/^https?:\/\//i.test(String(cfg?.tokenUrl || ''))) return true if (typeof window === 'undefined') return false - return real(window.MAP_TOKEN) || real(window.__doxaMapToken) + return real(window.MAP_TOKEN) || real(window.__doxaMapToken) || !!window.__doxaMapTokenPending } /** @@ -58,14 +60,19 @@ export function hasMapToken(cfg) { * Pure — safe to call outside a Vue setup(). */ export function resolveMapProvider(profileConfig, { token } = {}) { + const cfg = unwrapConfig(profileConfig) + return explicitMapProvider(cfg) || (hasMapToken(cfg) || hasMapToken({ tk: token }) ? 'mapbox' : 'maplibre') +} + +/** The engine someone ASKED for (rungs 1-3), or '' when the default ('auto') decides. */ +export function explicitMapProvider(profileConfig) { const cfg = unwrapConfig(profileConfig) const fromConfig = cfg?.provider const fromUrl = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('provider') : undefined const fromWindow = typeof window !== 'undefined' ? window.MAP_PROVIDER : undefined const raw = String(fromConfig || fromUrl || fromWindow || DEFAULT_PROVIDER).toLowerCase().trim() - if (MAP_PROVIDERS.includes(raw)) return raw - return hasMapToken(cfg) || hasMapToken({ tk: token }) ? 'mapbox' : 'maplibre' + return MAP_PROVIDERS.includes(raw) ? raw : '' } /** Human-readable engine label for a badge / UI. */ diff --git a/library/composables/useSharedMapToken.js b/library/composables/useSharedMapToken.js index 973efc86c..5d3fd97bf 100644 --- a/library/composables/useSharedMapToken.js +++ b/library/composables/useSharedMapToken.js @@ -52,6 +52,7 @@ const readStored = () => { /** Announce a token to every element on the page. No-ops on an empty or unchanged value. */ export function publishMapToken(tk) { if (typeof window === 'undefined') return '' + installBridge() const next = typeof tk === 'string' ? tk.trim() : '' if (!next || next === readStored()) return readStored() window[KEY] = next @@ -59,6 +60,20 @@ export function publishMapToken(tk) { return next } +/** + * Withdraw the page-wide token (a "Clear" in the studio). publishMapToken('') is deliberately a no-op, and + * `delete window.MAP_TOKEN` removed the bridge's accessor while its install flag stayed set, so the bridge + * never came back and the OLD token stayed live. This empties the value through the same channel. + */ +export function clearMapToken() { + if (typeof window === 'undefined') return + const had = readStored() || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) + installBridge() + window[KEY] = '' + if (!Object.getOwnPropertyDescriptor(window, 'MAP_TOKEN')?.get) { try { window.MAP_TOKEN = '' } catch { /* host-owned */ } } + if (had) { try { window.dispatchEvent(new CustomEvent(EVENT, { detail: '' })) } catch { /* pre-CustomEvent host */ } } +} + /** * Adopt `window.MAP_TOKEN` as a source, in BOTH directions and for the whole life of the page. * @@ -68,7 +83,10 @@ export function publishMapToken(tk) { * because N bundles on one page would otherwise stack N accessors on the same property. */ function installBridge() { - if (typeof window === 'undefined' || window[FLAG]) return + if (typeof window === 'undefined') return + // Installed AND still in place: a host (the studio's old Clear did) can `delete window.MAP_TOKEN`, which + // removes the accessor while the flag stays set — then nothing was ever adopted again. Re-install. + if (window[FLAG] && Object.getOwnPropertyDescriptor(window, 'MAP_TOKEN')?.get) return // Seed from whatever the page already set before this bundle loaded. const existing = typeof window.MAP_TOKEN === 'string' ? window.MAP_TOKEN : '' if (existing && !readStored()) window[KEY] = existing @@ -100,3 +118,30 @@ export function useSharedMapToken() { } return token } + +/** + * profile-config.tokenUrl: a token SERVICE the element asks when it was handed no token (the studio's + * header and the gallery write it; nothing used to read it). Same contract as the leaf pages' ladder: + * JSON {token|tk|accessToken} or a bare string, and only a PUBLIC token (pk.…) is accepted. One fetch per + * URL per page; the answer is published page-wide. While it is in flight, window.__doxaMapTokenPending + * holds the promise, so a map about to start on Mapbox waits for it instead of failing tokenless. + * @returns {Promise} the token, or '' when the service did not give one + */ +export function fetchMapToken(url) { + if (typeof window === 'undefined' || !/^https?:\/\//i.test(String(url || ''))) return Promise.resolve('') + const cache = (window.__doxaMapTokenFetches = window.__doxaMapTokenFetches || {}) + if (cache[url]) return cache[url] + const p = fetch(url) + .then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return (r.headers.get('content-type') || '').includes('json') ? r.json() : r.text() }) + .then((d) => { + const tk = String((typeof d === 'string' ? d : d && (d.token || d.tk || d.accessToken)) || '').trim() + if (!tk.startsWith('pk.')) return '' + publishMapToken(tk) + return tk + }) + .catch((e) => { console.warn(`[map token] ${url}: ${e && e.message ? e.message : e}`); return '' }) + .finally(() => { if (window.__doxaMapTokenPending === p) window.__doxaMapTokenPending = null }) + window.__doxaMapTokenPending = p + cache[url] = p + return p +} diff --git a/library/layouts/app-wrapper-material/languages.js b/library/layouts/app-wrapper-material/languages.js index 6d38c6d09..17692dde9 100644 --- a/library/layouts/app-wrapper-material/languages.js +++ b/library/layouts/app-wrapper-material/languages.js @@ -32,7 +32,12 @@ export const LANGUAGES = [ // Kept here, flagged, so the list stays a faithful mirror of the source of truth. { code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', hideFromSwitcher: true }, { code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', hideFromSwitcher: true }, - // enabled:false in dx-life (routable/translatable but not offered): it, zh, ro. + // enabled:false in dx-life (routable/translatable but not offered), but every library catalogue carries + // them (library/i18n SUPPORTED_LOCALES, 11 codes). Listed, hidden from the public switcher, so a tag of + // it/ro/zh is no longer normalised to 'en' — it was, in the studio (11 locales vs 8 here). + { code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', hideFromSwitcher: true }, + { code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', hideFromSwitcher: true }, + { code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', hideFromSwitcher: true }, ] /** Languages offered in the dropdown. */ diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue index 521519319..a7d559d22 100644 --- a/plugin-designs/plugin-studio/plugin-studio.vue +++ b/plugin-designs/plugin-studio/plugin-studio.vue @@ -203,7 +203,8 @@ import { defineComponent, inject } from 'vue' import { useShadowStyles } from '@map/composables/useShadowStyles.js' import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue' import StudioTree from './src/StudioTree.vue' -import { SWITCHER_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' // 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). @@ -503,7 +504,9 @@ export default defineComponent({ userTokenUrl: readStored(TOKEN_URL_KEY), fetchedTk: '', bakedTk: '', - switcherLangs: SWITCHER_LANGUAGES, + hostProvider: '', + // EVERY locale the library carries, not the public site's six: the studio exists to exercise props. + switcherLangs: LANGUAGES, // rows keyed by "/"; each carries its resolved nesting state. rows: [], // flat list built from the manifest (grouped for display via `groups`) // raw manifest bundles, kept ONLY so composeGroups() can read each bundle's `composes` list — @@ -648,7 +651,8 @@ export default defineComponent({ return this.userTk || this.fetchedTk || this.tk || this.cfg.tk || this.bakedTk || '' }, effectiveProvider() { - const p = this.userProvider || this.provider || this.cfg.provider || '' + // Case-insensitive, as resolveMapProvider is: a host's provider="MapLibre" was dropped here. + const p = String(this.userProvider || this.provider || this.cfg.provider || '').toLowerCase().trim() return p === 'maplibre' || p === 'mapbox' ? p : '' }, engines() { @@ -687,9 +691,16 @@ export default defineComponent({ mounted() { // Read once: after applyToken sets window.MAP_TOKEN it would no longer be "the page's own". this.bakedTk = (typeof window !== 'undefined' && window.MAP_TOKEN) || '' - if (this.userProvider) this.publishProvider() + // The HOST's own engine switch, kept so "each map's own" restores it instead of deleting it. + this.hostProvider = (typeof window !== 'undefined' && typeof window.MAP_PROVIDER === 'string') ? window.MAP_PROVIDER : '' + this.publishProvider() // a host provider (attribute/profile-config) reaches nested maps too, chosen or not if (this.userTk) this.publishToken() - else if (this.userTokenUrl) this.fetchToken(this.userTokenUrl).then((t) => { if (t) { this.fetchedTk = t; this.publishToken() } }).catch(() => {}) + else if (this.userTokenUrl || this.tokenUrl || this.cfg.tokenUrl) { + // A token from a URL arrives AFTER the first pane mounted; re-mount so that pane gets it too. + this.fetchToken(this.userTokenUrl || this.tokenUrl || this.cfg.tokenUrl) + .then((t) => { if (t && t !== this.fetchedTk) { this.fetchedTk = t; this.publishToken(); this.remountPanes() } }) + .catch(() => {}) + } this.loadManifest() }, methods: { @@ -745,6 +756,7 @@ export default defineComponent({ publishProvider() { if (typeof window === 'undefined') return if (this.effectiveProvider) window.MAP_PROVIDER = this.effectiveProvider + else if (this.hostProvider) window.MAP_PROVIDER = this.hostProvider else if ('MAP_PROVIDER' in window) delete window.MAP_PROVIDER }, clearToken() { @@ -758,8 +770,9 @@ export default defineComponent({ // so the effective token is published there too (and the page's own token restored on clear). publishToken() { if (typeof window === 'undefined') return - if (this.effectiveTk) window.MAP_TOKEN = this.effectiveTk - else if ('MAP_TOKEN' in window) delete window.MAP_TOKEN + // Through useSharedMapToken, never `delete`: deleting removed its bridge for good (U3). + if (this.effectiveTk) { publishMapToken(this.effectiveTk); if (window.MAP_TOKEN !== this.effectiveTk) window.MAP_TOKEN = this.effectiveTk } + else clearMapToken() }, // Release every mounted pane (frees its map) and re-mount the one on screen with the new token; // the others pick it up when they are next selected. @@ -803,7 +816,8 @@ export default defineComponent({ paneConfig(row, locale) { const pc = { profile: row.profile, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale } if (this.effectiveTk) pc.tk = this.effectiveTk - if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl + const tokenUrl = this.userTokenUrl || this.tokenUrl || this.cfg.tokenUrl + if (tokenUrl) pc.tokenUrl = tokenUrl if (this.effectiveProvider) pc.provider = this.effectiveProvider if (this.base) pc.treeBase = this.base return pc @@ -813,11 +827,13 @@ export default defineComponent({ this.localeOpen = false saveLocale(code) try { - const row = this.active - const slot = this._slots && this._slots[this.activeKey] - const el = slot && slot.firstElementChild - if (el && el.setAttribute && row) { - el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, code))) + // EVERY mounted pane, not only the one on screen: a kept-alive pane showed the old language + // when the reader came back to it. + for (const row of this.rows) { + if (!row.mounted) continue + const slot = this._slots && this._slots[row.key] + const el = slot && slot.firstElementChild + if (el && el.setAttribute) el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, code))) } } catch { /* non-fatal: the switcher still persists + updates the UI */ } },