From b8c3dee8d46c7007ee02f8fd604ebf58b3890bff Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Mon, 1 Jun 2026 14:01:59 +0400 Subject: [PATCH 1/4] fix: lazy-load globe map stack --- src/components/MapContainer.ts | 55 +++++++++++++++++++++++++--------- vite.config.ts | 6 +++- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts index e4a25e7bad..9b3e76dbc9 100644 --- a/src/components/MapContainer.ts +++ b/src/components/MapContainer.ts @@ -11,7 +11,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'; import { isMobileDevice } from '@/utils'; import { MapComponent } from './Map'; import { DeckGLMap, type DeckMapView, type CountryClickPayload } from './DeckGLMap'; -import { GlobeMap } from './GlobeMap'; +import type { GlobeMap } from './GlobeMap'; import type { MapLayers, Hotspot, @@ -87,6 +87,7 @@ interface TechEventMarker { type FireMarker = { lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }; type NewsLocationMarker = { lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }; type CIIScore = { code: string; score: number; level: string }; +type GlobeMapCtor = typeof import('./GlobeMap').GlobeMap; /** * Unified map interface that delegates to either DeckGLMap or MapComponent @@ -98,10 +99,13 @@ export class MapContainer { private deckGLMap: DeckGLMap | null = null; private svgMap: MapComponent | null = null; private globeMap: GlobeMap | null = null; + private globeMapCtorPromise: Promise | null = null; + private globeActivationId = 0; private supplyChainPanel: import('@/components/SupplyChainPanel').SupplyChainPanel | null = null; private initialState: MapContainerState; private useDeckGL: boolean; private useGlobe: boolean; + private destroyed = false; private isResizingInternal = false; private resizeObserver: ResizeObserver | null = null; @@ -205,10 +209,39 @@ export class MapContainer { this.svgMap = new MapComponent(this.container, this.initialState); } + private loadGlobeMapCtor(): Promise { + this.globeMapCtorPromise ??= import('./GlobeMap').then(({ GlobeMap }) => GlobeMap); + return this.globeMapCtorPromise; + } + + private observeContainerResize(): void { + if (typeof ResizeObserver === 'undefined' || this.resizeObserver) return; + this.resizeObserver = new ResizeObserver(() => { + // Skip if we are already handling resize manually via drag handlers + if (this.isResizingInternal) return; + this.resize(); + }); + this.resizeObserver.observe(this.container); + } + + private async initGlobeMap( + snapshot?: MapContainerState, + center?: { lat: number; lon: number } | null, + ): Promise { + const activationId = ++this.globeActivationId; + console.log('[MapContainer] Initializing 3D globe (globe.gl mode)'); + const GlobeMapCtor = await this.loadGlobeMapCtor(); + if (this.destroyed || !this.useGlobe || activationId !== this.globeActivationId) return; + + this.globeMap = new GlobeMapCtor(this.container, this.initialState); + this.observeContainerResize(); + if (snapshot) this.restoreViewport(snapshot, center ?? null); + this.rehydrateActiveMap(); + } + private init(): void { if (this.useGlobe) { - console.log('[MapContainer] Initializing 3D globe (globe.gl mode)'); - this.globeMap = new GlobeMap(this.container, this.initialState); + void this.initGlobeMap(); } else if (this.useDeckGL) { console.log('[MapContainer] Initializing deck.gl map (desktop mode)'); try { @@ -226,14 +259,7 @@ export class MapContainer { } // Automatic resize on container change (fixes gaps on load/layout shift) - if (typeof ResizeObserver !== 'undefined') { - this.resizeObserver = new ResizeObserver(() => { - // Skip if we are already handling resize manually via drag handlers - if (this.isResizingInternal) return; - this.resize(); - }); - this.resizeObserver.observe(this.container); - } + this.observeContainerResize(); } /** Switch to 3D globe mode at runtime (called from Settings). */ @@ -246,9 +272,7 @@ export class MapContainer { this.destroyFlatMap(); this.useGlobe = true; this.useDeckGL = false; - this.globeMap = new GlobeMap(this.container, this.initialState); - this.restoreViewport(snapshot, center); - this.rehydrateActiveMap(); + void this.initGlobeMap(snapshot, center); } /** Reload basemap style (called when map provider changes in Settings). */ @@ -266,6 +290,7 @@ export class MapContainer { this.globeMap?.destroy(); this.globeMap = null; this.useGlobe = false; + this.globeActivationId++; this.useDeckGL = this.shouldUseDeckGL(); this.init(); this.restoreViewport(snapshot, center); @@ -1048,6 +1073,8 @@ export class MapContainer { } public destroy(): void { + this.destroyed = true; + this.globeActivationId++; this.resizeObserver?.disconnect(); this.globeMap?.destroy(); this.deckGLMap?.destroy(); diff --git a/vite.config.ts b/vite.config.ts index e0543683d8..920c95ad33 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,8 +20,9 @@ const BROTLI_EXTENSIONS = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.jso // silent-breakage failure mode where renaming a chunk in `manualChunks` // re-eagerises the WebGL stack without any build-time error. // - maplibre, deck-stack: heavy WebGL deps, only reachable via MapContainer +// - globe-stack: globe.gl + three.js, only reachable when globe mode loads // - MapContainer: the dynamic-import target itself -const LAZY_HTML_PRELOAD_CHUNKS = ['maplibre', 'deck-stack', 'MapContainer'] as const; +const LAZY_HTML_PRELOAD_CHUNKS = ['maplibre', 'deck-stack', 'globe-stack', 'MapContainer'] as const; const LAZY_HTML_PRELOAD_RE = new RegExp( `/(${LAZY_HTML_PRELOAD_CHUNKS.join('|')})-[A-Za-z0-9_-]+\\.js$`, ); @@ -987,6 +988,9 @@ export default defineConfig(({ mode }) => { ) { return 'deck-stack'; } + if (id.includes('/globe.gl/') || id.includes('/three/')) { + return 'globe-stack'; + } if (id.includes('/d3/')) { return 'd3'; } From 67d00974aea1aee7d04558bfce9aa3c8e95db420 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Mon, 1 Jun 2026 14:19:42 +0400 Subject: [PATCH 2/4] fix: recover from globe load failures --- src/components/MapContainer.ts | 41 +++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts index 9b3e76dbc9..69fe4c8838 100644 --- a/src/components/MapContainer.ts +++ b/src/components/MapContainer.ts @@ -8,7 +8,7 @@ // paint anyway. Keep this import at the top of the file so Vite associates it // with this module's chunk, not whichever sibling pulls it in first. import 'maplibre-gl/dist/maplibre-gl.css'; -import { isMobileDevice } from '@/utils'; +import { isMobileDevice, saveToStorage, showToast } from '@/utils'; import { MapComponent } from './Map'; import { DeckGLMap, type DeckMapView, type CountryClickPayload } from './DeckGLMap'; import type { GlobeMap } from './GlobeMap'; @@ -53,6 +53,7 @@ import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor import type { TrafficAnomaly as ProtoTrafficAnomaly, DdosLocationHit } from '@/generated/client/worldmonitor/infrastructure/v1/service_client'; import type { DiseaseOutbreakItem } from '@/services/disease-outbreaks'; import type { GetChokepointStatusResponse } from '@/services/supply-chain'; +import { STORAGE_KEYS } from '@/config'; import type { ScenarioVisualState, ScenarioResult } from '@/config/scenario-templates'; import { getAuthState } from '@/services/auth-state'; import { hasPremiumAccess } from '@/services/panel-gating'; @@ -210,10 +211,24 @@ export class MapContainer { } private loadGlobeMapCtor(): Promise { - this.globeMapCtorPromise ??= import('./GlobeMap').then(({ GlobeMap }) => GlobeMap); + if (!this.globeMapCtorPromise) { + this.globeMapCtorPromise = import('./GlobeMap') + .then(({ GlobeMap }) => GlobeMap) + .catch((error) => { + this.globeMapCtorPromise = null; + throw error; + }); + } return this.globeMapCtorPromise; } + private markFlatModeAfterGlobeFailure(): void { + saveToStorage(STORAGE_KEYS.mapMode, 'flat'); + document.querySelectorAll('#mapDimensionToggle .map-dim-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.mode === 'flat'); + }); + } + private observeContainerResize(): void { if (typeof ResizeObserver === 'undefined' || this.resizeObserver) return; this.resizeObserver = new ResizeObserver(() => { @@ -230,10 +245,26 @@ export class MapContainer { ): Promise { const activationId = ++this.globeActivationId; console.log('[MapContainer] Initializing 3D globe (globe.gl mode)'); - const GlobeMapCtor = await this.loadGlobeMapCtor(); - if (this.destroyed || !this.useGlobe || activationId !== this.globeActivationId) return; - this.globeMap = new GlobeMapCtor(this.container, this.initialState); + try { + const GlobeMapCtor = await this.loadGlobeMapCtor(); + if (this.destroyed || !this.useGlobe || activationId !== this.globeActivationId) return; + + this.globeMap = new GlobeMapCtor(this.container, this.initialState); + } catch (error) { + if (this.destroyed || !this.useGlobe || activationId !== this.globeActivationId) return; + + console.warn('[MapContainer] Globe initialization failed, falling back to flat map', error); + this.useGlobe = false; + this.useDeckGL = this.shouldUseDeckGL(); + this.markFlatModeAfterGlobeFailure(); + this.init(); + if (snapshot) this.restoreViewport(snapshot, center ?? null); + this.rehydrateActiveMap(); + showToast('3D globe failed to load. Showing 2D map instead.'); + return; + } + this.observeContainerResize(); if (snapshot) this.restoreViewport(snapshot, center ?? null); this.rehydrateActiveMap(); From 2aa045ae9553975274d2316ce0f2acca1bb6aee4 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Mon, 1 Jun 2026 14:30:42 +0400 Subject: [PATCH 3/4] test: guard lazy map preloads --- src/components/MapContainer.ts | 8 +++----- vite.config.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts index 69fe4c8838..755daef9a1 100644 --- a/src/components/MapContainer.ts +++ b/src/components/MapContainer.ts @@ -8,7 +8,7 @@ // paint anyway. Keep this import at the top of the file so Vite associates it // with this module's chunk, not whichever sibling pulls it in first. import 'maplibre-gl/dist/maplibre-gl.css'; -import { isMobileDevice, saveToStorage, showToast } from '@/utils'; +import { isMobileDevice, showToast } from '@/utils'; import { MapComponent } from './Map'; import { DeckGLMap, type DeckMapView, type CountryClickPayload } from './DeckGLMap'; import type { GlobeMap } from './GlobeMap'; @@ -53,7 +53,6 @@ import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor import type { TrafficAnomaly as ProtoTrafficAnomaly, DdosLocationHit } from '@/generated/client/worldmonitor/infrastructure/v1/service_client'; import type { DiseaseOutbreakItem } from '@/services/disease-outbreaks'; import type { GetChokepointStatusResponse } from '@/services/supply-chain'; -import { STORAGE_KEYS } from '@/config'; import type { ScenarioVisualState, ScenarioResult } from '@/config/scenario-templates'; import { getAuthState } from '@/services/auth-state'; import { hasPremiumAccess } from '@/services/panel-gating'; @@ -222,8 +221,7 @@ export class MapContainer { return this.globeMapCtorPromise; } - private markFlatModeAfterGlobeFailure(): void { - saveToStorage(STORAGE_KEYS.mapMode, 'flat'); + private markFlatModeActiveAfterGlobeFailure(): void { document.querySelectorAll('#mapDimensionToggle .map-dim-btn').forEach((btn) => { btn.classList.toggle('active', btn.dataset.mode === 'flat'); }); @@ -257,7 +255,7 @@ export class MapContainer { console.warn('[MapContainer] Globe initialization failed, falling back to flat map', error); this.useGlobe = false; this.useDeckGL = this.shouldUseDeckGL(); - this.markFlatModeAfterGlobeFailure(); + this.markFlatModeActiveAfterGlobeFailure(); this.init(); if (snapshot) this.restoreViewport(snapshot, center ?? null); this.rehydrateActiveMap(); diff --git a/vite.config.ts b/vite.config.ts index 920c95ad33..f6c53d2504 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,6 +26,10 @@ const LAZY_HTML_PRELOAD_CHUNKS = ['maplibre', 'deck-stack', 'globe-stack', 'MapC const LAZY_HTML_PRELOAD_RE = new RegExp( `/(${LAZY_HTML_PRELOAD_CHUNKS.join('|')})-[A-Za-z0-9_-]+\\.js$`, ); +const HTML_MODULEPRELOAD_TAG_RE = /]*rel=["']modulepreload["'][^>]*>/g; +const LAZY_HTML_CHUNK_REF_RE = new RegExp( + `\\bhref=["'][^"']*/(${LAZY_HTML_PRELOAD_CHUNKS.join('|')})-[A-Za-z0-9_-]+\\.js["']`, +); // Panel-cluster manualChunks map. Splits the previously monolithic ~2.3MB // `panels` chunk into per-domain chunks so cache invalidation is local to @@ -125,6 +129,35 @@ function brotliPrecompressPlugin(): Plugin { }; } +function lazyHtmlPreloadGuardPlugin(): Plugin { + return { + name: 'lazy-html-preload-guard', + apply: 'build', + generateBundle(_outputOptions, bundle) { + const violations: string[] = []; + + for (const [fileName, asset] of Object.entries(bundle)) { + if (asset.type !== 'asset' || !fileName.endsWith('.html')) continue; + + const html = typeof asset.source === 'string' + ? asset.source + : new TextDecoder().decode(asset.source); + const preloadTags = html.match(HTML_MODULEPRELOAD_TAG_RE) ?? []; + const matches = preloadTags.filter((tag) => LAZY_HTML_CHUNK_REF_RE.test(tag)); + if (matches.length > 0) { + violations.push(`${fileName}: ${matches.join(', ')}`); + } + } + + if (violations.length > 0) { + this.error( + `Lazy chunks were emitted as entry HTML modulepreload dependencies:\n${violations.join('\n')}`, + ); + } + }, + }; +} + function htmlVariantPlugin(activeMeta: VariantMeta, activeVariant: string, isDesktopBuild: boolean): Plugin { return { name: 'html-variant', @@ -779,6 +812,7 @@ export default defineConfig(({ mode }) => { youtubeLivePlugin(), gpsjamDevPlugin(), sebufApiPlugin(), + lazyHtmlPreloadGuardPlugin(), brotliPrecompressPlugin(), VitePWA({ registerType: 'autoUpdate', From d04b6c723734c1b6e2c261a2a810968c3c1bb87c Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Mon, 1 Jun 2026 14:54:36 +0400 Subject: [PATCH 4/4] test: verify lazy preload guard after html emit --- vite.config.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index f6c53d2504..43ad1927ae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -133,21 +133,23 @@ function lazyHtmlPreloadGuardPlugin(): Plugin { return { name: 'lazy-html-preload-guard', apply: 'build', - generateBundle(_outputOptions, bundle) { + enforce: 'post', + async writeBundle(outputOptions, bundle) { + const outDir = outputOptions.dir; + if (!outDir) return; + const violations: string[] = []; - for (const [fileName, asset] of Object.entries(bundle)) { - if (asset.type !== 'asset' || !fileName.endsWith('.html')) continue; + await Promise.all(Object.entries(bundle).map(async ([fileName, asset]) => { + if (asset.type !== 'asset' || !fileName.endsWith('.html')) return; - const html = typeof asset.source === 'string' - ? asset.source - : new TextDecoder().decode(asset.source); + const html = await readFile(resolve(outDir, fileName), 'utf8'); const preloadTags = html.match(HTML_MODULEPRELOAD_TAG_RE) ?? []; const matches = preloadTags.filter((tag) => LAZY_HTML_CHUNK_REF_RE.test(tag)); if (matches.length > 0) { violations.push(`${fileName}: ${matches.join(', ')}`); } - } + })); if (violations.length > 0) { this.error(