diff --git a/internal/check-map-unmount-guard.mjs b/internal/check-map-unmount-guard.mjs new file mode 100644 index 000000000..f5e6a9fa7 --- /dev/null +++ b/internal/check-map-unmount-guard.mjs @@ -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) diff --git a/internal/check-map-unmount-mid-init.mjs b/internal/check-map-unmount-mid-init.mjs new file mode 100644 index 000000000..1b01e5f03 --- /dev/null +++ b/internal/check-map-unmount-mid-init.mjs @@ -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 '); 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') diff --git a/internal/check-shared-component-reached-bundles.mjs b/internal/check-shared-component-reached-bundles.mjs index 5ca5b4277..178870cca 100644 --- a/internal/check-shared-component-reached-bundles.mjs +++ b/internal/check-shared-component-reached-bundles.mjs @@ -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}`) diff --git a/library/composables/useMapInstance.js b/library/composables/useMapInstance.js index b84cfaba6..324916fdb 100644 --- a/library/composables/useMapInstance.js +++ b/library/composables/useMapInstance.js @@ -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 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 diff --git a/package.json b/package.json index fc9247735..7701557d2 100644 --- a/package.json +++ b/package.json @@ -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",