211 lines
7.5 KiB
TypeScript
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;
|