From ec335577ce1bbd1bb5230ef8605d1a45a6d31c6a Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Thu, 28 May 2026 10:10:42 +0400 Subject: [PATCH] fix(build): re-split lazy panel chunks --- src/App.ts | 31 +-- src/app/country-intel.ts | 9 +- src/app/event-handlers.ts | 9 +- src/app/search-manager.ts | 2 +- src/components/BigMacPanel.ts | 8 +- src/components/ClimateNewsPanel.ts | 8 +- src/components/DeductionPanel.ts | 8 +- src/components/EnergyDisruptionsPanel.ts | 10 +- src/components/EnergyRiskOverviewPanel.ts | 10 +- src/components/FaoFoodPriceIndexPanel.ts | 8 +- src/components/FuelPricesPanel.ts | 8 +- src/components/FuelShortagePanel.ts | 14 +- src/components/GroceryBasketPanel.ts | 8 +- src/components/GulfEconomiesPanel.ts | 8 +- src/components/MacroSignalsPanel.ts | 6 +- src/components/NewsPanel.ts | 6 +- src/components/PipelineStatusPanel.ts | 16 +- src/components/RegionalIntelligenceBoard.ts | 12 +- src/components/StorageFacilityMapPanel.ts | 16 +- src/components/TechEventsPanel.ts | 6 +- src/services/rpc-client.ts | 12 ++ tests/chunk-assignment.test.mjs | 120 +++++++++-- vite.config.ts | 227 +++++++++++++++++++- 23 files changed, 439 insertions(+), 123 deletions(-) diff --git a/src/App.ts b/src/App.ts index 1ca60c77b0..81171e6caa 100644 --- a/src/App.ts +++ b/src/App.ts @@ -224,18 +224,23 @@ export class App { if (this.mapModulesInitialized) return; this.mapModulesInitialized = true; - this.countryIntel.init(); - // Unblock any WebMCP tool invocations that arrived during startup, even if - // the deferred map has not loaded yet. - this.resolveUiReady(); - if (this.pendingMobileGeoCoords && this.state.map) { - this.state.map.setCenter(this.pendingMobileGeoCoords.lat, this.pendingMobileGeoCoords.lon, 6); - this.pendingMobileGeoCoords = null; - } - this.state.countryBriefPage?.onStateChange?.(() => { - this.eventHandlers.syncUrlState(); - }); - this.handleDeepLinks(); + void this.countryIntel.init() + .catch((err) => { + console.error('[CountryIntel] init failed:', err); + }) + .then(() => { + // Unblock any WebMCP tool invocations that arrived during startup, even if + // the deferred map has not loaded yet. + this.resolveUiReady(); + if (this.pendingMobileGeoCoords && this.state.map) { + this.state.map.setCenter(this.pendingMobileGeoCoords.lat, this.pendingMobileGeoCoords.lon, 6); + this.pendingMobileGeoCoords = null; + } + this.state.countryBriefPage?.onStateChange?.(() => { + this.eventHandlers.syncUrlState(); + }); + this.handleDeepLinks(); + }); } private getCachedBootstrapUpdatedAt(): number | null { @@ -1290,7 +1295,7 @@ export class App { // Phase 3: UI setup methods this.eventHandlers.startHeaderClock(); this.eventHandlers.setupPlaybackControl(); - this.eventHandlers.setupStatusPanel(); + await this.eventHandlers.setupStatusPanel(); this.eventHandlers.setupPizzIntIndicator(); this.eventHandlers.setupLlmStatusIndicator(); this.eventHandlers.setupExportPanel(); diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index 5dcffc8d0d..07aad3f9c5 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -9,7 +9,6 @@ import type { CountryDeepDiveMilitarySummary, CountryDeepDiveSignalDetails, } from '@/components/CountryBriefPanel'; -import { CountryDeepDivePanel } from '@/components/CountryDeepDivePanel'; import { reverseGeocode } from '@/utils/reverse-geocode'; import { effectivePubDateMs } from '@/services/feed-date'; import { @@ -86,8 +85,8 @@ export class CountryIntelManager implements AppModule { this.ctx = ctx; } - init(): void { - this.setupCountryIntel(); + async init(): Promise { + await this.setupCountryIntel(); this.frameworkUnsubscribe = subscribeFrameworkChange('country-brief', () => { const page = this.ctx.countryBriefPage; if (!page?.isVisible()) return; @@ -124,8 +123,10 @@ export class CountryIntelManager implements AppModule { this.authUnsubscribe = null; } - private setupCountryIntel(): void { + private async setupCountryIntel(): Promise { if (!this.ctx.map) return; + const { CountryDeepDivePanel } = await import('@/components/CountryDeepDivePanel'); + if (!this.ctx.map || this.ctx.isDestroyed) return; this.ctx.countryBriefPage = new CountryDeepDivePanel(this.ctx.map); this.ctx.countryBriefPage.setShareStoryHandler((code, name) => { this.ctx.countryBriefPage?.hide(); diff --git a/src/app/event-handlers.ts b/src/app/event-handlers.ts index 4056f6724b..f30d8b380b 100644 --- a/src/app/event-handlers.ts +++ b/src/app/event-handlers.ts @@ -11,11 +11,10 @@ import type { PositionSample } from '@/services/aviation'; import type { ClusteredEvent } from '@/types'; import type { DashboardSnapshot } from '@/services/storage'; import { PlaybackControl } from '@/components/PlaybackControl'; -import { StatusPanel } from '@/components/StatusPanel'; import { PizzIntIndicator } from '@/components/PizzIntIndicator'; import { LlmStatusIndicator } from '@/components/LlmStatusIndicator'; -import { CIIPanel } from '@/components/CIIPanel'; -import { PredictionPanel } from '@/components/PredictionPanel'; +import type { CIIPanel } from '@/components/CIIPanel'; +import type { PredictionPanel } from '@/components/PredictionPanel'; import { buildMapUrl, debounce, @@ -1021,7 +1020,9 @@ export class EventHandlerManager implements AppModule { this.clockIntervalId = setInterval(tick, 1000); } - setupStatusPanel(): void { + async setupStatusPanel(): Promise { + const { StatusPanel } = await import('@/components/StatusPanel'); + if (this.ctx.isDestroyed) return; this.ctx.statusPanel = new StatusPanel(); } diff --git a/src/app/search-manager.ts b/src/app/search-manager.ts index 534b75766a..4d75eb7a98 100644 --- a/src/app/search-manager.ts +++ b/src/app/search-manager.ts @@ -4,7 +4,7 @@ import type { NewsItem, MapLayers } from '@/types'; import type { MapView } from '@/components/MapContainer'; import type { Command } from '@/config/commands'; import { SearchModal } from '@/components/SearchModal'; -import { CIIPanel } from '@/components/CIIPanel'; +import type { CIIPanel } from '@/components/CIIPanel'; import { SITE_VARIANT, STORAGE_KEYS } from '@/config'; import { getAllowedLayerKeys, isLayerExecutable } from '@/config/map-layer-definitions'; import type { MapRenderer } from '@/config/map-layer-definitions'; diff --git a/src/components/BigMacPanel.ts b/src/components/BigMacPanel.ts index 731bddc71e..be84759148 100644 --- a/src/components/BigMacPanel.ts +++ b/src/components/BigMacPanel.ts @@ -2,11 +2,11 @@ import { Panel } from './Panel'; import { t } from '@/services/i18n'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; import { getHydratedData } from '@/services/bootstrap'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client'; import type { ListBigMacPricesResponse } from '@/generated/client/worldmonitor/economic/v1/service_client'; -const client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters) => globalThis.fetch(...args) }); +const getEconomicClient = createLazyClient(() => new EconomicServiceClient(getRpcBaseUrl(), { fetch: rpcFetch })); export class BigMacPanel extends Panel { constructor() { @@ -19,13 +19,13 @@ export class BigMacPanel extends Panel { if (hydrated?.countries?.length) { if (!this.element?.isConnected) return; this.renderIndex(hydrated); - void client.listBigMacPrices({}).then(data => { + void getEconomicClient().listBigMacPrices({}).then(data => { if (!this.element?.isConnected || !data.countries?.length) return; this.renderIndex(data); }).catch(() => {}); return; } - const data = await client.listBigMacPrices({}); + const data = await getEconomicClient().listBigMacPrices({}); if (!this.element?.isConnected) return; this.renderIndex(data); } catch (err) { diff --git a/src/components/ClimateNewsPanel.ts b/src/components/ClimateNewsPanel.ts index efdd421b67..6b06864a56 100644 --- a/src/components/ClimateNewsPanel.ts +++ b/src/components/ClimateNewsPanel.ts @@ -2,11 +2,11 @@ import { Panel } from './Panel'; import { t } from '@/services/i18n'; import { joinSafeHtml, safeHtml, safeUrlAttr, type SafeHtml } from '@/utils/sanitize'; import { getHydratedData } from '@/services/bootstrap'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { ClimateServiceClient } from '@/generated/client/worldmonitor/climate/v1/service_client'; import type { ListClimateNewsResponse, ClimateNewsItem } from '@/generated/client/worldmonitor/climate/v1/service_client'; -const client = new ClimateServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters) => globalThis.fetch(...args) }); +const getClimateClient = createLazyClient(() => new ClimateServiceClient(getRpcBaseUrl(), { fetch: rpcFetch })); function formatTimeAgo(epochMs: number): string { const diffMs = Date.now() - epochMs; @@ -52,13 +52,13 @@ export class ClimateNewsPanel extends Panel { if (hydrated?.items?.length) { if (!this.element?.isConnected) return; this.renderNewsList(hydrated); - void client.listClimateNews({}).then(data => { + void getClimateClient().listClimateNews({}).then(data => { if (!this.element?.isConnected || !data.items?.length) return; this.renderNewsList(data); }).catch(() => {}); return; } - const data = await client.listClimateNews({}); + const data = await getClimateClient().listClimateNews({}); if (!this.element?.isConnected) return; this.renderNewsList(data); } catch (err) { diff --git a/src/components/DeductionPanel.ts b/src/components/DeductionPanel.ts index c11d3404db..f52be03f33 100644 --- a/src/components/DeductionPanel.ts +++ b/src/components/DeductionPanel.ts @@ -1,5 +1,5 @@ -import { Panel } from './Panel'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { Panel } from './Panel'; +import { createLazyClient, getRpcBaseUrl } from '@/services/rpc-client'; import { premiumFetch } from '@/services/premium-fetch'; import { IntelligenceServiceClient } from '@/generated/client/worldmonitor/intelligence/v1/service_client'; import { h, replaceChildren, setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; @@ -12,7 +12,7 @@ import { hasPremiumAccess } from '@/services/panel-gating'; import { FrameworkSelector } from './FrameworkSelector'; // deduct-situation + list-market-implications are premium-gated. -const client = new IntelligenceServiceClient(getRpcBaseUrl(), { fetch: premiumFetch }); +const getIntelligenceClient = createLazyClient(() => new IntelligenceServiceClient(getRpcBaseUrl(), { fetch: premiumFetch })); const COOLDOWN_MS = 5_000; @@ -247,7 +247,7 @@ export class DeductionPanel extends Panel { ); try { - const resp = await client.deductSituation({ + const resp = await getIntelligenceClient().deductSituation({ query, geoContext, framework: fw?.systemPromptAppend ?? '', diff --git a/src/components/EnergyDisruptionsPanel.ts b/src/components/EnergyDisruptionsPanel.ts index f5f2e2f932..7c9b750571 100644 --- a/src/components/EnergyDisruptionsPanel.ts +++ b/src/components/EnergyDisruptionsPanel.ts @@ -1,6 +1,6 @@ import { Panel } from './Panel'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { attributionFooterHtml, ATTRIBUTION_FOOTER_CSS } from '@/utils/attribution-footer'; import { SupplyChainServiceClient } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; import type { @@ -14,9 +14,9 @@ import { type DisruptionStatus, } from '@/shared/disruption-timeline'; -const client = new SupplyChainServiceClient(getRpcBaseUrl(), { - fetch: (...args: Parameters) => globalThis.fetch(...args), -}); +const getSupplyChainClient = createLazyClient(() => new SupplyChainServiceClient(getRpcBaseUrl(), { + fetch: rpcFetch, +})); // One glyph per event type so readers can scan the timeline by nature of // disruption. Kept terse — the type string itself is shown next to the glyph. @@ -127,7 +127,7 @@ export class EnergyDisruptionsPanel extends Panel { public async fetchData(): Promise { try { - const live = await client.listEnergyDisruptions({ + const live = await getSupplyChainClient().listEnergyDisruptions({ assetId: '', assetType: '', ongoingOnly: false, diff --git a/src/components/EnergyRiskOverviewPanel.ts b/src/components/EnergyRiskOverviewPanel.ts index ec3771e80b..12346e9116 100644 --- a/src/components/EnergyRiskOverviewPanel.ts +++ b/src/components/EnergyRiskOverviewPanel.ts @@ -20,16 +20,16 @@ import { Panel } from './Panel'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { fetchHormuzTracker, type HormuzTrackerData } from '@/services/hormuz-tracker'; import { getEuGasStorageData } from '@/services/economic'; import { fetchCommodityQuotes } from '@/services/market'; import { SupplyChainServiceClient } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; import { buildOverviewState, type OverviewState } from './_energy-risk-overview-state'; -const supplyChain = new SupplyChainServiceClient(getRpcBaseUrl(), { - fetch: (...args: Parameters) => globalThis.fetch(...args), -}); +const getSupplyChainClient = createLazyClient(() => new SupplyChainServiceClient(getRpcBaseUrl(), { + fetch: rpcFetch, +})); const BRENT_SYMBOL = 'BZ=F'; const BRENT_META = [{ symbol: BRENT_SYMBOL, name: 'Brent Crude', display: 'BRENT' }]; @@ -111,7 +111,7 @@ export class EnergyRiskOverviewPanel extends Panel { // a Greptile P2 finding (over-fetch); buildOverviewState's count // calculation handles either response (the redundant client-side // filter remains as defense-in-depth in the state builder). - supplyChain.listEnergyDisruptions({ assetId: '', assetType: '', ongoingOnly: true }), + getSupplyChainClient().listEnergyDisruptions({ assetId: '', assetType: '', ongoingOnly: true }), ]); this.state = buildOverviewState(hormuz, euGas, brent, disruptions, Date.now()); diff --git a/src/components/FaoFoodPriceIndexPanel.ts b/src/components/FaoFoodPriceIndexPanel.ts index 3ead69361a..c06e9df578 100644 --- a/src/components/FaoFoodPriceIndexPanel.ts +++ b/src/components/FaoFoodPriceIndexPanel.ts @@ -2,11 +2,11 @@ import { Panel } from './Panel'; import { t } from '@/services/i18n'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; import { getHydratedData } from '@/services/bootstrap'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client'; import type { GetFaoFoodPriceIndexResponse, FaoFoodPricePoint } from '@/generated/client/worldmonitor/economic/v1/service_client'; -const client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters) => globalThis.fetch(...args) }); +const getEconomicClient = createLazyClient(() => new EconomicServiceClient(getRpcBaseUrl(), { fetch: rpcFetch })); const SVG_W = 480; const SVG_H = 140; @@ -106,13 +106,13 @@ export class FaoFoodPriceIndexPanel extends Panel { if (hydrated?.points?.length) { if (!this.element?.isConnected) return; this.renderChart(hydrated); - void client.getFaoFoodPriceIndex({}).then(data => { + void getEconomicClient().getFaoFoodPriceIndex({}).then(data => { if (!this.element?.isConnected || !data.points?.length) return; this.renderChart(data); }).catch(() => {}); return; } - const data = await client.getFaoFoodPriceIndex({}); + const data = await getEconomicClient().getFaoFoodPriceIndex({}); if (!this.element?.isConnected) return; this.renderChart(data); } catch (err) { diff --git a/src/components/FuelPricesPanel.ts b/src/components/FuelPricesPanel.ts index f8dead4290..8582efd7c2 100644 --- a/src/components/FuelPricesPanel.ts +++ b/src/components/FuelPricesPanel.ts @@ -2,11 +2,11 @@ import { Panel } from './Panel'; import { t } from '@/services/i18n'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; import { getHydratedData } from '@/services/bootstrap'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client'; import type { ListFuelPricesResponse } from '@/generated/client/worldmonitor/economic/v1/service_client'; -const client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters) => globalThis.fetch(...args) }); +const getEconomicClient = createLazyClient(() => new EconomicServiceClient(getRpcBaseUrl(), { fetch: rpcFetch })); export class FuelPricesPanel extends Panel { constructor() { @@ -19,13 +19,13 @@ export class FuelPricesPanel extends Panel { if (hydrated?.countries?.length) { if (!this.element?.isConnected) return; this.renderIndex(hydrated); - void client.listFuelPrices({}).then(data => { + void getEconomicClient().listFuelPrices({}).then(data => { if (!this.element?.isConnected || !data.countries?.length) return; this.renderIndex(data); }).catch(() => {}); return; } - const data = await client.listFuelPrices({}); + const data = await getEconomicClient().listFuelPrices({}); if (!this.element?.isConnected) return; this.renderIndex(data); } catch (err) { diff --git a/src/components/FuelShortagePanel.ts b/src/components/FuelShortagePanel.ts index 7f381bf686..bc8de4c526 100644 --- a/src/components/FuelShortagePanel.ts +++ b/src/components/FuelShortagePanel.ts @@ -1,6 +1,6 @@ import { Panel } from './Panel'; import { escapeHtml, sanitizeUrl, unsafeRawHtml } from '@/utils/sanitize'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { attributionFooterHtml, ATTRIBUTION_FOOTER_CSS } from '@/utils/attribution-footer'; import { SupplyChainServiceClient } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; import type { @@ -19,9 +19,9 @@ import { type RawFuelShortageRegistry, } from '@/shared/fuel-shortage-registry-store'; -const client = new SupplyChainServiceClient(getRpcBaseUrl(), { - fetch: (...args: Parameters) => globalThis.fetch(...args), -}); +const getSupplyChainClient = createLazyClient(() => new SupplyChainServiceClient(getRpcBaseUrl(), { + fetch: rpcFetch, +})); const SEVERITY_COLOR: Record = { confirmed: '#e74c3c', @@ -161,7 +161,7 @@ export class FuelShortagePanel extends Panel { if (hydrated) { this.data = hydrated; this.render(); - void client.listFuelShortages({ country: '', product: '', severity: '' }).then(live => { + void getSupplyChainClient().listFuelShortages({ country: '', product: '', severity: '' }).then(live => { if (!this.element?.isConnected || !live?.shortages?.length) return; this.data = live; this.render(); @@ -176,7 +176,7 @@ export class FuelShortagePanel extends Panel { return; } - const live = await client.listFuelShortages({ country: '', product: '', severity: '' }); + const live = await getSupplyChainClient().listFuelShortages({ country: '', product: '', severity: '' }); if (!this.element?.isConnected) return; if (live.upstreamUnavailable || !live.shortages?.length) { this.showError('Fuel shortage registry unavailable', () => void this.fetchData()); @@ -203,7 +203,7 @@ export class FuelShortagePanel extends Panel { this.detailLoading = true; this.render(); try { - const d = await client.getFuelShortageDetail({ shortageId }); + const d = await getSupplyChainClient().getFuelShortageDetail({ shortageId }); if (!this.element?.isConnected || this.selectedId !== shortageId) return; this.detail = d; this.detailLoading = false; diff --git a/src/components/GroceryBasketPanel.ts b/src/components/GroceryBasketPanel.ts index 03d5469588..bd5d011eb1 100644 --- a/src/components/GroceryBasketPanel.ts +++ b/src/components/GroceryBasketPanel.ts @@ -2,11 +2,11 @@ import { Panel } from './Panel'; import { t } from '@/services/i18n'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; import { getHydratedData } from '@/services/bootstrap'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client'; import type { ListGroceryBasketPricesResponse } from '@/generated/client/worldmonitor/economic/v1/service_client'; -const client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters) => globalThis.fetch(...args) }); +const getEconomicClient = createLazyClient(() => new EconomicServiceClient(getRpcBaseUrl(), { fetch: rpcFetch })); export class GroceryBasketPanel extends Panel { constructor() { @@ -19,13 +19,13 @@ export class GroceryBasketPanel extends Panel { if (hydrated?.countries?.length) { if (!this.element?.isConnected) return; this.renderBasket(hydrated); - void client.listGroceryBasketPrices({}).then(data => { + void getEconomicClient().listGroceryBasketPrices({}).then(data => { if (!this.element?.isConnected || !data.countries?.length) return; this.renderBasket(data); }).catch(() => {}); return; } - const data = await client.listGroceryBasketPrices({}); + const data = await getEconomicClient().listGroceryBasketPrices({}); if (!this.element?.isConnected) return; this.renderBasket(data); } catch (err) { diff --git a/src/components/GulfEconomiesPanel.ts b/src/components/GulfEconomiesPanel.ts index 0cb55cd44f..8ffddad78d 100644 --- a/src/components/GulfEconomiesPanel.ts +++ b/src/components/GulfEconomiesPanel.ts @@ -1,5 +1,5 @@ import { Panel } from './Panel'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { t } from '@/services/i18n'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; import { formatPrice, formatChange, getChangeClass } from '@/utils'; @@ -8,7 +8,7 @@ import { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/s import type { ListGulfQuotesResponse, GulfQuote } from '@/generated/client/worldmonitor/market/v1/service_client'; import { getHydratedData } from '@/services/bootstrap'; -const client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters) => globalThis.fetch(...args) }); +const getMarketClient = createLazyClient(() => new MarketServiceClient(getRpcBaseUrl(), { fetch: rpcFetch })); function renderSection(title: string, quotes: GulfQuote[]): string { if (quotes.length === 0) return ''; @@ -39,13 +39,13 @@ export class GulfEconomiesPanel extends Panel { if (hydrated?.quotes?.length) { if (!this.element?.isConnected) return; this.renderGulf(hydrated); - void client.listGulfQuotes({}).then(data => { + void getMarketClient().listGulfQuotes({}).then(data => { if (!this.element?.isConnected || !data.quotes?.length) return; this.renderGulf(data); }).catch(() => {}); return; } - const data = await client.listGulfQuotes({}); + const data = await getMarketClient().listGulfQuotes({}); if (!this.element?.isConnected) return; this.renderGulf(data); } catch (err) { diff --git a/src/components/MacroSignalsPanel.ts b/src/components/MacroSignalsPanel.ts index b617e0c651..383e15c0d1 100644 --- a/src/components/MacroSignalsPanel.ts +++ b/src/components/MacroSignalsPanel.ts @@ -1,5 +1,5 @@ import { Panel } from './Panel'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; import { t } from '@/services/i18n'; import { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client'; @@ -24,7 +24,7 @@ interface MacroSignalData { unavailable?: boolean; } -const economicClient = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); +const getEconomicClient = createLazyClient(() => new EconomicServiceClient(getRpcBaseUrl(), { fetch: rpcFetch })); /** Map proto response (optional fields = undefined) to MacroSignalData (null for absent values). */ function mapProtoToData(r: GetMacroSignalsResponse): MacroSignalData { @@ -153,7 +153,7 @@ export class MacroSignalsPanel extends Panel { private async refreshFromRpc(): Promise { try { - const res = await economicClient.getMacroSignals({}); + const res = await getEconomicClient().getMacroSignals({}); if (!this.element?.isConnected) return false; this.data = mapProtoToData(res); this.error = null; diff --git a/src/components/NewsPanel.ts b/src/components/NewsPanel.ts index c4299c5d3d..4661489c64 100644 --- a/src/components/NewsPanel.ts +++ b/src/components/NewsPanel.ts @@ -4,7 +4,11 @@ import type { NewsItem, ClusteredEvent, DeviationLevel, RelatedAsset, RelatedAss import { THREAT_PRIORITY } from '@/services/threat-classifier'; import { formatTime, getCSSColor } from '@/utils'; import { escapeHtml, sanitizeUrl, unsafeRawHtml } from '@/utils/sanitize'; -import { analysisWorker, enrichWithVelocityML, getClusterAssetContext, MAX_DISTANCE_KM, activityTracker, generateSummary, translateText } from '@/services'; +import { analysisWorker } from '@/services/analysis-worker'; +import { activityTracker } from '@/services/activity-tracker'; +import { getClusterAssetContext, MAX_DISTANCE_KM } from '@/services/related-assets'; +import { generateSummary, translateText } from '@/services/summarization'; +import { enrichWithVelocityML } from '@/services/velocity'; import { getSourcePropagandaRisk, getSourceTier, getSourceType } from '@/config/feeds'; import { SITE_VARIANT } from '@/config'; import { t, getCurrentLanguage } from '@/services/i18n'; diff --git a/src/components/PipelineStatusPanel.ts b/src/components/PipelineStatusPanel.ts index 4311f5328d..976e89dbfc 100644 --- a/src/components/PipelineStatusPanel.ts +++ b/src/components/PipelineStatusPanel.ts @@ -1,6 +1,6 @@ import { Panel } from './Panel'; import { escapeHtml, sanitizeUrl, unsafeRawHtml } from '@/utils/sanitize'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { attributionFooterHtml, ATTRIBUTION_FOOTER_CSS } from '@/utils/attribution-footer'; import { SupplyChainServiceClient } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; import type { @@ -22,9 +22,9 @@ import { type RawPipelineRegistry, } from '@/shared/pipeline-registry-store'; -const client = new SupplyChainServiceClient(getRpcBaseUrl(), { - fetch: (...args: Parameters) => globalThis.fetch(...args), -}); +const getSupplyChainClient = createLazyClient(() => new SupplyChainServiceClient(getRpcBaseUrl(), { + fetch: rpcFetch, +})); // Shape of the raw Redis registry hydrated by bootstrap. This mirrors // scripts/data/pipelines-{gas,oil}.json verbatim — the seeder does NOT @@ -224,7 +224,7 @@ export class PipelineStatusPanel extends Panel { // classifierVersion + updatedAt into the shared store so the map's // next re-render uses the newer stamps too — prevents map/panel // drift during rollouts. - void client.listPipelines({ commodityType: '' }).then(live => { + void getSupplyChainClient().listPipelines({ commodityType: '' }).then(live => { if (!this.element?.isConnected || !live?.pipelines?.length) return; this.data = live; this.render(); @@ -242,7 +242,7 @@ export class PipelineStatusPanel extends Panel { return; } - const live = await client.listPipelines({ commodityType: '' }); + const live = await getSupplyChainClient().listPipelines({ commodityType: '' }); if (!this.element?.isConnected) return; if (live.upstreamUnavailable || !live.pipelines?.length) { this.showError('Pipeline registry unavailable', () => void this.fetchData()); @@ -272,8 +272,8 @@ export class PipelineStatusPanel extends Panel { this.render(); try { const [d, events] = await Promise.all([ - client.getPipelineDetail({ pipelineId }), - client.listEnergyDisruptions({ assetId: pipelineId, assetType: 'pipeline', ongoingOnly: false }), + getSupplyChainClient().getPipelineDetail({ pipelineId }), + getSupplyChainClient().listEnergyDisruptions({ assetId: pipelineId, assetType: 'pipeline', ongoingOnly: false }), ]); if (!this.element?.isConnected || this.selectedId !== pipelineId) return; this.detail = d; diff --git a/src/components/RegionalIntelligenceBoard.ts b/src/components/RegionalIntelligenceBoard.ts index b964ee66bb..ceb7876371 100644 --- a/src/components/RegionalIntelligenceBoard.ts +++ b/src/components/RegionalIntelligenceBoard.ts @@ -1,5 +1,5 @@ import { Panel } from './Panel'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl } from '@/services/rpc-client'; import { premiumFetch } from '@/services/premium-fetch'; import { IS_EMBEDDED_PREVIEW } from '@/utils/embedded-preview'; import { hasPremiumAccess } from '@/services/panel-gating'; @@ -13,7 +13,7 @@ import { BOARD_REGIONS, DEFAULT_REGION_ID, buildBoardHtml, buildRegimeHistoryBlo // get-regional-snapshot + get-regime-history + get-regional-brief are // premium-gated. Plain globalThis.fetch skips Clerk/tester/api-key injection // and returns 401 for pro users — premiumFetch is the correct fetcher here. -const client = new IntelligenceServiceClient(getRpcBaseUrl(), { fetch: premiumFetch }); +const getIntelligenceClient = createLazyClient(() => new IntelligenceServiceClient(getRpcBaseUrl(), { fetch: premiumFetch })); /** * RegionalIntelligenceBoard — premium panel rendering a canonical @@ -181,7 +181,7 @@ export class RegionalIntelligenceBoard extends Panel { let actualRegion = myRegion; let fallbackFrom: string | null = null; try { - const resp = await client.getRegionalSnapshot({ regionId: myRegion }); + const resp = await getIntelligenceClient().getRegionalSnapshot({ regionId: myRegion }); if (!isLatestSequence(mySequence, this.latestSequence)) return; snapshot = resp.snapshot; } catch (err) { @@ -214,7 +214,7 @@ export class RegionalIntelligenceBoard extends Panel { }; const timer = setTimeout(() => settle(null), FALLBACK_TIMEOUT_MS); for (const id of fallbackIds) { - client.getRegionalSnapshot({ regionId: id }) + getIntelligenceClient().getRegionalSnapshot({ regionId: id }) .then(resp => { if (resp.snapshot?.regionId) { clearTimeout(timer); @@ -257,8 +257,8 @@ export class RegionalIntelligenceBoard extends Panel { // Phase 2: fire history + brief RPCs in background. Use actualRegion so // the enrichments match the rendered snapshot when we fell back. - const historyPromise = client.getRegimeHistory({ regionId: actualRegion, limit: 20 }).catch(() => null); - const briefPromise = client.getRegionalBrief({ regionId: actualRegion }).catch(() => null); + const historyPromise = getIntelligenceClient().getRegimeHistory({ regionId: actualRegion, limit: 20 }).catch(() => null); + const briefPromise = getIntelligenceClient().getRegionalBrief({ regionId: actualRegion }).catch(() => null); Promise.allSettled([historyPromise, briefPromise]).then(([hResult, bResult]) => { if (!isLatestSequence(mySequence, this.latestSequence)) return; diff --git a/src/components/StorageFacilityMapPanel.ts b/src/components/StorageFacilityMapPanel.ts index 213668825c..733d726aec 100644 --- a/src/components/StorageFacilityMapPanel.ts +++ b/src/components/StorageFacilityMapPanel.ts @@ -1,6 +1,6 @@ import { Panel } from './Panel'; import { escapeHtml, sanitizeUrl, unsafeRawHtml } from '@/utils/sanitize'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { attributionFooterHtml, ATTRIBUTION_FOOTER_CSS } from '@/utils/attribution-footer'; import { SupplyChainServiceClient } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; import type { @@ -18,9 +18,9 @@ import { type RawStorageFacilityRegistry, } from '@/shared/storage-facility-registry-store'; -const client = new SupplyChainServiceClient(getRpcBaseUrl(), { - fetch: (...args: Parameters) => globalThis.fetch(...args), -}); +const getSupplyChainClient = createLazyClient(() => new SupplyChainServiceClient(getRpcBaseUrl(), { + fetch: rpcFetch, +})); const BADGE_COLOR: Record = { operational: '#2ecc71', @@ -212,7 +212,7 @@ export class StorageFacilityMapPanel extends Panel { // Background RPC refresh for post-deploy classifier-version bumps. // When it lands, mirror the fresh shape into the store so the // map's next re-render uses the newer stamps too. - void client.listStorageFacilities({ facilityType: '' }).then(live => { + void getSupplyChainClient().listStorageFacilities({ facilityType: '' }).then(live => { if (!this.element?.isConnected || !live?.facilities?.length) return; this.data = live; this.render(); @@ -227,7 +227,7 @@ export class StorageFacilityMapPanel extends Panel { return; } - const live = await client.listStorageFacilities({ facilityType: '' }); + const live = await getSupplyChainClient().listStorageFacilities({ facilityType: '' }); if (!this.element?.isConnected) return; if (live.upstreamUnavailable || !live.facilities?.length) { this.showError('Storage registry unavailable', () => void this.fetchData()); @@ -256,8 +256,8 @@ export class StorageFacilityMapPanel extends Panel { this.render(); try { const [d, events] = await Promise.all([ - client.getStorageFacilityDetail({ facilityId }), - client.listEnergyDisruptions({ assetId: facilityId, assetType: 'storage', ongoingOnly: false }), + getSupplyChainClient().getStorageFacilityDetail({ facilityId }), + getSupplyChainClient().listEnergyDisruptions({ assetId: facilityId, assetType: 'storage', ongoingOnly: false }), ]); if (!this.element?.isConnected || this.selectedId !== facilityId) return; this.detail = d; diff --git a/src/components/TechEventsPanel.ts b/src/components/TechEventsPanel.ts index ffb2413f2e..94a2908587 100644 --- a/src/components/TechEventsPanel.ts +++ b/src/components/TechEventsPanel.ts @@ -1,5 +1,5 @@ import { Panel } from './Panel'; -import { getRpcBaseUrl } from '@/services/rpc-client'; +import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { t } from '@/services/i18n'; import { sanitizeUrl } from '@/utils/sanitize'; import { h, replaceChildren } from '@/utils/dom-utils'; @@ -12,7 +12,7 @@ import { getHydratedData } from '@/services/bootstrap'; type ViewMode = 'upcoming' | 'conferences' | 'earnings' | 'all'; -const researchClient = new ResearchServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); +const getResearchClient = createLazyClient(() => new ResearchServiceClient(getRpcBaseUrl(), { fetch: rpcFetch })); export class TechEventsPanel extends Panel { private viewMode: ViewMode = 'upcoming'; @@ -44,7 +44,7 @@ export class TechEventsPanel extends Panel { // Fallback: single RPC call — listTechEvents reads from Redis seed, // retrying on empty returns the same stale result each time. try { - const data = await researchClient.listTechEvents({ + const data = await getResearchClient().listTechEvents({ type: '', mappable: false, days: 180, diff --git a/src/services/rpc-client.ts b/src/services/rpc-client.ts index 15213b33b6..61343f3628 100644 --- a/src/services/rpc-client.ts +++ b/src/services/rpc-client.ts @@ -5,3 +5,15 @@ export function getRpcBaseUrl(): string { // latest sidecar port per request instead of freezing a stale module-load port. return getConfiguredWebApiBaseUrl() || ''; } + +export function rpcFetch(...args: Parameters): ReturnType { + return globalThis.fetch(...args); +} + +export function createLazyClient(factory: () => T): () => T { + let client: T | undefined; + return () => { + client ??= factory(); + return client; + }; +} diff --git a/tests/chunk-assignment.test.mjs b/tests/chunk-assignment.test.mjs index e14edca3e0..66840635dd 100644 --- a/tests/chunk-assignment.test.mjs +++ b/tests/chunk-assignment.test.mjs @@ -1,13 +1,15 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const viteConfigSource = readFileSync(resolve(__dirname, '../vite.config.ts'), 'utf-8'); +const repoRoot = resolve(__dirname, '..'); +const componentDir = resolve(repoRoot, 'src/components'); +const viteConfigSource = readFileSync(resolve(repoRoot, 'vite.config.ts'), 'utf-8'); const manualChunksMatch = viteConfigSource.match( - /manualChunks\(id\)\s*\{([\s\S]*?)\n\s*\/\/ Give lazy-loaded locale chunks/, + /manualChunks\(id(?:,\s*\{[^}]+\})?\)\s*\{([\s\S]*?)\n\s*\/\/ Give lazy-loaded locale chunks/, ); assert.ok( manualChunksMatch, @@ -15,36 +17,118 @@ assert.ok( ); const manualChunksSource = manualChunksMatch[1]; +function extractObjectEntries(objectName) { + const match = viteConfigSource.match(new RegExp(`const ${objectName}:[\\s\\S]*?= \\{([\\s\\S]*?)\\n\\};`)); + assert.ok(match, `Could not locate ${objectName} in vite.config.ts.`); + return new Map([...match[1].matchAll(/^\s*([A-Za-z0-9_]+):\s*'([^']+)'/gm)].map(([, key, value]) => [key, value])); +} + +function extractArrayEntries(arrayName) { + const match = viteConfigSource.match(new RegExp(`const ${arrayName} = \\[([\\s\\S]*?)\\] as const;`)); + assert.ok(match, `Could not locate ${arrayName} in vite.config.ts.`); + return [...match[1].matchAll(/'([^']+)'/g)].map(([, value]) => value); +} + +function panelKeyForFile(fileName) { + const baseName = fileName.replace(/\.ts$/, ''); + if (baseName === 'Panel') return 'Panel'; + if (baseName.endsWith('Panel')) return baseName.slice(0, -'Panel'.length); + if (baseName === 'CountryBriefPage' || baseName === 'RegionalIntelligenceBoard') return baseName; + return null; +} + +function lineNumberForOffset(source, offset) { + return source.slice(0, offset).split('\n').length; +} + +const panelCluster = extractObjectEntries('PANEL_CLUSTER'); +const panelChunkNames = new Set(extractArrayEntries('PANEL_CHUNK_NAMES')); + describe('panel chunk assignment guardrails', () => { - it('keeps panel component modules in one chunk until TDZ-prone singletons are removed', () => { + it('assigns every panel component file to a documented panel cluster', () => { + const panelFiles = readdirSync(componentDir) + .filter(file => file.endsWith('.ts')) + .map(file => ({ file, key: panelKeyForFile(file) })) + .filter(({ key }) => key !== null && key !== 'Panel'); + + const missing = panelFiles + .filter(({ key }) => !panelCluster.has(key)) + .map(({ file, key }) => `${file} (${key})`); + assert.deepEqual(missing, [], 'Every panel component file must be assigned in PANEL_CLUSTER.'); + + const stale = [...panelCluster.keys()] + .filter((key) => key !== 'CountryBriefPage' && key !== 'RegionalIntelligenceBoard') + .filter((key) => !existsSync(resolve(componentDir, `${key}Panel.ts`))); + assert.deepEqual(stale, [], 'PANEL_CLUSTER contains entries for missing panel files.'); + + const invalidChunks = [...panelCluster.entries()] + .filter(([, chunk]) => !panelChunkNames.has(chunk)) + .map(([key, chunk]) => `${key}: ${chunk}`); + assert.deepEqual(invalidChunks, [], 'PANEL_CLUSTER must only use PANEL_CHUNK_NAMES entries.'); + }); + + it('wires panel modules through PANEL_CLUSTER instead of the monolithic panels chunk', () => { assert.match( manualChunksSource, - /id\.includes\('\/src\/components\/'\)\s*&&\s*id\.endsWith\('Panel\.ts'\)[\s\S]*?return\s+'panels'/, - 'Panel component modules must stay in the single panels chunk until top-level service clients are lazy-initialized.', + /\bpanelChunkForComponentId\(id\)/, + 'manualChunks must classify panel files through panelChunkForComponentId().', + ); + assert.match( + viteConfigSource, + /\bPANEL_CLUSTER\[panelKey\]/, + 'panelChunkForComponentId() must route panel files through PANEL_CLUSTER.', ); - }); - - it('does not re-enable variant panel chunks that create circular ESM evaluation', () => { assert.doesNotMatch( manualChunksSource, - /return\s+'(?:core|full|finance|happy|tech)-panels'/, - 'Variant panel chunks can reintroduce core-panels -> full-panels -> core-panels cycles and startup TDZ crashes.', + /return\s+'panels'/, + 'The monolithic panels chunk regresses cache invalidation and eager boot downloads.', ); }); - it('does not retain unused staged panel-cluster config', () => { - assert.doesNotMatch( + it('keeps panel clusters out of the entry HTML modulepreload list', () => { + assert.match( viteConfigSource, - /\bPANEL_CLUSTER\b/, - 'Unused staged panel-cluster maps add maintenance surface and can be mistaken for active build routing.', + /LAZY_HTML_PRELOAD_CHUNKS = \[[^\]]*\.\.\.PANEL_CHUNK_NAMES/s, + 'Panel chunk names must feed the HTML preload filter.', ); }); - it('does not wire domain panel chunks into manualChunks', () => { + it('keeps entry-shared support code out of lazy panel-support', () => { + assert.match( + viteConfigSource, + /function hasStaticEntryImporter\(/, + 'manualChunks must distinguish static entry dependencies from lazy panel-only support.', + ); + assert.match( + manualChunksSource, + /hasStaticEntryImporter\(id,\s*getModuleInfo\)\s*\?\s*'app-shared'\s*:\s*'panel-support'/, + 'Support modules shared by the static app shell must use app-shared, not lazy panel-support.', + ); + }); + + it('does not re-enable the old variant panel chunks', () => { assert.doesNotMatch( manualChunksSource, - /return\s+['"`]panels-[a-z-]+['"`]/, - 'Domain panel chunks can reintroduce startup TDZ crashes while panel modules still have top-level service clients.', + /return\s+'(?:core|full|finance|happy|tech)-panels'/, + 'Variant panel chunks previously created cross-chunk ESM evaluation cycles.', + ); + }); + + it('keeps generated service clients lazy in component modules', () => { + const offenders = []; + for (const file of readdirSync(componentDir).filter(file => file.endsWith('.ts'))) { + const source = readFileSync(resolve(componentDir, file), 'utf-8'); + for (const match of source.matchAll(/^const\s+[A-Za-z0-9_]+(?:Client)?\s*=\s*new\s+[A-Za-z0-9_]+ServiceClient\b/gm)) { + offenders.push(`${file}:${lineNumberForOffset(source, match.index ?? 0)}`); + } + for (const match of source.matchAll(/^\s*(?:private\s+|public\s+|protected\s+)?static\s+[^=\n]+=\s*new\s+[A-Za-z0-9_]+ServiceClient\b/gm)) { + offenders.push(`${file}:${lineNumberForOffset(source, match.index ?? 0)}`); + } + } + assert.deepEqual( + offenders, + [], + 'Generated ServiceClient instances in component modules must be created through lazy getters, not at module evaluation.', ); }); }); diff --git a/vite.config.ts b/vite.config.ts index 3020230453..7ac00e1600 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,18 @@ import { VARIANT_META, type VariantMeta } from './src/config/variant-meta'; const brotliCompressAsync = promisify(brotliCompress); const BROTLI_EXTENSIONS = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.json', '.txt', '.xml', '.wasm']); +const PANEL_CHUNK_NAMES = [ + 'panels-core', + 'panels-markets', + 'panels-energy', + 'panels-defense', + 'panels-news', + 'panels-economy', + 'panels-intel', + 'panels-risk', +] as const; +type PanelChunkName = typeof PANEL_CHUNK_NAMES[number]; + // Single source of truth for chunk names that must NOT be hoisted into the // entry HTML's modulepreload list. Used by both `manualChunks` (return values // must literally match these strings) and `modulePreload.resolveDependencies` @@ -22,11 +34,209 @@ const BROTLI_EXTENSIONS = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.jso // re-eagerises the WebGL stack without any build-time error. // - maplibre, deck-stack: heavy WebGL deps, only reachable via MapContainer // - MapContainer: the dynamic-import target itself -const LAZY_HTML_PRELOAD_CHUNKS = ['maplibre', 'deck-stack', 'MapContainer'] as const; +// - panel-support: shared base/helper modules for lazy panel chunks +// - panels-*: lazy panel clusters; keep them out of the entry HTML preload +const LAZY_HTML_PRELOAD_CHUNKS = ['maplibre', 'deck-stack', 'MapContainer', 'panel-support', ...PANEL_CHUNK_NAMES] as const; const LAZY_HTML_PRELOAD_RE = new RegExp( `/(${LAZY_HTML_PRELOAD_CHUNKS.join('|')})-[A-Za-z0-9_-]+\\.js$`, ); +const EXTRA_PANEL_COMPONENT_FILES = new Set(['CountryBriefPage', 'RegionalIntelligenceBoard']); + +function panelKeyForComponentFile(fileName: string): string | null { + if (fileName === 'Panel') return fileName; + if (fileName.endsWith('Panel')) return fileName.slice(0, -'Panel'.length); + if (EXTRA_PANEL_COMPONENT_FILES.has(fileName)) return fileName; + return null; +} + +function panelChunkForComponentId(id: string): PanelChunkName | 'panel-support' | null { + if (!id.includes('/src/components/') || !id.endsWith('.ts')) return null; + const match = id.match(/\/([^/]+)\.ts$/); + if (!match) return null; + const panelKey = panelKeyForComponentFile(match[1]); + if (panelKey === 'Panel') return 'panel-support'; + if (!panelKey) return null; + const chunkName = PANEL_CLUSTER[panelKey]; + if (chunkName) return chunkName; + throw new Error(`[manualChunks] Unassigned panel component ${match[1]}. Add ${panelKey} to PANEL_CLUSTER in vite.config.ts.`); +} + +function isPanelSupportCandidate(id: string): boolean { + return ( + id.includes('/src/components/') + || id.includes('/src/services/') + || id.includes('/src/utils/') + || id.includes('/src/config/') + || id.includes('/src/generated/') + || id.includes('/src/shared/') + ); +} + +type ChunkModuleInfo = { importers: string[]; dynamicImporters: string[]; isEntry?: boolean }; +type GetChunkModuleInfo = (moduleId: string) => ChunkModuleInfo | null; + +function hasPanelComponentImporter( + id: string, + getModuleInfo: GetChunkModuleInfo, + seen = new Set(), +): boolean { + if (seen.has(id)) return false; + seen.add(id); + const info = getModuleInfo(id); + if (!info) return false; + for (const importer of [...info.importers, ...info.dynamicImporters]) { + if (panelChunkForComponentId(importer)) return true; + if (isPanelSupportCandidate(importer) && hasPanelComponentImporter(importer, getModuleInfo, seen)) { + return true; + } + } + return false; +} + +function hasStaticEntryImporter( + id: string, + getModuleInfo: GetChunkModuleInfo, + seen = new Set(), +): boolean { + if (seen.has(id)) return false; + seen.add(id); + const info = getModuleInfo(id); + if (!info) return false; + if (info.isEntry) return true; + for (const importer of info.importers) { + if (hasStaticEntryImporter(importer, getModuleInfo, seen)) return true; + } + return false; +} + +// Panel-cluster manualChunks map. The keys are component file basenames without +// the trailing "Panel" suffix. These chunks are lazy dynamic-import targets, so +// editing one panel no longer invalidates the entire panel surface. +const PANEL_CLUSTER: Record = { + // Boot shell / base panel infrastructure + Status: 'panels-core', + + // Markets / equities / crypto positioning + AAIISentiment: 'panels-markets', + CotPositioning: 'panels-markets', + ETFFlows: 'panels-markets', + EarningsCalendar: 'panels-markets', + EconomicCalendar: 'panels-markets', + FearGreed: 'panels-markets', + GoldIntelligence: 'panels-markets', + LiquidityShifts: 'panels-markets', + MacroSignals: 'panels-markets', + Market: 'panels-markets', + MarketBreadth: 'panels-markets', + MarketImplications: 'panels-markets', + Positioning: 'panels-markets', + Stablecoin: 'panels-markets', + StockAnalysis: 'panels-markets', + StockBacktest: 'panels-markets', + WsbTickerScanner: 'panels-markets', + YieldCurve: 'panels-markets', + + // Energy / commodities / supply infrastructure + ChokepointStrip: 'panels-energy', + EnergyComplex: 'panels-energy', + EnergyCrisis: 'panels-energy', + EnergyDisruptions: 'panels-energy', + EnergyRiskOverview: 'panels-energy', + FuelPrices: 'panels-energy', + FuelShortage: 'panels-energy', + Hormuz: 'panels-energy', + OilInventories: 'panels-energy', + PipelineStatus: 'panels-energy', + RenewableEnergy: 'panels-energy', + StorageFacilityMap: 'panels-energy', + + // Defense / military / aviation + AirlineIntel: 'panels-defense', + DefensePatents: 'panels-defense', + OrefSirens: 'panels-defense', + StrategicPosture: 'panels-defense', + StrategicRisk: 'panels-defense', + ThermalEscalation: 'panels-defense', + UcdpEvents: 'panels-defense', + + // News / feeds / briefs + BreakthroughsTicker: 'panels-news', + ClimateNews: 'panels-news', + DailyMarketBrief: 'panels-news', + GdeltIntel: 'panels-news', + GoodThingsDigest: 'panels-news', + LatestBrief: 'panels-news', + LiveNews: 'panels-news', + News: 'panels-news', + PositiveNewsFeed: 'panels-news', + TelegramIntel: 'panels-news', + + // Macro / prices / trade + BigMac: 'panels-economy', + ConsumerPrices: 'panels-economy', + Economic: 'panels-economy', + FSI: 'panels-economy', + FaoFoodPriceIndex: 'panels-economy', + GroceryBasket: 'panels-economy', + GulfEconomies: 'panels-economy', + Investments: 'panels-economy', + MacroTiles: 'panels-economy', + NationalDebt: 'panels-economy', + SanctionsPressure: 'panels-economy', + SupplyChain: 'panels-economy', + TradePolicy: 'panels-economy', + + // Country briefs / signals / monitors / agent surfaces. Keep the + // CorrelationPanel base and all correlation consumers together. + Cascade: 'panels-intel', + ChatAnalyst: 'panels-intel', + CII: 'panels-intel', + Correlation: 'panels-intel', + CountryBrief: 'panels-intel', + CountryBriefPage: 'panels-intel', + CountryDeepDive: 'panels-intel', + CrossSourceSignals: 'panels-intel', + CustomWidget: 'panels-intel', + Deduction: 'panels-intel', + DisasterCorrelation: 'panels-intel', + EconomicCorrelation: 'panels-intel', + EscalationCorrelation: 'panels-intel', + Forecast: 'panels-intel', + HeroSpotlight: 'panels-intel', + Insights: 'panels-intel', + LiveWebcams: 'panels-intel', + McpData: 'panels-intel', + MilitaryCorrelation: 'panels-intel', + Monitor: 'panels-intel', + PinnedWebcams: 'panels-intel', + Prediction: 'panels-intel', + ProgressCharts: 'panels-intel', + RegionalIntelligenceBoard: 'panels-intel', + Regulation: 'panels-intel', + + // Disasters / climate / connectivity / society + ClimateAnomaly: 'panels-risk', + Counters: 'panels-risk', + DiseaseOutbreaks: 'panels-risk', + Displacement: 'panels-risk', + GeoHubs: 'panels-risk', + Giving: 'panels-risk', + InternetDisruptions: 'panels-risk', + PopulationExposure: 'panels-risk', + RadiationWatch: 'panels-risk', + RuntimeConfig: 'panels-risk', + SatelliteFires: 'panels-risk', + SecurityAdvisories: 'panels-risk', + ServiceStatus: 'panels-risk', + SocialVelocity: 'panels-risk', + SpeciesComeback: 'panels-risk', + TechEvents: 'panels-risk', + TechHubs: 'panels-risk', + TechReadiness: 'panels-risk', + WorldClock: 'panels-risk', +}; + function brotliPrecompressPlugin(): Plugin { return { name: 'brotli-precompress', @@ -907,7 +1117,7 @@ export default defineConfig(({ mode }) => { mcpGrant: resolve(__dirname, 'mcp-grant.html'), }, output: { - manualChunks(id) { + manualChunks(id, { getModuleInfo }) { if (id.includes('node_modules')) { if (id.includes('/onnxruntime-web/')) { return 'onnxruntime'; @@ -944,13 +1154,12 @@ export default defineConfig(({ mode }) => { return 'sentry'; } } - if (id.includes('/src/components/') && id.endsWith('Panel.ts')) { - // Keep panel modules in one chunk for now. Splitting them by - // variant or domain exposes ESM TDZ crashes because several - // panel modules still create generated service clients at the - // top level. Revisit finer panel chunking only after those - // imports are lazy-initialized. - return 'panels'; + if (id.includes('/src/components/') && id.endsWith('.ts')) { + const panelChunk = panelChunkForComponentId(id); + if (panelChunk) return panelChunk; + } + if (isPanelSupportCandidate(id) && hasPanelComponentImporter(id, getModuleInfo)) { + return hasStaticEntryImporter(id, getModuleInfo) ? 'app-shared' : 'panel-support'; } // Give lazy-loaded locale chunks a recognizable prefix so the // service worker can exclude them from precache (en.json is