at main 4.5 kB view raw
1class Router { 2 #routes = []; 3 #outlet = null; 4 #currentPath = null; 5 #pageCache = new Map(); // path -> { element } 6 #scrollCache = new Map(); // path -> scrollY (persists after element eviction) 7 8 // Only cache these route patterns (timeline, profiles, notifications, explore) 9 #cacheablePatterns = [ 10 /^\/$/, // timeline 11 /^\/profile\/[^/]+$/, // profile (not followers/following/gallery) 12 /^\/notifications$/, // notifications 13 /^\/explore$/ // explore/search 14 ]; 15 16 register(path, componentTag) { 17 this.#routes.push({ path, componentTag }); 18 return this; 19 } 20 21 connect(outlet) { 22 this.#outlet = outlet; 23 // Skip View Transitions for popstate - browser gestures provide their own 24 window.addEventListener('popstate', () => this.#navigate()); 25 this.#navigate(); 26 return this; 27 } 28 29 push(path) { 30 if (location.pathname === path) return; 31 32 const navigate = () => { 33 history.pushState(null, '', path); 34 this.#navigate(); 35 window.dispatchEvent(new CustomEvent('grain:navigate')); 36 }; 37 38 if (document.startViewTransition) { 39 document.startViewTransition(navigate); 40 } else { 41 navigate(); 42 } 43 } 44 45 replace(path) { 46 if (location.pathname === path) return; 47 48 const navigate = () => { 49 history.replaceState(null, '', path); 50 this.#navigate(); 51 window.dispatchEvent(new CustomEvent('grain:navigate')); 52 }; 53 54 if (document.startViewTransition) { 55 document.startViewTransition(navigate); 56 } else { 57 navigate(); 58 } 59 } 60 61 #matchRoute(pathname) { 62 for (const route of this.#routes) { 63 const params = this.#extractParams(route.path, pathname); 64 if (params !== null) { 65 return { componentTag: route.componentTag, params }; 66 } 67 } 68 return null; 69 } 70 71 #extractParams(pattern, pathname) { 72 if (pattern === '*') return {}; 73 74 const patternParts = pattern.split('/'); 75 const pathParts = pathname.split('/'); 76 77 if (patternParts.length !== pathParts.length) return null; 78 79 const params = {}; 80 for (let i = 0; i < patternParts.length; i++) { 81 if (patternParts[i].startsWith(':')) { 82 params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]); 83 } else if (patternParts[i] !== pathParts[i]) { 84 return null; 85 } 86 } 87 return params; 88 } 89 90 #isCacheable(pathname) { 91 return this.#cacheablePatterns.some(pattern => pattern.test(pathname)); 92 } 93 94 #navigate() { 95 const pathname = location.pathname; 96 97 // Save scroll position of current page before switching 98 if (this.#currentPath && this.#outlet) { 99 this.#scrollCache.set(this.#currentPath, this.#outlet.scrollTop); 100 } 101 102 // Skip if same path 103 if (this.#currentPath === pathname) return; 104 105 // Deactivate/remove current page 106 if (this.#currentPath) { 107 const current = this.#pageCache.get(this.#currentPath); 108 if (current) { 109 if (this.#isCacheable(this.#currentPath)) { 110 // Hide cacheable pages 111 current.element.style.display = 'none'; 112 current.element.dispatchEvent(new CustomEvent('grain:deactivated')); 113 } else { 114 // Remove non-cacheable pages from DOM 115 current.element.remove(); 116 this.#pageCache.delete(this.#currentPath); 117 } 118 } 119 } 120 121 this.#currentPath = pathname; 122 123 // Check if we have a cached page element 124 if (this.#pageCache.has(pathname)) { 125 const cached = this.#pageCache.get(pathname); 126 cached.element.style.display = ''; 127 cached.element.dispatchEvent(new CustomEvent('grain:activated')); 128 // Restore scroll position after paint 129 requestAnimationFrame(() => { 130 if (this.#outlet) { 131 this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0; 132 } 133 }); 134 return; 135 } 136 137 // Create new page 138 const match = this.#matchRoute(pathname); 139 if (!match || !this.#outlet) return; 140 141 const el = document.createElement(match.componentTag); 142 Object.assign(el, match.params); 143 144 // Cache if cacheable, otherwise just track for deactivation 145 this.#pageCache.set(pathname, { element: el }); 146 this.#outlet.appendChild(el); 147 148 // Restore saved scroll position, or start at top for new pages 149 requestAnimationFrame(() => { 150 if (this.#outlet) { 151 this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0; 152 } 153 }); 154 } 155} 156 157export const router = new Router();