From 47e106684d505e03be2ae1b86f005cea375c8e28 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 30 Jul 2026 04:35:57 +0200 Subject: [PATCH] perf(webui): lazily load module descriptors --- README.md | 4 + docs/DOCUMENTATION_MAP.md | 1 + docs/MODULE_ARCHITECTURE.md | 14 +- docs/WEBUI_BUNDLE_BUDGETS.md | 71 ++++++++ webui/bundle-budget.json | 10 ++ webui/package.json | 3 +- webui/scripts/check-bundle-budget.mjs | 173 ++++++++++++++++++++ webui/scripts/test-module-permutations.mjs | 22 ++- webui/src/App.tsx | 119 ++++++++++---- webui/src/components/ModuleLoadBoundary.tsx | 76 +++++++++ webui/src/platform/modules.ts | 119 ++++++++++---- webui/src/styles/components.css | 23 +++ webui/src/vite-env.d.ts | 9 +- webui/vite.config.ts | 44 +++-- 14 files changed, 604 insertions(+), 84 deletions(-) create mode 100644 docs/WEBUI_BUNDLE_BUDGETS.md create mode 100644 webui/bundle-budget.json create mode 100644 webui/scripts/check-bundle-budget.mjs create mode 100644 webui/src/components/ModuleLoadBoundary.tsx diff --git a/README.md b/README.md index 15631d7..64d7f2e 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md). +Production builds lazy-load enabled module descriptors and enforce initial and +asynchronous JavaScript budgets. See +[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md). + ## Module contract Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute: diff --git a/docs/DOCUMENTATION_MAP.md b/docs/DOCUMENTATION_MAP.md index 3ec3d0c..fc83b5b 100644 --- a/docs/DOCUMENTATION_MAP.md +++ b/docs/DOCUMENTATION_MAP.md @@ -26,6 +26,7 @@ operator, and roadmap pages. | Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. | | Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. | | Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. | +| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. | ## Product And Module Planning diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index 6d5df01..2487957 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -624,11 +624,18 @@ Uninstall remains non-destructive unless the operator explicitly requests ## WebUI Contract -A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions. +A WebUI module exports a `PlatformWebModule` from its package. The object +contributes local/fallback metadata and route render functions. The package +must ship `src/module.ts` with the default contribution export: Core's Vite +host imports that descriptor directly after the backend reports the module as +enabled. This keeps package-root re-exports from pulling page implementations +into the initial shell. Example: ```ts +const FilesPage = lazy(() => import("./features/files/FilesPage")); + export const filesModule: PlatformWebModule = { id: "files", label: "Files", @@ -643,6 +650,11 @@ export const filesModule: PlatformWebModule = { }; ``` +Route pages and substantial panels must use stable lazy imports. Core supplies +the shared loading and retryable error state around route rendering. The +initial static import closure and largest asynchronous chunk are enforced by +the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md). + WebUI modules receive only the core route context: - `settings` diff --git a/docs/WEBUI_BUNDLE_BUDGETS.md b/docs/WEBUI_BUNDLE_BUDGETS.md new file mode 100644 index 0000000..1099edd --- /dev/null +++ b/docs/WEBUI_BUNDLE_BUDGETS.md @@ -0,0 +1,71 @@ +# WebUI Loading And Bundle Budgets + +The Core WebUI host owns the loading boundary for installed module packages. +Vite discovers configured packages at build time, but emits an asynchronous +loader for each package's `src/module.ts` contribution descriptor. At runtime, +Core imports only descriptors whose backend manifests are enabled and identify +the matching `frontend.package_name`. + +The direct descriptor entry is intentional. A package root may re-export pages +for consumers; importing that barrel as module wiring can cause those pages to +be evaluated before navigation. Route pages and substantial panels should use +`React.lazy`, and Core wraps routes in the shared loading/error boundary. + +## Enforced Budgets + +`webui/bundle-budget.json` contains the production limits: + +| Measurement | Raw limit | Gzip limit | +| --- | ---: | ---: | +| Initial JavaScript static import closure | 512 KiB | 160 KiB | +| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB | + +`npm run build` writes a Vite manifest, measures the entry and its recursive +static imports, writes `dist/bundle-metrics.json`, and fails when either budget +is exceeded. `npm run test:module-permutations` applies the same gate to every +permutation and records the collected results in +`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added +to the step summary. + +Budgets are limits, not targets. A change that approaches a limit should add a +new lazy boundary or remove unnecessary entry code instead of raising the +limit without measurement and review. + +## 2026-07-30 Baseline + +Measurements use the same full-product source tree and Node 22 runtime. The +post-change build additionally includes the Search module in the default and +full-product sets. + +| Initial-load measurement | Before | After | Reduction | +| --- | ---: | ---: | ---: | +| JavaScript assets in initial static closure | 1 | 1 | 0% | +| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% | +| Gzip level 9 | 364,767 B | 141,725 B | 61.1% | +| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% | +| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% | +| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% | + +The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry +source 30 times with a randomized source marker. It is useful for a controlled +before/after comparison, but is not enforced in CI because absolute timings +vary across runners. Transfer budgets use deterministic raw and gzip byte +counts. + +The first budgeted full-product build reported: + +- initial JavaScript: 453,769 B raw / 141,725 B gzip; +- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip. + +## Verification + +```bash +cd /mnt/DATA/git/govoplan-core/webui +npm run build +npm run check:bundle-budget +npm run test:module-permutations +``` + +The build gate also catches accidental eager imports: a page pulled into the +entry closure consumes the initial budget, while an oversized page or module +descriptor consumes the asynchronous chunk budget. diff --git a/webui/bundle-budget.json b/webui/bundle-budget.json new file mode 100644 index 0000000..02a9d30 --- /dev/null +++ b/webui/bundle-budget.json @@ -0,0 +1,10 @@ +{ + "initialJs": { + "rawBytes": 524288, + "gzipBytes": 163840 + }, + "asyncChunk": { + "rawBytes": 393216, + "gzipBytes": 112640 + } +} diff --git a/webui/package.json b/webui/package.json index de1f37d..6eea570 100644 --- a/webui/package.json +++ b/webui/package.json @@ -23,7 +23,8 @@ "scripts": { "dev": "vite --host 127.0.0.1 --port 5173", "prebuild": "npm run audit:i18n-structural", - "build": "tsc && vite build", + "build": "tsc && vite build && node scripts/check-bundle-budget.mjs", + "check:bundle-budget": "node scripts/check-bundle-budget.mjs", "preview": "vite preview --host 127.0.0.1 --port 4173", "audit:i18n-structural": "node scripts/audit-i18n-structural.mjs", "test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs", diff --git a/webui/scripts/check-bundle-budget.mjs b/webui/scripts/check-bundle-budget.mjs new file mode 100644 index 0000000..8c6c47e --- /dev/null +++ b/webui/scripts/check-bundle-budget.mjs @@ -0,0 +1,173 @@ +import { appendFileSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { gzipSync } from "node:zlib"; + +const webuiRoot = new URL("..", import.meta.url); +const distRoot = new URL("./dist/", webuiRoot); +const manifestPath = new URL("./.vite/manifest.json", distRoot); +const budgetPath = new URL("./bundle-budget.json", webuiRoot); +const metricsPath = new URL("./bundle-metrics.json", distRoot); +const buildName = process.env.GOVOPLAN_WEBUI_BUILD_NAME || "default"; + +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +const budgets = JSON.parse(readFileSync(budgetPath, "utf8")); +const entries = Object.entries(manifest); +const entryKeys = entries + .filter(([, chunk]) => chunk.isEntry && isJavaScript(chunk.file)) + .map(([key]) => key); + +if (entryKeys.length === 0) { + throw new Error("Vite manifest has no JavaScript entry"); +} + +const initialKeys = new Set(); +for (const entryKey of entryKeys) collectStaticImports(entryKey, initialKeys); +const initialFiles = uniqueChunks( + [...initialKeys].map((key) => [key, manifest[key]]).filter(([, chunk]) => chunk) +); +const initialFileNames = new Set(initialFiles.map(([, chunk]) => chunk.file)); +const moduleDescriptorChunks = entries.filter(([key, chunk]) => ( + isJavaScript(chunk.file) && + chunk.isDynamicEntry && + ( + key.startsWith("virtual:govoplan-installed-module/") || + chunk.name?.startsWith("_40govoplan_2F") + ) +)); +const eagerModuleDescriptors = moduleDescriptorChunks.filter( + ([, chunk]) => initialFileNames.has(chunk.file) +); +const asyncFiles = uniqueChunks( + entries.filter(([, chunk]) => ( + isJavaScript(chunk.file) && !initialFileNames.has(chunk.file) + )) +); + +const initial = summarizeChunks(initialFiles); +const measuredAsyncChunks = asyncFiles.map(measureChunk); +const largestAsync = measuredAsyncChunks.sort( + (left, right) => right.rawBytes - left.rawBytes +)[0] ?? null; +const failures = [ + ...eagerModuleDescriptors.map(([, chunk]) => ( + `Installed module descriptor ${chunk.file} is part of the initial static closure` + )), + ...budgetFailures("Initial JavaScript", initial, budgets.initialJs), + ...(largestAsync + ? budgetFailures("Largest async chunk", largestAsync, budgets.asyncChunk) + : ["No asynchronous JavaScript chunk was produced"]) +]; + +const metrics = { + schemaVersion: 1, + buildName, + measuredAt: new Date().toISOString(), + initial, + largestAsync, + moduleDescriptorCount: moduleDescriptorChunks.length, + eagerModuleDescriptorCount: eagerModuleDescriptors.length, + budgets, + passed: failures.length === 0, + failures +}; +writeFileSync(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`); + +console.log(`\nWebUI bundle budget (${buildName})`); +console.log(formatMetric("Initial JavaScript", initial, budgets.initialJs)); +console.log(formatMetric("Largest async chunk", largestAsync, budgets.asyncChunk)); +console.log(`Metrics: ${metricsPath.pathname}`); +appendCiSummary(metrics); + +if (failures.length) { + for (const failure of failures) console.error(`Bundle budget exceeded: ${failure}`); + process.exitCode = 1; +} + +function collectStaticImports(key, target) { + if (target.has(key)) return; + const chunk = manifest[key]; + if (!chunk || !isJavaScript(chunk.file)) return; + target.add(key); + for (const importedKey of chunk.imports ?? []) { + collectStaticImports(importedKey, target); + } +} + +function uniqueChunks(chunks) { + const byFile = new Map(); + for (const [key, chunk] of chunks) { + if (!chunk || byFile.has(chunk.file)) continue; + byFile.set(chunk.file, [key, chunk]); + } + return [...byFile.values()]; +} + +function summarizeChunks(chunks) { + const measured = chunks.map(measureChunk); + return { + assetCount: measured.length, + rawBytes: measured.reduce((total, chunk) => total + chunk.rawBytes, 0), + gzipBytes: measured.reduce((total, chunk) => total + chunk.gzipBytes, 0), + assets: measured + }; +} + +function measureChunk([key, chunk]) { + const assetUrl = new URL(chunk.file, distRoot); + const source = readFileSync(assetUrl); + return { + key, + file: chunk.file, + rawBytes: statSync(assetUrl).size, + gzipBytes: gzipSync(source, { level: 9 }).length + }; +} + +function budgetFailures(label, measured, budget) { + if (!measured) return [`${label} was not measured`]; + const failures = []; + for (const field of ["rawBytes", "gzipBytes"]) { + if (measured[field] > budget[field]) { + failures.push( + `${label} ${field} is ${measured[field]} bytes; budget is ${budget[field]} bytes` + ); + } + } + return failures; +} + +function formatMetric(label, measured, budget) { + if (!measured) return `${label}: not produced`; + const file = measured.file ? ` (${measured.file})` : ` (${measured.assetCount} assets)`; + return [ + `${label}${file}:`, + `${formatBytes(measured.rawBytes)} raw / ${formatBytes(budget.rawBytes)} budget,`, + `${formatBytes(measured.gzipBytes)} gzip / ${formatBytes(budget.gzipBytes)} budget` + ].join(" "); +} + +function formatBytes(value) { + return `${(value / 1024).toFixed(1)} KiB`; +} + +function isJavaScript(file) { + return typeof file === "string" && file.endsWith(".js"); +} + +function appendCiSummary(result) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) return; + const status = result.passed ? "pass" : "fail"; + const asyncFile = result.largestAsync?.file ?? "none"; + appendFileSync( + summaryPath, + [ + "", + `### WebUI bundle budget: ${result.buildName}`, + "", + "| Status | Initial raw | Initial gzip | Largest async | Async raw | Async gzip |", + "| --- | ---: | ---: | --- | ---: | ---: |", + `| ${status} | ${formatBytes(result.initial.rawBytes)} | ${formatBytes(result.initial.gzipBytes)} | \`${asyncFile}\` | ${formatBytes(result.largestAsync?.rawBytes ?? 0)} | ${formatBytes(result.largestAsync?.gzipBytes ?? 0)} |`, + "" + ].join("\n") + ); +} diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 22c9918..26d59be 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; const packageByModule = { access: "@govoplan/access-webui", @@ -19,7 +20,9 @@ const packageByModule = { ops: "@govoplan/ops-webui", policy: "@govoplan/policy-webui", postbox: "@govoplan/postbox-webui", + risk_compliance: "@govoplan/risk-compliance-webui", scheduling: "@govoplan/scheduling-webui", + search: "@govoplan/search-webui", views: "@govoplan/views-webui", workflow: "@govoplan/workflow-webui" }; @@ -52,19 +55,23 @@ const cases = [ { name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] }, { name: "scheduling-only", modules: ["scheduling"] }, { name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] }, + { name: "search-only", modules: ["search"] }, + { name: "risk-compliance-only", modules: ["risk_compliance"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] }, - { name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox"] } + { name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] } ]; const npmExec = process.env.npm_execpath; const command = npmExec ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm"); const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"]; +const collectedMetrics = []; for (const testCase of cases) { const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(","); const env = { ...process.env, - GOVOPLAN_WEBUI_MODULE_PACKAGES: packages + GOVOPLAN_WEBUI_MODULE_PACKAGES: packages, + GOVOPLAN_WEBUI_BUILD_NAME: testCase.name }; delete env.npm_config_tmp; delete env.NPM_CONFIG_TMP; @@ -81,4 +88,15 @@ for (const testCase of cases) { if (result.status !== 0) { process.exit(result.status ?? 1); } + collectedMetrics.push( + JSON.parse(readFileSync(new URL("../dist/bundle-metrics.json", import.meta.url), "utf8")) + ); } + +const metricsDirectory = new URL("../dist/", import.meta.url); +mkdirSync(metricsDirectory, { recursive: true }); +writeFileSync( + new URL("module-permutation-bundle-metrics.json", metricsDirectory), + `${JSON.stringify({ schemaVersion: 1, builds: collectedMetrics }, null, 2)}\n` +); +console.log(`\nRecorded bundle metrics for ${collectedMetrics.length} module permutations.`); diff --git a/webui/src/App.tsx b/webui/src/App.tsx index ed84b61..1b5b368 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,5 +1,5 @@ -import { Navigate, Route, Routes } from "react-router-dom"; -import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { lazy, useEffect, useMemo, useState } from "react"; import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth"; import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform"; import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client"; @@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell"; import PublicLandingPage from "./features/auth/PublicLandingPage"; import LoginModal from "./features/auth/LoginModal"; import { PermissionBoundary } from "./components/AccessBoundary"; -import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules, uiCapability } from "./platform/modules"; +import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules"; import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PlatformViewProvider } from "./platform/ViewContext"; import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views"; @@ -16,6 +16,7 @@ import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents"; import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard"; import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext"; import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary"; +import ModuleLoadBoundary from "./components/ModuleLoadBoundary"; const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); @@ -29,22 +30,24 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = { }; export default function App() { + const location = useLocation(); const [settings, setSettings] = useState(() => loadApiSettings()); const [auth, setAuth] = useState(null); const [checkingSession, setCheckingSession] = useState(true); const [platformModules, setPlatformModules] = useState(null); const [platformPublicModules, setPlatformPublicModules] = useState(null); + const [localWebModules, setLocalWebModules] = useState([]); + const [localPublicWebModules, setLocalPublicWebModules] = useState([]); const [remoteWebModules, setRemoteWebModules] = useState([]); const [remotePublicWebModules, setRemotePublicWebModules] = useState([]); + const [webModulesLoading, setWebModulesLoading] = useState(true); const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null }); const [backendReachable, setBackendReachable] = useState(true); const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null); const [reloginMessage, setReloginMessage] = useState(""); const [viewProjection, setViewProjection] = useState(null); - const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]); const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]); - const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]); const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]); const viewsRuntime = useMemo( () => uiCapability("views.runtime", webModules), @@ -112,6 +115,7 @@ export default function App() { } function handlePublicLogin(response: LoginResponse) { + setWebModulesLoading(true); updateAuth(authFromLoginResponse(response), ""); } @@ -186,6 +190,7 @@ export default function App() { const shellAuth = await fetchShellAuth(settings); if (!cancelled) { setBackendReachable(true); + setWebModulesLoading(true); setAuth(normalizeAuthInfo(shellAuth)); } } catch (error) { @@ -196,6 +201,7 @@ export default function App() { saveApiSettings(cleared); setAuth(null); setPlatformModules(null); + setWebModulesLoading(false); setRemoteWebModules([]); } } finally { @@ -218,7 +224,12 @@ export default function App() { lastRefreshAt = Date.now(); return fetchPlatformModules(settings). then((response) => {if (!cancelled) setPlatformModules(response.modules);}). - catch(() => {if (!cancelled) setPlatformModules(null);}). + catch(() => { + if (!cancelled) { + setPlatformModules(null); + setWebModulesLoading(false); + } + }). finally(() => {inFlight = false;}); } @@ -245,6 +256,70 @@ export default function App() { }; }, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]); + useEffect(() => { + let cancelled = false; + if (!auth) { + setLocalWebModules([]); + setRemoteWebModules([]); + setWebModulesLoading(false); + return () => {cancelled = true;}; + } + if (platformModules === null) { + return () => {cancelled = true;}; + } + if (platformModules.length === 0) { + setLocalWebModules([]); + setRemoteWebModules([]); + setWebModulesLoading(false); + return () => {cancelled = true;}; + } + + async function loadWebModules() { + const local = await loadInstalledWebModules(platformModules); + if (cancelled) return; + setLocalWebModules(local); + setWebModulesLoading(false); + const remote = await loadRemoteWebModules(platformModules, local); + if (!cancelled) setRemoteWebModules(remote); + } + + void loadWebModules().catch((error) => { + console.error("Failed to load platform WebUI modules", error); + if (!cancelled) { + setLocalWebModules([]); + setRemoteWebModules([]); + setWebModulesLoading(false); + } + }); + return () => {cancelled = true;}; + }, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules]); + + useEffect(() => { + let cancelled = false; + if (!platformPublicModules?.length) { + setLocalPublicWebModules([]); + setRemotePublicWebModules([]); + return () => {cancelled = true;}; + } + + async function loadPublicWebModules() { + const local = await loadInstalledPublicWebModules(platformPublicModules); + if (cancelled) return; + setLocalPublicWebModules(local); + const remote = await loadRemotePublicWebModules(platformPublicModules, local); + if (!cancelled) setRemotePublicWebModules(remote); + } + + void loadPublicWebModules().catch((error) => { + console.error("Failed to load public WebUI modules", error); + if (!cancelled) { + setLocalPublicWebModules([]); + setRemotePublicWebModules([]); + } + }); + return () => {cancelled = true;}; + }, [platformPublicModules]); + useEffect(() => { const preferences = auth?.user.ui_preferences ?? DEFAULT_UI_PREFERENCES; const root = document.documentElement; @@ -279,30 +354,6 @@ export default function App() { auth?.user.ui_preferences?.theme ]); - useEffect(() => { - let cancelled = false; - if (!auth || !platformModules?.length) { - setRemoteWebModules([]); - return () => {cancelled = true;}; - } - loadRemoteWebModules(platformModules, localWebModules). - then((modules) => {if (!cancelled) setRemoteWebModules(modules);}). - catch(() => {if (!cancelled) setRemoteWebModules([]);}); - return () => {cancelled = true;}; - }, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]); - - useEffect(() => { - let cancelled = false; - if (!platformPublicModules?.length) { - setRemotePublicWebModules([]); - return () => {cancelled = true;}; - } - loadRemotePublicWebModules(platformPublicModules, localPublicWebModules). - then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}). - catch(() => {if (!cancelled) setRemotePublicWebModules([]);}); - return () => {cancelled = true;}; - }, [platformPublicModules, localPublicWebModules]); - useEffect(() => { if (!auth) return; @@ -380,7 +431,7 @@ export default function App() { -

