From d1f9264016b8458f7cebaf6e5c03f3f91bdfed44 Mon Sep 17 00:00:00 2001
From: doxa-maps-88655c70
<88627970+doxa-maps-88655c70@users.noreply.github.com>
Date: Fri, 25 Sep 2026 21:14:19 +0000
Subject: [PATCH] The dashboard's search header follows the engine it was given
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
It fetched the Mapbox geocoder from api.mapbox.com whatever the provider, and
never passed `provider` to the GeocoderComponent it mounts — so a partner who
chose MapLibre, the keyless engine picked precisely so that no Mapbox key and no
Mapbox traffic is involved, still sent every visitor's IP to Mapbox and still
got the Mapbox geocoder. It now resolves the provider through the usual ladder
and, under MapLibre, preloads nothing: GeocoderComponent already fetches the
MapLibre plugin itself, so the right amount of work here is none.
★ AND THIS DOES NOT FIX THE LEAK THAT WAS REPORTED, WHICH IS WORTH SAYING PLAINLY.
The audit named this file, and this file was wrong. But research-dashboard.vue:23
says in as many words that DashboardSearchHeader "remains available in the kit
but is not mounted here" — so nothing this dashboard renders was ever affected by
it. Measured on the built leaf at ?provider=maplibre, the four requests it makes
to api.mapbox.com are all tags in the GENERATED LEAF PAGE:
mapbox-gl.css · mapbox-gl.js · mapbox-gl-geocoder.css · mapbox-gl-geocoder.min.js
The leaf ships Mapbox GL and the Mapbox geocoder to every visitor regardless of
the engine asked for. That belongs to the leaf generator, not to this plugin,
and it has gone to studio as a finding.
So what lands here is a component made correct for whoever mounts it, and a gate
that names the real cause. The gate is deliberately NOT in gates:post: it cannot
pass until the leaf stops shipping those four files, and a red gate about another
lane's file teaches everyone to ignore it. Run it by name.
---
.../check-dashboard-geocoder-provider.mjs | 134 ++++++++++++++++++
.../dashboards/DashboardSearchHeader.vue | 22 +++
.../dashboard-plus-grid.js | 98 -------------
.../dashboard-plus-grid/default/index.html | 2 +-
.../dashboard-plus-grid/index.html | 2 +-
public/dx-maps/index.html | 2 +-
public/dx-maps/manifest.json | 8 +-
.../research-dashboard/research-dashboard.js | 62 ++++----
.../research-dashboard/index.html | 2 +-
9 files changed, 195 insertions(+), 137 deletions(-)
create mode 100644 plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs
diff --git a/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs b/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs
new file mode 100644
index 000000000..1384ced8d
--- /dev/null
+++ b/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+/**
+ * check-dashboard-geocoder-provider.mjs — a dashboard running on MapLibre must not call Mapbox.
+ *
+ * THE CONTRACT: a partner who selects MapLibre has selected the keyless engine precisely so that no
+ * Mapbox key and no Mapbox traffic is involved. One request to api.mapbox.com sends that visitor's
+ * IP to Mapbox and breaks the promise, whatever the page looks like afterwards.
+ *
+ * ★ WHAT THIS MEASURED, AND IT IS NOT WHERE THE AUDIT POINTED. studio's Mapbox-globals audit named
+ * DashboardSearchHeader.vue, which did preload api.mapbox.com/.../mapbox-gl-geocoder.min.js
+ * unconditionally and never passed `provider` to the GeocoderComponent it mounts — both now fixed.
+ * But that component is NOT MOUNTED by this dashboard (research-dashboard.vue:23 says so in as many
+ * words), so fixing it changes nothing here. The four requests this leaf actually makes under
+ * ?provider=maplibre are all from the GENERATED LEAF PAGE's own tags:
+ *
+ * mapbox-gl.css · mapbox-gl.js · mapbox-gl-geocoder.css · mapbox-gl-geocoder.min.js
+ *
+ * i.e. the leaf ships Mapbox GL and the Mapbox geocoder to every visitor regardless of the engine
+ * it was asked for. That is the leaf GENERATOR's doing, not this plugin's, and it is why this gate
+ * is deliberately NOT wired into gates:post yet: it would be a red gate about somebody else's file.
+ * Run it by name; it names the exact URLs.
+ *
+ * ★ ZERO IS THE ASSERTION, not "it rendered". A build that quietly kept the Mapbox geocoder would
+ * look identical: the bars appear, suggestions work, nothing errors. Only the request log tells the
+ * truth, which is why this counts requests rather than looking at the page.
+ *
+ * ASSERTIONS, on the built tree:
+ * maplibre ZERO requests to api.mapbox.com.
+ * both whatever search bars this leaf renders still produce suggestions — a zero bought by
+ * breaking search is not a fix. MEASURED: this leaf renders NONE, because the search
+ * header is not mounted here, so that half reports rather than asserts. Point the gate
+ * at a leaf that mounts the bars and it starts asserting again.
+ *
+ * Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
+ */
+import { createServer } from 'node:http'
+import { spawn } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import { resolve, dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const TAG = 'DASHBOARD-GEOCODER-PROVIDER'
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
+const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
+
+const { TREE_NAME } = await import(join(ROOT, 'internal', '_tree-name.mjs'))
+const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
+if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
+let chromium
+try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
+
+const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
+const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-dashboard')
+const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl)
+if (!profile) skip('the manifest declares no research-dashboard leaf to load.')
+
+const freePort = () => new Promise((ok) => {
+ const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
+})
+const PORT = await freePort()
+const TREE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
+const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
+const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
+const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
+await new Promise((r) => setTimeout(r, 900))
+if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
+ cleanup(); skip('could not serve the built tree locally.')
+}
+const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
+await requireThisTree(TREE, ROOT, TAG)
+
+const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
+const fails = []
+const lines = []
+try {
+ for (const engine of ['maplibre', 'mapbox']) {
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
+ const mapboxCalls = []
+ page.on('request', (r) => { const u = r.url(); if (u.includes('api.mapbox.com')) mapboxCalls.push(u) })
+ await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
+ await page.waitForTimeout(20000)
+
+ const search = await page.evaluate(async () => {
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+ const seen = new Set(); const hits = []
+ const walk = (root) => {
+ if (!root || seen.has(root)) return
+ seen.add(root)
+ for (const el of root.querySelectorAll('*')) {
+ if (el.matches && el.matches('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')) hits.push(el)
+ if (el.shadowRoot) walk(el.shadowRoot)
+ }
+ }
+ walk(document)
+ if (!hits.length) return { bars: 0, suggestions: 0 }
+ const input = hits[0]
+ input.focus()
+ input.value = 'Afar'
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ for (let i = 0; i < 40; i++) {
+ await sleep(500)
+ const root = input.getRootNode()
+ const n = root.querySelectorAll('.mapboxgl-ctrl-geocoder .suggestions li, .maplibregl-ctrl-geocoder .suggestions li, .suggestions li').length
+ if (n) return { bars: hits.length, suggestions: n }
+ }
+ return { bars: hits.length, suggestions: 0 }
+ })
+ await page.close()
+
+ lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}`)
+
+ if (engine === 'maplibre' && mapboxCalls.length) {
+ fails.push(`under maplibre the dashboard made ${mapboxCalls.length} request(s) to api.mapbox.com:`)
+ for (const u of [...new Set(mapboxCalls)].slice(0, 8)) fails.push(` ${u.slice(0, 150)}`)
+ }
+ // Reported, not asserted, when the leaf mounts no bars at all — see the header note.
+ if (search.bars && !search.suggestions) {
+ fails.push(`under ${engine} the search bar rendered but typing produced no suggestions — zero Mapbox traffic bought by a broken search is not a fix.`)
+ }
+ }
+} catch (err) {
+ fails.push(`the measurement itself failed: ${err && err.message}`)
+} finally {
+ await browser.close().catch(() => {})
+ cleanup()
+}
+
+for (const l of lines) console.log(l)
+if (fails.length) {
+ console.log(`\n${TAG}: FAIL`)
+ for (const f of fails) console.log(` ✗ ${f}`)
+ process.exit(1)
+}
+console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times and still searches; Mapbox is unchanged.`)
diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardSearchHeader.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardSearchHeader.vue
index 22463e72f..95acc1c90 100644
--- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardSearchHeader.vue
+++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardSearchHeader.vue
@@ -32,6 +32,7 @@ import { inject, ref, computed, unref, onMounted } from 'vue'
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import { useGeocoderSearch } from '@map/composables/useGeocoderSearch.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
+import { resolveMapProvider } from '@map/composables/useMapProvider.js'
const bus = inject('selectionBus', null)
const dash = inject('dashboardData', null)
@@ -43,6 +44,15 @@ const mapboxToken = inject('mapboxToken', ref(''))
// map profiles wire :data-source-id="dataSource" (DASH-FIX-2).
const searchSourceId = inject('searchSourceId', '')
+// ★ WHICH ENGINE THIS DASHBOARD IS RUNNING ON, because the search bar has to agree with it.
+// The same ladder every map panel uses: profile-config.provider -> ?provider= -> window.MAP_PROVIDER
+// -> mapbox.
+// inject() at setup top level, NOT inside the computed: Vue only resolves an injection during
+// setup, and a call from inside a computed returns undefined with a warning — which would have
+// silently resolved every host to the default engine, i.e. exactly the bug being fixed.
+const profileConfig = inject('profileConfig', null)
+const provider = computed(() => resolveMapProvider(profileConfig))
+
const token = computed(() => unref(mapboxToken) || '')
// ── Host-independent geocoder-plugin loader ────────────────────────────────────
@@ -52,10 +62,21 @@ const token = computed(() => unref(mapboxToken) || '')
// host remembering to add that script. So load the plugin from the Mapbox CDN
// ourselves (mirrors how GeocoderComponent injects the geocoder CSS at runtime)
// and only mount the bars once it's ready — works in ANY host with zero wiring.
+// ★ AND UNDER MapLibre IT MUST NOT BE THE MAPBOX ONE. This preloader fetched
+// api.mapbox.com/.../mapbox-gl-geocoder.min.js unconditionally, so a partner who chose MapLibre
+// precisely to avoid Mapbox still sent every visitor's IP to api.mapbox.com the moment this
+// dashboard mounted — the keyless embed's whole reason for existing, cancelled by one hardcoded
+// URL. (Found by studio's Mapbox-globals audit.)
+//
+// GeocoderComponent already loads whichever plugin its `provider` says (ensureMapboxGeocoder /
+// ensureMaplibreGeocoder), so under MapLibre the right thing to do here is NOTHING: mark ready and
+// let the component fetch its own from jsDelivr. The mapbox preload stays for the mapbox path,
+// where it is what keeps a host from having to remember a
-
+
-
+
+