perf(webui): lazily load module descriptors

This commit is contained in:
2026-07-30 04:35:57 +02:00
parent 9e219bc4d3
commit 47e106684d
14 changed files with 604 additions and 84 deletions
+85 -34
View File
@@ -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<ApiSettings>(() => loadApiSettings());
const [auth, setAuth] = useState<AuthInfo | null>(null);
const [checkingSession, setCheckingSession] = useState(true);
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
const [localWebModules, setLocalWebModules] = useState<PlatformWebModule[]>([]);
const [localPublicWebModules, setLocalPublicWebModules] = useState<PlatformWebModule[]>([]);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
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<EffectiveViewProjection | null>(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<ViewsRuntimeUiCapability>("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() {
<PlatformViewProvider modules={publicWebModules} projection={null}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<ModuleLoadBoundary resetKey={location.pathname}>
<Routes>
{publicRoutes.map((route) =>
<Route
@@ -391,7 +442,7 @@ export default function App() {
)}
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
</Routes>
</Suspense>
</ModuleLoadBoundary>
</AppShell>
</UnsavedChangesProvider>
</PlatformViewProvider>
@@ -424,7 +475,7 @@ export default function App() {
<PlatformViewProvider modules={webModules} projection={viewProjection}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<ModuleLoadBoundary resetKey={location.pathname} loading={webModulesLoading}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
@@ -455,7 +506,7 @@ export default function App() {
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
</Routes>
</Suspense>
</ModuleLoadBoundary>
{reloginMessage &&
<LoginModal
settings={settings}