i18n:govoplan-core.loading_module.50161f3c

}> + {publicRoutes.map((route) => } /> -
+
@@ -424,7 +475,7 @@ export default function App() { -

i18n:govoplan-core.loading_module.50161f3c

}> + } /> {!dashboardModuleInstalled && } />} @@ -455,7 +506,7 @@ export default function App() { } /> } /> -
+ {reloginMessage && { + state: ModuleLoadBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ModuleLoadBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("GovOPlaN module route failed to load", error, info); + } + + componentDidUpdate(previousProps: ModuleLoadBoundaryProps) { + if ( + previousProps.resetKey !== this.props.resetKey && + this.state.error + ) { + this.setState({ error: null }); + } + } + + render() { + if (this.props.loading) { + return ; + } + if (this.state.error) { + return ( +
+ +

i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf

+ +
+
+ ); + } + + return ( + } + > + {this.props.children} + + ); + } +} + +function ModuleLoadProgress() { + return ( +
+ + i18n:govoplan-core.loading_module.50161f3c +
+ ); +} diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index 5e2fa7c..effbc85 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -1,5 +1,5 @@ import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react"; -import installedWebModules from "virtual:govoplan-installed-modules"; +import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types"; import { hasUiCapability as hasUiCapabilityForModules, @@ -21,12 +21,20 @@ import { export const fallbackDashboardNavItem: PlatformNavItem = { to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 }; +type InstalledWebModuleLoader = { + packageName: string; + load: () => Promise<{ default: PlatformWebModule }>; +}; + +const installedWebModuleLoaders = + installedWebModuleLoaderSource as unknown as InstalledWebModuleLoader[]; + export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] { return moduleInstalledForModules("dashboard", modules) ? [] : [fallbackDashboardNavItem]; } - -const localModules: PlatformWebModule[] = installedWebModules; +const localModuleCache = new Map>(); +const loadedLocalModules = new Map(); const remoteModuleCache = new Map>(); declare global { @@ -214,44 +222,39 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo { }; } -export function resolveInstalledPublicWebModules( +export async function loadInstalledPublicWebModules( platformModules: PlatformPublicModuleInfo[] | null | undefined -): PlatformWebModule[] { +): Promise { if (!platformModules?.length) return []; - const localById = new Map(localModules.map((module) => [module.id, module])); - return platformModules.flatMap((info) => { - const local = localById.get(info.id); - if (!local) return []; - const resolved = applyServerMetadata(local, publicModuleInfo(info)); - return resolved.publicRoutes?.length ? [resolved] : []; - }); + const loaded = await loadInstalledWebModules(platformModules.map(publicModuleInfo)); + return loaded.filter((module) => module.publicRoutes?.length); } export async function loadRemotePublicWebModules( platformModules: PlatformPublicModuleInfo[] | null | undefined, - alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules) + alreadyLoaded: PlatformWebModule[] = [] ): Promise { if (!platformModules?.length) return []; const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded); return loaded.filter((module) => module.publicRoutes?.length); } -export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] { - if (!platformModules?.length) return localModules; +export async function loadInstalledWebModules( + platformModules: PlatformModuleInfo[] | null | undefined +): Promise { + if (!platformModules?.length) return []; - const localById = new Map(localModules.map((module) => [module.id, module])); - return platformModules. - filter((module) => module.enabled). - map((module) => { - const local = localById.get(module.id); - return local ? applyServerMetadata(local, module) : null; - }). - filter((module): module is PlatformWebModule => module !== null); + const enabledModules = platformModules.filter((module) => module.enabled); + const resolved = await Promise.all(enabledModules.map(async (info) => { + const local = await loadInstalledWebModule(info); + return local ? applyServerMetadata(local, info) : null; + })); + return resolved.filter((module): module is PlatformWebModule => module !== null); } export async function loadRemoteWebModules( platformModules: PlatformModuleInfo[] | null | undefined, -alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)) +alreadyLoaded: PlatformWebModule[] = []) : Promise { if (!platformModules?.length) return []; const loadedIds = new Set(alreadyLoaded.map((module) => module.id)); @@ -274,6 +277,56 @@ alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules) return loaded.filter((module): module is PlatformWebModule => module !== null); } +async function loadInstalledWebModule(info: PlatformModuleInfo): Promise { + if (!info.frontend) return null; + const packageName = info.frontend?.package_name; + if (packageName) { + const loader = installedWebModuleLoaders.find((candidate) => candidate.packageName === packageName); + return loader ? loadInstalledWebModulePackage(loader, info.id) : null; + } + + // Legacy manifests may omit package_name. Keep compatibility without making + // every modern module eager: only this fallback imports all installed descriptors. + const candidates = await Promise.all( + installedWebModuleLoaders.map((loader) => loadInstalledWebModulePackage(loader)) + ); + return candidates.find((candidate) => candidate?.id === info.id) ?? null; +} + +async function loadInstalledWebModulePackage( + loader: InstalledWebModuleLoader, + expectedModuleId?: string +): Promise { + let promise = localModuleCache.get(loader.packageName); + if (!promise) { + promise = loader.load(). + then((imported) => { + const module = imported.default; + if (!isPlatformWebModule(module)) { + throw new Error(`${loader.packageName} does not export a PlatformWebModule`); + } + loadedLocalModules.set(loader.packageName, module); + return module; + }). + catch((error) => { + localModuleCache.delete(loader.packageName); + console.warn("GovOPlaN installed WebUI module was not loaded:", loader.packageName, error); + return null; + }); + localModuleCache.set(loader.packageName, promise); + } + const module = await promise; + if (module && expectedModuleId && module.id !== expectedModuleId) { + console.warn( + "GovOPlaN installed WebUI package id mismatch:", + loader.packageName, + `expected ${expectedModuleId}, received ${module.id}` + ); + return null; + } + return module; +} + async function loadRemoteWebModule(info: PlatformModuleInfo): Promise { const frontend = info.frontend; if (!frontend?.asset_manifest) return null; @@ -387,26 +440,26 @@ function constantTimeEqual(left: string, right: string): boolean { } export function installedLocalWebModules(): PlatformWebModule[] { - return localModules; + return [...loadedLocalModules.values()]; } -export function uiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): T | null { +export function uiCapability(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T | null { return uiCapabilityForModules(capabilityName, modules); } -export function uiCapabilities(capabilityName: string, modules: PlatformWebModule[] = localModules): T[] { +export function uiCapabilities(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T[] { return uiCapabilitiesForModules(capabilityName, modules); } -export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): boolean { +export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean { return hasUiCapabilityForModules(capabilityName, modules); } -export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = localModules): boolean { +export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean { return moduleInstalledForModules(moduleId, modules); } -export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = localModules): boolean { +export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean { return moduleIntegrationEnabledForModules(moduleId, dependencyId, modules); } @@ -419,7 +472,7 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[]) } export function dashboardWidgetsForModules( - modules: PlatformWebModule[] = localModules, + modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null ): DashboardWidgetContribution[] { const catalogue = viewSurfaceCatalogueForModules(modules); @@ -446,7 +499,7 @@ export function navItemsForModules( sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); } -export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] { +export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): PlatformNavItem[] { return navItemsForModules(modules, projection).filter((item) => { if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false; if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false; @@ -454,6 +507,6 @@ export function visibleNavItems(auth: AuthInfo | null | undefined, modules: Plat }); } -export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string { +export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): string { return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard"; } diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 248e5fa..fe308bd 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -2204,6 +2204,29 @@ font-weight: 600; } +.module-load-progress { + display: flex; + min-height: 120px; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-muted); +} + +.module-load-error .alert { + max-width: 640px; +} + +.module-load-error .alert-message { + display: grid; + justify-items: start; + gap: 12px; +} + +.module-load-error .alert-message p { + margin: 0; +} + /* Collapsible cards */ .card-collapsible .card-header { diff --git a/webui/src/vite-env.d.ts b/webui/src/vite-env.d.ts index 444d3e2..75ba799 100644 --- a/webui/src/vite-env.d.ts +++ b/webui/src/vite-env.d.ts @@ -4,7 +4,10 @@ declare module "virtual:govoplan-installed-modules" { import type { PlatformWebModule } from "@govoplan/core-webui"; - const installedWebModules: PlatformWebModule[]; - export { installedWebModules }; - export default installedWebModules; + const installedWebModuleLoaders: Array<{ + packageName: string; + load: () => Promise<{ default: PlatformWebModule }>; + }>; + + export default installedWebModuleLoaders; } diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 8b93d65..ef10e07 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -9,6 +9,8 @@ const require = createRequire(import.meta.url); const webuiRoot = dirname(fileURLToPath(import.meta.url)); const installedModulesVirtualId = "virtual:govoplan-installed-modules"; const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`; +const installedModuleVirtualPrefix = "virtual:govoplan-installed-module/"; +const resolvedInstalledModuleVirtualPrefix = `\0${installedModuleVirtualPrefix}`; const defaultWebModulePackages = [ "@govoplan/access-webui", @@ -31,6 +33,7 @@ const defaultWebModulePackages = [ "@govoplan/postbox-webui", "@govoplan/risk-compliance-webui", "@govoplan/scheduling-webui", + "@govoplan/search-webui", "@govoplan/views-webui", "@govoplan/workflow-webui" ]; @@ -61,20 +64,41 @@ function availableWebModuleSpecifiers(): string[] { } function govoplanInstalledModulesPlugin(): Plugin { + const specifierByModuleVirtualId = new Map( + availableWebModuleSpecifiers().map((specifier) => [ + `${installedModuleVirtualPrefix}${encodeURIComponent(specifier)}`, + specifier + ]) + ); + return { name: "govoplan-installed-modules", resolveId(id: string) { if (id === installedModulesVirtualId) return resolvedInstalledModulesVirtualId; + if (specifierByModuleVirtualId.has(id)) return `\0${id}`; return null; }, load(id: string) { - if (id !== resolvedInstalledModulesVirtualId) return null; - const imports = availableWebModuleSpecifiers(); - return [ - ...imports.map((specifier, index) => `import module${index} from ${JSON.stringify(specifier)};`), - `export const installedWebModules = [${imports.map((_specifier, index) => `module${index}`).join(", ")}];`, - "export default installedWebModules;" - ].join("\n"); + if (id === resolvedInstalledModulesVirtualId) { + const loaders = [...specifierByModuleVirtualId].map(([virtualId, specifier]) => ( + `{ packageName: ${JSON.stringify(specifier)}, load: () => import(${JSON.stringify(virtualId)}) }` + )); + return [ + `export const installedWebModuleLoaders = [${loaders.join(", ")}];`, + "export default installedWebModuleLoaders;" + ].join("\n"); + } + if (!id.startsWith(resolvedInstalledModuleVirtualPrefix)) return null; + const publicVirtualId = id.slice(1); + const specifier = specifierByModuleVirtualId.get(publicVirtualId); + if (!specifier) return null; + const moduleEntry = join(packageDirectory(specifier), "src", "module.ts"); + if (!existsSync(moduleEntry)) { + throw new Error(`${specifier} does not expose src/module.ts`); + } + // Import the contribution descriptor directly. Package root barrels may + // intentionally re-export pages and would otherwise defeat route splitting. + return `export { default } from ${JSON.stringify(moduleEntry)};`; } }; } @@ -88,9 +112,8 @@ export default defineConfig({ include: ["@xyflow/react"] }, build: { - // Full-product builds include the host shell plus all installed module wiring. - // Dedicated route/vendor splitting belongs in a separate performance pass. - chunkSizeWarningLimit: 1200 + manifest: true, + chunkSizeWarningLimit: 500 }, resolve: { preserveSymlinks: false, @@ -132,6 +155,7 @@ export default defineConfig({ fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url)) ]