doxa-maps/library/composables/useSharedMapToken.js
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

102 lines
5.3 KiB
JavaScript

/**
* useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
*
* THE BUG THIS ENDS (the agent, 2026-09-23; the repo already names it
* BUG-COMPOSITE-LEAVES-ARE-TOKEN-FREE-BUT-NEST-REAL-MAPS):
* "the element gets the tk to the element itself and does not need to only gatekeep that to one
* component only — all components in a plugin can share that tk"
*
* Inside ONE element that was already true: ProfileLoader provides 'mapboxToken' and eleven
* components inject it. The gate is the ELEMENT BOUNDARY. Every custom element mounts its own Vue
* app with its own ProfileLoader, which re-derives the token from ITS OWN profile-config — so a
* composite that nests a real map hands the child no `tk` and the child starts from nothing.
*
* ★ AND THE FALLBACK THAT WAS SUPPOSED TO COVER THAT COULD NOT FIRE. ProfileLoader's ladder already
* read `window.MAP_TOKEN` after config.tk. MEASURED on the built tree: window.MAP_TOKEN IS set on
* the composite leaf — and the child STILL had no search bar. The reason is that the ladder lives
* in a computed() and window.MAP_TOKEN is a PLAIN GLOBAL, not a reactive source. The generated
* composite leaf FETCHES its token (one fetch( in the page), so the computed evaluates to '' long
* before the token lands, caches that, and never re-runs — nothing reactive ever changed.
* So the symptom looked like "no token" while the token was right there, seconds later. A grep
* for the fallback would have said the fallback existed; only reading WHEN it ran explains it.
*
* THE FIX: make the page-level token a REACTIVE, CROSS-ELEMENT fact.
* - publishMapToken(tk) stores it and announces it on `window`.
* - useSharedMapToken() returns a ref that is seeded from whatever is already known and UPDATES
* when a later publish happens — so an async token reaches elements that mounted before it.
* - Assignments to `window.MAP_TOKEN` are adopted automatically (see installBridge), so every
* ALREADY-GENERATED page keeps working untouched: the leaf pages assign that global today and
* now that assignment broadcasts instead of landing silently.
*
* ★ WHY AN EVENT AND NOT A SHARED MODULE REF. Each bundle is its own IIFE with its own copy of this
* module, so a module-level ref is NOT shared between <research-map-plus-grid> and the
* <doxa-research-map> it nests — they are different bundles. The storage therefore lives on
* `window` and changes travel as a DOM event, which is the only channel both copies can see.
*
* SECURITY: this shares a PUBLIC Mapbox token (pk.*) that is already in the page's HTML and in
* every tile request. It does not widen exposure — it stops a second element on the SAME page from
* being unable to use what that page was already given. Nothing is persisted or sent anywhere.
*/
import { ref, onBeforeUnmount } from 'vue'
const KEY = '__doxaMapToken' // the value, on window, so every bundle sees one copy
const EVENT = 'doxa:map-token' // fired when it changes
const FLAG = '__doxaMapTokenBridge'
const readStored = () => {
if (typeof window === 'undefined') return ''
const v = window[KEY]
return typeof v === 'string' ? v : ''
}
/** 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 ''
const next = typeof tk === 'string' ? tk.trim() : ''
if (!next || next === readStored()) return readStored()
window[KEY] = next
try { window.dispatchEvent(new CustomEvent(EVENT, { detail: next })) } catch { /* pre-CustomEvent host */ }
return next
}
/**
* Adopt `window.MAP_TOKEN` as a source, in BOTH directions and for the whole life of the page.
*
* Every generated leaf assigns `window.MAP_TOKEN`, some of them from an async fetch. Replacing that
* property with an accessor means such an assignment publishes to every element automatically —
* which is what makes this fix reach pages nobody regenerates. Installed once per bundle; guarded
* because N bundles on one page would otherwise stack N accessors on the same property.
*/
function installBridge() {
if (typeof window === 'undefined' || window[FLAG]) 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
try {
Object.defineProperty(window, 'MAP_TOKEN', {
configurable: true,
get() { return readStored() },
set(v) { publishMapToken(v) },
})
window[FLAG] = true
} catch {
// A host may have made MAP_TOKEN non-configurable. Reading it still works through the ladder
// below, so degrade to "no live adoption" rather than throwing during bundle init.
}
}
/**
* A reactive view of the page's shared map token.
* @returns {import('vue').Ref<string>} updates when any element publishes a token
*/
export function useSharedMapToken() {
installBridge()
const token = ref(readStored())
if (typeof window !== 'undefined') {
const onChange = () => { token.value = readStored() }
window.addEventListener(EVENT, onChange)
// A composable may be called outside a component (rare here); guard so it cannot throw.
try { onBeforeUnmount(() => window.removeEventListener(EVENT, onChange)) } catch { /* not in setup */ }
}
return token
}