ai-code-battle/web/src/router.ts
jedarden 9750d29618 feat(web): performance trifecta — preload-on-hover, skeleton screens, back-cache per §16.14
Three perceived-performance features:
- Preload on hover: internal links prefetch target JSON data after 150ms
  hover debounce using <link rel=prefetch>. Touch events prefetch
  immediately.
- Skeleton screens: every async page shows a shimmer-animated placeholder
  matching the final content layout (leaderboard rows, bot profile card,
  replay canvas, playlist grid, etc.) instead of generic "Loading..." text.
- Instant back-cache: back/forward navigation restores scroll position and
  cached HTML from an in-memory LRU cache (8 pages), making back navigation
  0ms.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:28:43 -04:00

124 lines
2.9 KiB
TypeScript

// Simple hash-based router for single-page navigation
// §16.14: integrated with back-cache for instant back/forward navigation
export type RouteHandler = (params: Record<string, string>) => void | Promise<void>;
interface Route {
pattern: RegExp;
handler: RouteHandler;
paramNames: string[];
}
class Router {
private routes: Route[] = [];
private notFoundHandler: RouteHandler | null = null;
private beforeNavigateHooks: Array<(from: string, to: string) => void> = [];
private afterNavigateHooks: Array<(path: string) => void> = [];
/**
* Register a route with a pattern like "/leaderboard" or "/bot/:id"
*/
on(pattern: string, handler: RouteHandler): this {
const paramNames: string[] = [];
const regexPattern = pattern.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
});
this.routes.push({
pattern: new RegExp(`^${regexPattern}$`),
handler,
paramNames,
});
return this;
}
/**
* Register a handler for unmatched routes
*/
notFound(handler: RouteHandler): this {
this.notFoundHandler = handler;
return this;
}
/**
* Register a hook called before each navigation (after hash changes).
* Receives (fromPath, toPath).
*/
beforeNavigate(hook: (from: string, to: string) => void): this {
this.beforeNavigateHooks.push(hook);
return this;
}
/**
* Register a hook called after each route handler completes.
* Receives the resolved path.
*/
afterNavigate(hook: (path: string) => void): this {
this.afterNavigateHooks.push(hook);
return this;
}
/**
* Navigate to a path
*/
navigate(path: string): void {
window.location.hash = path;
}
/**
* Get current path from hash
*/
getCurrentPath(): string {
const hash = window.location.hash.slice(1); // Remove #
return hash || '/';
}
/**
* Start listening for hash changes
*/
start(): void {
window.addEventListener('hashchange', () => this.handleRoute());
this.handleRoute();
}
/**
* Handle the current route
*/
private async handleRoute(): Promise<void> {
const path = this.getCurrentPath();
const prevPath = (this as any)._lastPath as string | undefined;
(this as any)._lastPath = path;
for (const hook of this.beforeNavigateHooks) {
hook(prevPath ?? '/', path);
}
for (const route of this.routes) {
const match = path.match(route.pattern);
if (match) {
const params: Record<string, string> = {};
route.paramNames.forEach((name, idx) => {
params[name] = decodeURIComponent(match[idx + 1]);
});
await route.handler(params);
for (const hook of this.afterNavigateHooks) {
hook(path);
}
return;
}
}
if (this.notFoundHandler) {
await this.notFoundHandler({});
}
for (const hook of this.afterNavigateHooks) {
hook(path);
}
}
}
export const router = new Router();