/** * @module ui/router/ibgib-router-service-base * * Generic, isomorphic/browser abstract base class for ibgib client SPA routers. * Manages HTML5 History API (pushState/replaceState), browser back/forward (popstate), * internal anchor link click interception, re-entrancy locks, and pub/sub route change notifications. */ import { extractErrorMsg } from '@ibgib/helper-gib/dist/helpers/utils-helper.mjs'; import { RouterNavigateOptions, RouteListener, } from './router-types.mjs'; /** * Abstract base class for ibgib SPA routers. * Parameterized by the application-specific route data model `TRouteInfo`. */ export abstract class IbGibRouterServiceBase { protected lc: string = `[${IbGibRouterServiceBase.name}]`; protected currentRoute?: TRouteInfo; protected listeners: RouteListener[] = []; protected isNavigating: boolean = false; constructor() { if (typeof window !== 'undefined') { this.initBrowserListeners(); } } /** * Parse raw URL pathname (and optional query string) into typed route info. * Must be implemented by the concrete downstream application router. */ protected abstract parseRoute(pathname: string): TRouteInfo; /** * Format typed route info into a URL pathname string. * Must be implemented by the concrete downstream application router. */ protected abstract formatRoute(route: TRouteInfo): string; /** * Check if two route infos represent the same route to prevent redundant event triggers. * Must be implemented by the concrete downstream application router. */ protected abstract isSameRoute(a?: TRouteInfo, b?: TRouteInfo): boolean; /** * Provide a fallback route if `loadCurrentURL` or `popstate` parsing encounters an error. * Must be implemented by the concrete downstream application router. */ protected abstract getFallbackRoute(pathname: string, error: unknown): TRouteInfo; /** * Hook to determine whether an anchor link click should be ignored by the SPA click interceptor. * Defaults to ignoring `/api/`, `/assets/`, and `/css/`. * Override in downstream routers if additional asset paths exist. */ protected isIgnoredPath(href: string): boolean { return href.startsWith('/api/') || href.startsWith('/assets/') || href.startsWith('/css/'); } /** * Initializes window and document level listeners for HTML5 history navigation and anchor clicks. */ protected initBrowserListeners(): void { const lc = `${this.lc}[${this.initBrowserListeners.name}]`; // 1. Popstate listener (browser back / forward buttons) window.addEventListener('popstate', async () => { try { const currentBrowserPath = `${window.location.pathname}${window.location.search}`; const newRoute = this.parseRoute(currentBrowserPath); if (this.isSameRoute(this.currentRoute, newRoute)) { return; } this.currentRoute = newRoute; await this.notifyListeners(newRoute); } catch (err) { console.error(`${lc} Popstate routing error: ${extractErrorMsg(err)}`); const fallback = this.getFallbackRoute( typeof window !== 'undefined' ? window.location.pathname : '/', err ); this.currentRoute = fallback; await this.notifyListeners(fallback); } }); // 2. Intercept internal anchor link clicks for seamless SPA navigation document.addEventListener('click', (e: MouseEvent) => { if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; } const target = e.target as HTMLElement | null; const anchor = target?.closest('a') as HTMLAnchorElement | null; if (!anchor) { return; } const href = anchor.getAttribute('href'); if (!href || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:')) { return; } // Exclude external links and target="_blank" if (anchor.target && anchor.target !== '_self') { return; } if (anchor.origin && anchor.origin !== window.location.origin) { return; } // Exclude API and asset routes if (this.isIgnoredPath(href)) { return; } // Intercept internal routing e.preventDefault(); this.navigatePath(href); }); } /** * Subscribes a listener to route change events. * Returns an unsubscribe callback. */ public subscribe(listener: RouteListener): () => void { this.listeners.push(listener); return () => { const idx = this.listeners.indexOf(listener); if (idx !== -1) { this.listeners.splice(idx, 1); } }; } /** * Returns the current active route info, if any. */ public getCurrentRoute(): TRouteInfo | undefined { return this.currentRoute; } /** * Navigates to a given typed route info. * Updates window.history (pushState or replaceState) and dispatches route changes to listeners. */ public async navigate(route: TRouteInfo, opts?: RouterNavigateOptions): Promise { const lc = `${this.lc}[${this.navigate.name}]`; if (this.isNavigating) { return; } this.isNavigating = true; try { const targetPath = this.formatRoute(route); if (typeof window !== 'undefined') { const currentBrowserPath = `${window.location.pathname}${window.location.search}`; if (currentBrowserPath !== targetPath) { if (opts?.replace) { window.history.replaceState(null, '', targetPath); } else { window.history.pushState(null, '', targetPath); } } } const wasSame = this.isSameRoute(this.currentRoute, route); this.currentRoute = route; if (!opts?.silent && !wasSame) { await this.notifyListeners(route); } } catch (err) { console.error(`${lc} Navigation error: ${extractErrorMsg(err)}`); } finally { this.isNavigating = false; } } /** * Navigates to a raw pathname string by parsing it into a typed route info. */ public async navigatePath(pathname: string, opts?: RouterNavigateOptions): Promise { const route = this.parseRoute(pathname); await this.navigate(route, opts); } /** * Reads current window.location, parses the route, updates internal state, * and notifies all listeners. Call during application bootstrap after Shell is ready. */ public async loadCurrentURL(): Promise { const lc = `${this.lc}[${this.loadCurrentURL.name}]`; try { const rawPath = typeof window !== 'undefined' ? `${window.location.pathname}${window.location.search}` : '/'; const route = this.parseRoute(rawPath); this.currentRoute = route; await this.notifyListeners(route); return route; } catch (err) { console.error(`${lc} loadCurrentURL error: ${extractErrorMsg(err)}`); const fallback = this.getFallbackRoute( typeof window !== 'undefined' ? window.location.pathname : '/', err ); this.currentRoute = fallback; await this.notifyListeners(fallback); return fallback; } } /** * Dispatches route changes sequentially to all registered subscribers. */ protected async notifyListeners(route: TRouteInfo): Promise { const lc = `${this.lc}[${this.notifyListeners.name}]`; for (const listener of this.listeners) { try { await listener(route); } catch (listenerErr) { console.error(`${lc} Listener error: ${extractErrorMsg(listenerErr)}`); } } } }