useMapInstance: a map unmounted mid-initialise returns quietly instead of throwing
Some checks are pending
build-gate / build (push) Waiting to run

initializeMap is async. If its component unmounts while it waits (a
studio tab switch, a composite swapping views), Vue nulls containerRef
and the next dereference threw "Cannot read properties of null (reading
'getRootNode')" from ensureEngineCSS. check:studio-all hit it about 1
run in 4 (gospel-dashboard); gospel-plugin found a second site one await
later (containerRef.value.clientWidth after the animation frame).

initializeMap now checks the ref after each of its three awaits and
returns silently when it is gone, and ensureEngineCSS returns on a null
element. An unmounted pane is normal, not an error, so nothing is
logged. The ref is the only liveness signal: requiring isConnected too
would silently stop maps that mount into a subtree before it is
attached.

Proof, deterministic in both directions:
- check:map-unmount-mid-init holds mapbox-gl.js, opens a map plugin in
  the studio, switches away (destroying the pane), then releases the
  script. Before: FAIL 2/2 runs (research-map, doxa-simple-map threw
  the production error). After: PASS 2/2 across research-map,
  gospel-dashboard and doxa-simple-map.
- check:map-unmount-race (gospel-plugin's browser-free test, adopted):
  the guarded ensureEngineCSS resolves; with the guard stripped it
  throws the exact message.
- No regression: check:studio-all passes 31/31, and 19 plugins still
  create a live map canvas, as before.

check:shared-reached now tracks this change with a regex marker (a
control-flow fix has no literal that survives minification). It
reports 12 consumer bundles still to rebuild. Verified: rebuilding
doxa-simple-map moves it to "carries it".
This commit is contained in:
doxa-maps-88655c70 2026-09-25 05:01:07 +00:00
parent c5fb3fde83
commit 0c1a91cd02
5 changed files with 154 additions and 3 deletions

View file

@ -0,0 +1,30 @@
/* check-map-unmount-guard — the fast, browser-free half of the unmount-race proof (by gospel-plugin,
adopted into internal/ by the studio lane; the end-to-end half is check-map-unmount-mid-init.mjs).
Proves the guard by REPRODUCING the race against the real file: take the shipped source,
run its ensureEngineCSS with a null element (what an unmounted pane hands it), and do the
same against a copy with the guard stripped. One must throw, the other must not — a fix
that cannot be shown to matter is not evidence. */
import { readFileSync } from 'node:fs'
const SRC = readFileSync('library/composables/useMapInstance.js', 'utf8')
const grab = (src) => {
const i = src.indexOf('async function ensureEngineCSS')
const j = src.indexOf('\n}\n', i)
return src.slice(i, j + 2)
}
const body = grab(SRC)
const stripped = body.replace(' if (!element) return\n', '')
if (stripped === body) { console.error('CONTROL BROKEN — the guard line was not found, so the negative arm proves nothing.'); process.exit(1) }
const run = async (fnSrc) => {
const f = new Function('ShadowRoot', `${fnSrc}; return ensureEngineCSS`)(class ShadowRoot {})
try { await f(null, 'https://example.invalid/x.css'); return 'resolved' } catch (e) { return `threw: ${e.message}` }
}
const withGuard = await run(body)
const without = await run(stripped)
console.log(` guarded -> ${withGuard}`)
console.log(` unguarded -> ${without}`)
const ok = withGuard === 'resolved' && /getRootNode/.test(without)
console.log(ok ? 'UNMOUNT-RACE: PASS — the guard turns studio\'s crash into a clean return.'
: 'UNMOUNT-RACE: FAIL — the arms did not separate; this proves nothing.')
process.exit(ok ? 0 : 1)

View file

@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* check-map-unmount-mid-init — a map whose pane is removed WHILE it is still initialising must not throw.
*
* USAGE: node internal/check-map-unmount-mid-init.mjs --url http://localhost:5299/
* Graded by EXIT CODE: 0 = no map threw, 1 = a map threw, 2 = UNMEASURED (the race was not staged).
*
* ★ THE DEFECT (found by check:studio-all, 1 run in 4, 2026-09-25):
* Cannot read properties of null (reading 'getRootNode')
* at ensureEngineCSS (library/composables/useMapInstance.js)
* <- initializeMap (library/composables/useMapInstance.js)
* <- DashboardMapPanel.vue
* initializeMap is async. If its component unmounts during an await (a studio tab switch, a
* composite swapping a view), Vue nulls the container ref, and the next line dereferenced it.
*
* ★ WHY 1-IN-N IS NOT PROOF, AND HOW THIS MAKES IT DETERMINISTIC. The window is the time the engine
* script takes to load, so whether a tab switch lands inside it is luck. This gate removes the
* luck: it HOLDS the engine script request, opens a map plugin (its initializeMap suspends on the
* held script), switches the studio to a non-map plugin (which destroys the map pane — map rows are
* torn down on deactivate), and only then releases the script. Every run lands in the window.
* Negative-tested: fails every run on the unguarded composable, passes on the guarded one.
*
* It refuses to pass vacuously: if the script was never requested (preloaded) or the pane was not
* actually removed before release, the race was not staged and it exits 2, not 0.
*/
import { chromium } from 'playwright'
const i = process.argv.indexOf('--url')
const BASE = (i > -1 ? process.argv[i + 1] : '').replace(/#.*$/, '')
if (!BASE) { console.error('check-map-unmount-mid-init: pass --url <studio page>'); process.exit(2) }
// Production map plugins that go through useMapInstance, opened by their studio key.
const CASES = [
{ key: 'doxa-research-map/research-map', tag: 'doxa-research-map' },
{ key: 'gospel-resources/gospel-dashboard', tag: 'gospel-resources-map' },
{ key: 'doxa-simple-map/doxa-simple-map', tag: 'doxa-map' }
]
const AWAY = 'doxa-vision-card' // a non-map plugin to switch to
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
let failed = 0, unmeasured = 0
for (const c of CASES) {
const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage()
const errors = []
page.on('pageerror', (e) => errors.push(String(e.message || e).slice(0, 160) + ' [' + String(e.stack || '').split('\n').slice(1, 3).map((l) => l.trim().replace(/https?:\/\/[^/]+/, '')).join(' <- ') + ']'))
page.on('console', (m) => { if (m.type() === 'error' && /useMapInstance|getRootNode|ZERO dimensions/.test(m.text())) errors.push('console: ' + m.text().slice(0, 160)) })
let release = null, held = false
const heldNow = new Promise((resolve) => {
page.route(/mapbox-gl\.js(\?|$)/, async (route) => {
held = true
resolve()
await new Promise((r) => { release = r })
await route.continue()
})
})
await page.goto(BASE + '#/' + c.key, { waitUntil: 'domcontentloaded' })
const got = await Promise.race([heldNow.then(() => true), new Promise((r) => setTimeout(() => r(false), 30000))])
if (!got) { console.log('UNMEASURED ' + c.key + ' — the engine script was never requested (preloaded?)'); unmeasured++; await page.close(); continue }
// Switch away while initializeMap is suspended on the held script.
await page.waitForFunction(() => {
const s = document.querySelector('plugin-studio')
return !!(s && s.shadowRoot && s.shadowRoot.querySelector('.st-row[aria-level="2"]'))
}, null, { timeout: 30000 })
await page.evaluate((away) => {
const rows = [...document.querySelector('plugin-studio').shadowRoot.querySelectorAll('.st-row[aria-level="2"]')]
const r = rows.find((x) => x.querySelector('.st-label').textContent.trim() === away)
if (r) r.click()
}, AWAY)
await page.waitForTimeout(600)
const stillMounted = await page.evaluate((tag) => !!document.querySelector('plugin-studio').shadowRoot.querySelector('.ps-slot ' + tag), c.tag)
if (stillMounted) { console.log('UNMEASURED ' + c.key + ' — its pane was not removed before the script was released'); unmeasured++; release(); await page.close(); continue }
release()
await page.waitForTimeout(4000)
if (!held) unmeasured++
if (errors.length) { failed++; console.log('FAIL ' + c.key + '\n ' + [...new Set(errors)].join('\n ')) }
else console.log('ok ' + c.key + ' — unmounted mid-initialise, released the engine, no error')
await page.close()
}
await browser.close()
if (failed) { console.log('MAP-UNMOUNT-MID-INIT: FAIL — ' + failed + ' of ' + CASES.length + ' maps threw after unmounting mid-initialise'); process.exit(1) }
if (unmeasured) { console.log('MAP-UNMOUNT-MID-INIT: UNMEASURED — ' + unmeasured + ' case(s) did not stage the race'); process.exit(2) }
console.log('MAP-UNMOUNT-MID-INIT: PASS — ' + CASES.length + ' maps unmounted mid-initialise without an error')

View file

@ -58,7 +58,25 @@ const MARKERS = [
landed: 'regions, c4c60a4 and earlier',
why: 'accessible names + aria-expanded on the legend row caret and clear chip',
},
{
// A CONTROL-FLOW fix has no literal to grep, so this entry uses REGEX markers (see `has` below).
// The built shape keeps its structure through minification even though names change:
// unguarded const o=e.getRootNode();if(!(o instanceof ShadowRoot))
// guarded if(!e)return;const o=e.getRootNode();if(!(o instanceof ShadowRoot))
// The `instanceof ShadowRoot` tail pins it to ensureEngineCSS: useShadowStyles has its own
// (older) `if(!a)return;…getRootNode()` guard, which must not count.
id: 'map-unmount-guard',
component: 'library/composables/useMapInstance.js',
sourceRe: /if \(!element\) return\n\s*const root = element\.getRootNode\(\)/,
builtRe: /if\(!([\w$]+)\)return;const ([\w$]+)=\1\.getRootNode\(\);if\(!\(\2 instanceof ShadowRoot\)\)/,
marker: 'if (!element) return (ensureEngineCSS)',
landed: 'studio, 2026-09-25',
why: 'a map whose pane unmounts mid-initialise no longer throws "reading getRootNode" (check-map-unmount-mid-init)',
},
]
// A marker is a literal (`marker`, matched with includes) unless the entry supplies regexes.
const inSource = (m, src) => (m.sourceRe ? m.sourceRe.test(src) : src.includes(m.marker))
const inBuilt = (m, js) => (m.builtRe ? m.builtRe.test(js) : js.includes(m.marker))
if (!existsSync(TREE)) {
console.log(`SHARED-REACHED-BUNDLES: SKIP — no built tree at ${TREE}.`)
@ -125,7 +143,7 @@ for (const m of MARKERS) {
continue
}
const src = readFileSync(srcPath, 'utf8')
if (!src.includes(m.marker)) {
if (!inSource(m, src)) {
// THE MARKER IS GONE FROM SOURCE. That is a stale ENTRY, not a stale bundle — failing every
// consumer here would blame the bundles for a change that was legitimately reverted.
staleMarkers.push(`${m.id}: marker "${m.marker}" is no longer in ${m.component} — `
@ -141,7 +159,7 @@ for (const m of MARKERS) {
const built = join(TREE, bundle, `${bundle}.js`)
if (!existsSync(built)) { unbuilt.push(bundle); continue }
const js = readFileSync(built, 'utf8')
;(js.includes(m.marker) ? have : missing).push(bundle)
;(inBuilt(m, js) ? have : missing).push(bundle)
}
console.log(` ${m.id} — ${m.why}`)

View file

@ -160,6 +160,8 @@ export async function ensureEngineScript(provider) {
* - Always resolves — never hangs (2 s timeout fallback).
*/
async function ensureEngineCSS(element, cssUrl) {
// An unmounted pane hands us null (see initializeMap's liveness checks); nothing to style.
if (!element) return
const root = element.getRootNode()
if (!(root instanceof ShadowRoot)) return // light DOM — wrapper <link> is enough
@ -367,8 +369,17 @@ export function useMapInstance({
return
}
// ★ LIVENESS AFTER EVERY AWAIT. initializeMap is async, and its component can unmount while it
// waits — a studio tab switch, a composite swapping views. Vue then nulls containerRef, and the
// next line that touched it threw "Cannot read properties of null (reading 'getRootNode')"
// (found 1 run in 4 by check:studio-all, 2026-09-25; made deterministic by
// internal/check-map-unmount-mid-init.mjs, which holds the engine script and switches away).
// An unmounted pane is normal, not an error, so these returns are SILENT.
const gone = () => !containerRef.value
// Load the selected engine (no-op if index.html preloaded it).
await ensureEngineScript(provider)
if (gone()) return
const gl = engineGlobal(provider)
if (!gl) {
console.error(`[useMapInstance] ${isMaplibre ? 'maplibregl' : 'mapboxgl'} is not available. `
@ -398,11 +409,13 @@ export function useMapInstance({
// Wait for the engine CSS inside the shadow root before creating the Map.
// Without this, _detectMissingCSS fires and the canvas initializes at 0px.
await ensureEngineCSS(containerRef.value, isMaplibre ? ENGINES.maplibre.css : MAPBOX_CSS_URL)
if (gone()) return
// Wait one animation frame so the browser has laid out the container.
// Mapbox reads clientWidth/clientHeight at init — if layout hasn't settled
// yet the canvas initializes at 0×0 and nothing renders.
await new Promise(resolve => requestAnimationFrame(resolve))
if (gone()) return
// ─── Dimension diagnostics ───────────────────────────────────────
const el = containerRef.value

View file

@ -125,6 +125,8 @@
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
"check:url-resolver": "node internal/check-url-resolver.mjs",
"check:studio-all": "node internal/check-studio-every-plugin.mjs",
"check:map-unmount-race": "node internal/check-map-unmount-guard.mjs",
"check:map-unmount-mid-init": "node internal/check-map-unmount-mid-init.mjs",
"smoke:studio": "node internal/smoke-studio.mjs",
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs",
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
@ -278,7 +280,9 @@
"check:shared-reached": "a shared library fix is only as deployed as the last rebuild of EVERY bundle that consumes it. Each bundle ships its own COMPILED copy, so a one-line fix in library/ is live in whichever bundles happened to be rebuilt after it and silently absent from the rest — and every other gate measures ONE leaf, which renders the old behaviour perfectly. Asserts a distinctive marker string per tracked change; consumers are DERIVED from imports, never hardcoded.",
"check:tree-weight": "the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one.",
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
"check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish."
"check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.",
"check:map-unmount-race": "browser-free, sub-second: runs the shipped ensureEngineCSS with a null element (what an unmounted pane hands it) against a copy with its guard stripped; the guarded arm must resolve and the stripped arm must throw \"reading getRootNode\". By gospel-plugin, adopted by studio.",
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",