Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts
2026-07-16 07:01:34 +08:00

211 lines
7.5 KiB
TypeScript

import { lazy, type ComponentType } from 'react';
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations';
type RouteModule = { default: ComponentType };
type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string };
type RoutePreloadRuntime = { visibilityState?: DocumentVisibilityState; deviceMemory?: number };
type RoutePreloadScheduler = (callback: () => void) => () => void;
type RouteLoadOptions = { timeoutMs?: number; retryDelayMs?: number; retries?: number };
const ROUTE_LOAD_TIMEOUT_MS = 10_000;
const ROUTE_RETRY_DELAY_MS = 180;
const importers: Record<RouteSection, () => Promise<RouteModule>> = {
monitor: () => import('../pages/MonitorPage'),
vehicles: () => import('../pages/VehiclePage'),
tracks: () => import('../pages/TrackPage'),
history: () => import('../pages/HistoryPage'),
statistics: () => import('../pages/StatisticsPage'),
access: () => import('../pages/AccessPage'),
alerts: () => import('../pages/AlertsPage'),
operations: () => import('../pages/OperationsPage')
};
const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
const likelyNextRoutes: Record<RouteSection, RouteSection[]> = {
monitor: ['vehicles', 'tracks'],
vehicles: ['monitor', 'tracks'],
tracks: ['monitor', 'history'],
history: ['statistics', 'tracks'],
statistics: ['history', 'vehicles'],
access: ['monitor', 'operations'],
alerts: ['monitor', 'access'],
operations: ['access', 'monitor']
};
function routeLoadTimeout<T>(promise: Promise<T>, section: string, timeoutMs: number) {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => {
const error = new Error(`Route chunk load timed out after ${timeoutMs}ms: ${section}`);
error.name = 'RouteChunkLoadTimeoutError';
reject(error);
}, timeoutMs);
promise.then(
(value) => { window.clearTimeout(timer); resolve(value); },
(error) => { window.clearTimeout(timer); reject(error); }
);
});
}
function retryDelay(delayMs: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, delayMs));
}
function isRouteLoadTimeout(error: unknown) {
return error instanceof Error && error.name === 'RouteChunkLoadTimeoutError';
}
export async function loadRouteModule<T>(
loader: () => Promise<T>,
section: string,
{ timeoutMs = ROUTE_LOAD_TIMEOUT_MS, retryDelayMs = ROUTE_RETRY_DELAY_MS, retries = 1 }: RouteLoadOptions = {}
) {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await routeLoadTimeout(Promise.resolve().then(loader), section, timeoutMs);
} catch (error) {
lastError = error;
if (attempt >= retries || isRouteLoadTimeout(error)) break;
await retryDelay(retryDelayMs);
}
}
throw lastError;
}
function loadRoute(section: RouteSection) {
const pending = pendingModules.get(section);
if (pending) return pending;
const next = loadRouteModule(importers[section], section);
pendingModules.set(section, next);
void next.catch(() => pendingModules.delete(section));
return next;
}
export function createRouteComponent(section: RouteSection) {
return lazy(() => loadRoute(section));
}
export function routeSectionForPath(pathname: string): RouteSection | undefined {
const section = pathname.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] as RouteSection | undefined;
return section && section in importers ? section : undefined;
}
export async function preloadRoute(pathname: string) {
const section = routeSectionForPath(pathname);
if (!section) return undefined;
try {
return await loadRoute(section);
} catch {
// Intent/background preloads are speculative. loadRoute already evicts the
// failed Promise so a later navigation can retry through the route boundary.
return undefined;
}
}
function browserConnection(): RoutePreloadConnection | undefined {
return (navigator as Navigator & { connection?: RoutePreloadConnection }).connection;
}
function browserRuntime(): RoutePreloadRuntime {
return {
visibilityState: document.visibilityState,
deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory
};
}
export function routePreloadBudget(connection = browserConnection(), runtime = browserRuntime()) {
if (runtime.visibilityState === 'hidden' || connection?.saveData) return 0;
const effectiveType = String(connection?.effectiveType ?? '').toLowerCase();
if (effectiveType.includes('2g') || (runtime.deviceMemory != null && runtime.deviceMemory <= 2)) return 0;
if (effectiveType.includes('3g') || (runtime.deviceMemory != null && runtime.deviceMemory <= 4)) return 1;
return 2;
}
export function shouldPreloadRoutes(connection = browserConnection(), runtime = browserRuntime()) {
return routePreloadBudget(connection, runtime) > 0;
}
export function routePreloadCandidates(pathname: string) {
const activeSection = routeSectionForPath(pathname);
return activeSection ? likelyNextRoutes[activeSection] : [];
}
function shouldContinuePreloading() {
return document.visibilityState !== 'hidden';
}
export function shouldPreloadRouteOnIntent(connection = browserConnection()) {
if (connection?.saveData) return false;
return !String(connection?.effectiveType ?? '').toLowerCase().includes('2g');
}
function scheduleWhenIdle(callback: () => void) {
if (typeof window.requestIdleCallback === 'function') {
const id = window.requestIdleCallback(callback, { timeout: 2_000 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(callback, 900);
return () => window.clearTimeout(id);
}
export function scheduleIdleRoutePreloads({
activePathname,
connection = browserConnection(),
runtime = browserRuntime(),
preload = preloadRoute,
schedule = scheduleWhenIdle,
isVisible = shouldContinuePreloading
}: {
activePathname: string;
connection?: RoutePreloadConnection;
runtime?: RoutePreloadRuntime;
preload?: (pathname: string) => Promise<unknown> | undefined;
schedule?: RoutePreloadScheduler;
isVisible?: () => boolean;
}) {
const budget = routePreloadBudget(connection, runtime);
if (!budget) return () => undefined;
const queue = routePreloadCandidates(activePathname).slice(0, budget);
let cancelled = false;
let cancelScheduled: () => void = () => undefined;
const warmNext = () => {
if (cancelled || !isVisible()) return;
const section = queue.shift();
if (!section) return;
void Promise.resolve().then(() => preload(`/${section}`))
.finally(() => {
if (!cancelled && queue.length && isVisible()) cancelScheduled = schedule(warmNext);
});
};
cancelScheduled = schedule(warmNext);
return () => {
cancelled = true;
cancelScheduled();
};
}
export const RoutePages = {
Monitor: createRouteComponent('monitor'),
Vehicles: createRouteComponent('vehicles'),
Tracks: createRouteComponent('tracks'),
History: createRouteComponent('history'),
Statistics: createRouteComponent('statistics'),
Access: createRouteComponent('access'),
Alerts: createRouteComponent('alerts'),
Operations: createRouteComponent('operations')
} as const;
export const RoutePageFactories = {
Monitor: () => createRouteComponent('monitor'),
Vehicles: () => createRouteComponent('vehicles'),
Tracks: () => createRouteComponent('tracks'),
History: () => createRouteComponent('history'),
Statistics: () => createRouteComponent('statistics'),
Access: () => createRouteComponent('access'),
Alerts: () => createRouteComponent('alerts'),
Operations: () => createRouteComponent('operations')
} as const;