WIP PWA for Grain
next.grain.social
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();