class Router { #routes = []; #outlet = null; #currentPath = null; #pageCache = new Map(); // path -> { element } #scrollCache = new Map(); // path -> scrollY (persists after element eviction) // Only cache these route patterns (timeline, profiles, notifications, explore) #cacheablePatterns = [ /^\/$/, // timeline /^\/profile\/[^/]+$/, // profile (not followers/following/gallery) /^\/notifications$/, // notifications /^\/explore$/ // explore/search ]; register(path, componentTag) { this.#routes.push({ path, componentTag }); return this; } connect(outlet) { this.#outlet = outlet; // Skip View Transitions for popstate - browser gestures provide their own window.addEventListener('popstate', () => this.#navigate()); this.#navigate(); return this; } push(path) { if (location.pathname === path) return; const navigate = () => { history.pushState(null, '', path); this.#navigate(); window.dispatchEvent(new CustomEvent('grain:navigate')); }; if (document.startViewTransition) { document.startViewTransition(navigate); } else { navigate(); } } replace(path) { if (location.pathname === path) return; const navigate = () => { history.replaceState(null, '', path); this.#navigate(); window.dispatchEvent(new CustomEvent('grain:navigate')); }; if (document.startViewTransition) { document.startViewTransition(navigate); } else { navigate(); } } #matchRoute(pathname) { for (const route of this.#routes) { const params = this.#extractParams(route.path, pathname); if (params !== null) { return { componentTag: route.componentTag, params }; } } return null; } #extractParams(pattern, pathname) { if (pattern === '*') return {}; const patternParts = pattern.split('/'); const pathParts = pathname.split('/'); if (patternParts.length !== pathParts.length) return null; const params = {}; for (let i = 0; i < patternParts.length; i++) { if (patternParts[i].startsWith(':')) { params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]); } else if (patternParts[i] !== pathParts[i]) { return null; } } return params; } #isCacheable(pathname) { return this.#cacheablePatterns.some(pattern => pattern.test(pathname)); } #navigate() { const pathname = location.pathname; // Save scroll position of current page before switching if (this.#currentPath && this.#outlet) { this.#scrollCache.set(this.#currentPath, this.#outlet.scrollTop); } // Skip if same path if (this.#currentPath === pathname) return; // Deactivate/remove current page if (this.#currentPath) { const current = this.#pageCache.get(this.#currentPath); if (current) { if (this.#isCacheable(this.#currentPath)) { // Hide cacheable pages current.element.style.display = 'none'; current.element.dispatchEvent(new CustomEvent('grain:deactivated')); } else { // Remove non-cacheable pages from DOM current.element.remove(); this.#pageCache.delete(this.#currentPath); } } } this.#currentPath = pathname; // Check if we have a cached page element if (this.#pageCache.has(pathname)) { const cached = this.#pageCache.get(pathname); cached.element.style.display = ''; cached.element.dispatchEvent(new CustomEvent('grain:activated')); // Restore scroll position after paint requestAnimationFrame(() => { if (this.#outlet) { this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0; } }); return; } // Create new page const match = this.#matchRoute(pathname); if (!match || !this.#outlet) return; const el = document.createElement(match.componentTag); Object.assign(el, match.params); // Cache if cacheable, otherwise just track for deactivation this.#pageCache.set(pathname, { element: el }); this.#outlet.appendChild(el); // Restore saved scroll position, or start at top for new pages requestAnimationFrame(() => { if (this.#outlet) { this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0; } }); } } export const router = new Router();