@pithyjs/router 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +621 -0
  3. package/dist/index.d.ts +29 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +49 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/router/core/config.d.ts +22 -0
  8. package/dist/router/core/config.d.ts.map +1 -0
  9. package/dist/router/core/config.js +54 -0
  10. package/dist/router/core/config.js.map +1 -0
  11. package/dist/router/core/constants.d.ts +89 -0
  12. package/dist/router/core/constants.d.ts.map +1 -0
  13. package/dist/router/core/constants.js +95 -0
  14. package/dist/router/core/constants.js.map +1 -0
  15. package/dist/router/core/history.d.ts +54 -0
  16. package/dist/router/core/history.d.ts.map +1 -0
  17. package/dist/router/core/history.js +89 -0
  18. package/dist/router/core/history.js.map +1 -0
  19. package/dist/router/core/matcher.d.ts +43 -0
  20. package/dist/router/core/matcher.d.ts.map +1 -0
  21. package/dist/router/core/matcher.js +242 -0
  22. package/dist/router/core/matcher.js.map +1 -0
  23. package/dist/router/core/params.d.ts +88 -0
  24. package/dist/router/core/params.d.ts.map +1 -0
  25. package/dist/router/core/params.js +150 -0
  26. package/dist/router/core/params.js.map +1 -0
  27. package/dist/router/core/pipeline.d.ts +90 -0
  28. package/dist/router/core/pipeline.d.ts.map +1 -0
  29. package/dist/router/core/pipeline.js +323 -0
  30. package/dist/router/core/pipeline.js.map +1 -0
  31. package/dist/router/core/route-tree.d.ts +76 -0
  32. package/dist/router/core/route-tree.d.ts.map +1 -0
  33. package/dist/router/core/route-tree.js +143 -0
  34. package/dist/router/core/route-tree.js.map +1 -0
  35. package/dist/router/core/router.d.ts +229 -0
  36. package/dist/router/core/router.d.ts.map +1 -0
  37. package/dist/router/core/router.js +762 -0
  38. package/dist/router/core/router.js.map +1 -0
  39. package/dist/router/core/types.d.ts +387 -0
  40. package/dist/router/core/types.d.ts.map +1 -0
  41. package/dist/router/core/types.js +22 -0
  42. package/dist/router/core/types.js.map +1 -0
  43. package/dist/router/core/utils.d.ts +66 -0
  44. package/dist/router/core/utils.d.ts.map +1 -0
  45. package/dist/router/core/utils.js +152 -0
  46. package/dist/router/core/utils.js.map +1 -0
  47. package/dist/router/features/coordinator.d.ts +75 -0
  48. package/dist/router/features/coordinator.d.ts.map +1 -0
  49. package/dist/router/features/coordinator.js +203 -0
  50. package/dist/router/features/coordinator.js.map +1 -0
  51. package/dist/router/features/errors/error-codes.d.ts +107 -0
  52. package/dist/router/features/errors/error-codes.d.ts.map +1 -0
  53. package/dist/router/features/errors/error-codes.js +247 -0
  54. package/dist/router/features/errors/error-codes.js.map +1 -0
  55. package/dist/router/features/errors/error-resolution.d.ts +31 -0
  56. package/dist/router/features/errors/error-resolution.d.ts.map +1 -0
  57. package/dist/router/features/errors/error-resolution.js +47 -0
  58. package/dist/router/features/errors/error-resolution.js.map +1 -0
  59. package/dist/router/features/errors/error-types.d.ts +60 -0
  60. package/dist/router/features/errors/error-types.d.ts.map +1 -0
  61. package/dist/router/features/errors/error-types.js +57 -0
  62. package/dist/router/features/errors/error-types.js.map +1 -0
  63. package/dist/router/features/errors/global-error-fallback.d.ts +33 -0
  64. package/dist/router/features/errors/global-error-fallback.d.ts.map +1 -0
  65. package/dist/router/features/errors/global-error-fallback.js +182 -0
  66. package/dist/router/features/errors/global-error-fallback.js.map +1 -0
  67. package/dist/router/features/errors/index.d.ts +15 -0
  68. package/dist/router/features/errors/index.d.ts.map +1 -0
  69. package/dist/router/features/errors/index.js +18 -0
  70. package/dist/router/features/errors/index.js.map +1 -0
  71. package/dist/router/features/errors/router-errors.d.ts +44 -0
  72. package/dist/router/features/errors/router-errors.d.ts.map +1 -0
  73. package/dist/router/features/errors/router-errors.js +53 -0
  74. package/dist/router/features/errors/router-errors.js.map +1 -0
  75. package/dist/router/features/guards/engine.d.ts +40 -0
  76. package/dist/router/features/guards/engine.d.ts.map +1 -0
  77. package/dist/router/features/guards/engine.js +67 -0
  78. package/dist/router/features/guards/engine.js.map +1 -0
  79. package/dist/router/features/guards/index.d.ts +52 -0
  80. package/dist/router/features/guards/index.d.ts.map +1 -0
  81. package/dist/router/features/guards/index.js +95 -0
  82. package/dist/router/features/guards/index.js.map +1 -0
  83. package/dist/router/features/loaders/cache.d.ts +60 -0
  84. package/dist/router/features/loaders/cache.d.ts.map +1 -0
  85. package/dist/router/features/loaders/cache.js +80 -0
  86. package/dist/router/features/loaders/cache.js.map +1 -0
  87. package/dist/router/features/loaders/engine.d.ts +17 -0
  88. package/dist/router/features/loaders/engine.d.ts.map +1 -0
  89. package/dist/router/features/loaders/engine.js +27 -0
  90. package/dist/router/features/loaders/engine.js.map +1 -0
  91. package/dist/router/features/loaders/index.d.ts +49 -0
  92. package/dist/router/features/loaders/index.d.ts.map +1 -0
  93. package/dist/router/features/loaders/index.js +112 -0
  94. package/dist/router/features/loaders/index.js.map +1 -0
  95. package/dist/router/features/loading-ui-manager.d.ts +69 -0
  96. package/dist/router/features/loading-ui-manager.d.ts.map +1 -0
  97. package/dist/router/features/loading-ui-manager.js +209 -0
  98. package/dist/router/features/loading-ui-manager.js.map +1 -0
  99. package/dist/router/features/module-loader.d.ts +50 -0
  100. package/dist/router/features/module-loader.d.ts.map +1 -0
  101. package/dist/router/features/module-loader.js +121 -0
  102. package/dist/router/features/module-loader.js.map +1 -0
  103. package/dist/router/features/outlet/errors.d.ts +24 -0
  104. package/dist/router/features/outlet/errors.d.ts.map +1 -0
  105. package/dist/router/features/outlet/errors.js +96 -0
  106. package/dist/router/features/outlet/errors.js.map +1 -0
  107. package/dist/router/features/outlet/index.d.ts +45 -0
  108. package/dist/router/features/outlet/index.d.ts.map +1 -0
  109. package/dist/router/features/outlet/index.js +95 -0
  110. package/dist/router/features/outlet/index.js.map +1 -0
  111. package/dist/router/features/outlet/mount.d.ts +38 -0
  112. package/dist/router/features/outlet/mount.d.ts.map +1 -0
  113. package/dist/router/features/outlet/mount.js +301 -0
  114. package/dist/router/features/outlet/mount.js.map +1 -0
  115. package/dist/router/features/outlet/segment-chain.d.ts +16 -0
  116. package/dist/router/features/outlet/segment-chain.d.ts.map +1 -0
  117. package/dist/router/features/outlet/segment-chain.js +62 -0
  118. package/dist/router/features/outlet/segment-chain.js.map +1 -0
  119. package/dist/router/features/prefetch/index.d.ts +97 -0
  120. package/dist/router/features/prefetch/index.d.ts.map +1 -0
  121. package/dist/router/features/prefetch/index.js +345 -0
  122. package/dist/router/features/prefetch/index.js.map +1 -0
  123. package/dist/router/features/ux/focus.d.ts +62 -0
  124. package/dist/router/features/ux/focus.d.ts.map +1 -0
  125. package/dist/router/features/ux/focus.js +136 -0
  126. package/dist/router/features/ux/focus.js.map +1 -0
  127. package/dist/router/features/ux/live-region.d.ts +81 -0
  128. package/dist/router/features/ux/live-region.d.ts.map +1 -0
  129. package/dist/router/features/ux/live-region.js +166 -0
  130. package/dist/router/features/ux/live-region.js.map +1 -0
  131. package/dist/router/features/ux/scroll-focus.d.ts +105 -0
  132. package/dist/router/features/ux/scroll-focus.d.ts.map +1 -0
  133. package/dist/router/features/ux/scroll-focus.js +111 -0
  134. package/dist/router/features/ux/scroll-focus.js.map +1 -0
  135. package/dist/router/features/ux/scroll.d.ts +71 -0
  136. package/dist/router/features/ux/scroll.d.ts.map +1 -0
  137. package/dist/router/features/ux/scroll.js +137 -0
  138. package/dist/router/features/ux/scroll.js.map +1 -0
  139. package/dist/router/features/ux/transitions.d.ts +120 -0
  140. package/dist/router/features/ux/transitions.d.ts.map +1 -0
  141. package/dist/router/features/ux/transitions.js +235 -0
  142. package/dist/router/features/ux/transitions.js.map +1 -0
  143. package/dist/router/integration/directive-binder.d.ts +20 -0
  144. package/dist/router/integration/directive-binder.d.ts.map +1 -0
  145. package/dist/router/integration/directive-binder.js +139 -0
  146. package/dist/router/integration/directive-binder.js.map +1 -0
  147. package/dist/router/integration/directives.d.ts +42 -0
  148. package/dist/router/integration/directives.d.ts.map +1 -0
  149. package/dist/router/integration/directives.js +109 -0
  150. package/dist/router/integration/directives.js.map +1 -0
  151. package/dist/router/integration/history-adapter.d.ts +76 -0
  152. package/dist/router/integration/history-adapter.d.ts.map +1 -0
  153. package/dist/router/integration/history-adapter.js +157 -0
  154. package/dist/router/integration/history-adapter.js.map +1 -0
  155. package/dist/router/integration/hmr.d.ts +83 -0
  156. package/dist/router/integration/hmr.d.ts.map +1 -0
  157. package/dist/router/integration/hmr.js +160 -0
  158. package/dist/router/integration/hmr.js.map +1 -0
  159. package/dist/router/integration/link-interceptor.d.ts +49 -0
  160. package/dist/router/integration/link-interceptor.d.ts.map +1 -0
  161. package/dist/router/integration/link-interceptor.js +94 -0
  162. package/dist/router/integration/link-interceptor.js.map +1 -0
  163. package/package.json +47 -0
@@ -0,0 +1,762 @@
1
+ /** @codex
2
+ {
3
+ "id": "pithy.router.core",
4
+ "title": "Router Core with Navigation Transaction State Machine",
5
+ "category": "runtime"
6
+ }
7
+ */
8
+ import { signal, computed } from '@pithyjs/signals';
9
+ import { parseQueryParams } from './params';
10
+ import { findBestMatch, normalizePath } from './matcher';
11
+ import { flattenRouteTree } from './route-tree';
12
+ import { initializeScrollRestoration } from './config';
13
+ import { ROUTER_CONFIG } from './constants';
14
+ import { NavigationPipeline } from './pipeline';
15
+ import { HistoryManager } from './history';
16
+ import { FeatureCoordinator, } from '../features/coordinator';
17
+ import { OutletManager } from '../features/outlet';
18
+ import { buildSegmentChain } from '../features/outlet/segment-chain';
19
+ import { scrollFocusManager } from '../features/ux/scroll-focus';
20
+ import { viewTransitionManager } from '../features/ux/transitions';
21
+ import { hmrManager } from '../integration/hmr';
22
+ import { LinkInterceptor } from '../integration/link-interceptor';
23
+ import { BrowserHistoryAdapter } from '../integration/history-adapter';
24
+ import { setGlobalRouter } from '../integration/directive-binder';
25
+ /** @codexApi {"parent":"pithy.router.core","name":"Router","stability":"stable","signature":"class Router","description":"Core router class with navigation transaction state machine"} */
26
+ /**
27
+ * Core router class with navigation transaction state machine
28
+ * Implements enhanced architectural improvements for production-ready routing
29
+ */
30
+ export class Router {
31
+ constructor(routes, config = {}) {
32
+ this.previousSegmentChain = [];
33
+ this.mountedElement = null;
34
+ this.isMounted = false;
35
+ // Cleanup references for dispose()
36
+ this.popstateListener = null;
37
+ this.hmrCleanup = null;
38
+ // Memoized computed accessors (created once to avoid leaking effects)
39
+ this._path = null;
40
+ this._params = null;
41
+ this._query = null;
42
+ this._loaderData = null;
43
+ // Router signals
44
+ this.routerState = signal({
45
+ path: '/',
46
+ params: {},
47
+ query: {},
48
+ match: null,
49
+ loaderData: null,
50
+ isNavigating: false,
51
+ activeNavigation: null,
52
+ });
53
+ // Committed state tracking for two-phase commit
54
+ this.committed = { path: '/', params: {}, query: {}, match: null };
55
+ // DOM snapshot for safety belt
56
+ this.outletSnapshot = null;
57
+ this.routes = routes;
58
+ // Detect if running in development mode
59
+ const isDev = typeof import.meta !== 'undefined' &&
60
+ import.meta.env?.DEV === true;
61
+ this.config = {
62
+ basePath: '',
63
+ trailingSlash: 'never',
64
+ decodeParams: true,
65
+ viewTransitions: false,
66
+ a11y: true,
67
+ interceptLinks: true,
68
+ linkIgnoreAttr: 'data-router-ignore',
69
+ loadingPolicy: 'replace',
70
+ popstateLoading: 'none',
71
+ setAsGlobal: true,
72
+ exposeToWindow: isDev,
73
+ windowKey: 'router',
74
+ outletSelector: '[data-router-outlet]',
75
+ ...config,
76
+ };
77
+ // Take control of scroll restoration - disable browser's automatic behavior
78
+ initializeScrollRestoration();
79
+ // Initialize history manager with adapter (defaults to browser adapter, enables SSR)
80
+ const historyAdapter = config.historyAdapter || new BrowserHistoryAdapter();
81
+ this.historyManager = new HistoryManager(historyAdapter);
82
+ // Initialize navigation pipeline
83
+ this.pipeline = new NavigationPipeline();
84
+ // Initialize feature coordinator
85
+ this.features = new FeatureCoordinator(this.config);
86
+ // Initialize link interceptor
87
+ this.linkInterceptor = new LinkInterceptor();
88
+ // Initialize outlet manager
89
+ this.outletManager = new OutletManager();
90
+ this.outletManager.setRouter(this);
91
+ // Managers are now handled by FeatureCoordinator
92
+ // Configure view transition manager with router config
93
+ // Handle both boolean (simple enable/disable) and object (advanced config)
94
+ if (typeof this.config.viewTransitions === 'boolean') {
95
+ viewTransitionManager.updateConfig({
96
+ enabled: this.config.viewTransitions,
97
+ });
98
+ }
99
+ else if (this.config.viewTransitions) {
100
+ viewTransitionManager.updateConfig(this.config.viewTransitions);
101
+ }
102
+ else {
103
+ viewTransitionManager.updateConfig({
104
+ enabled: false,
105
+ });
106
+ }
107
+ // Configure scroll/focus manager with a11y settings
108
+ scrollFocusManager.updateConfig({
109
+ liveRegion: {
110
+ enabled: this.config.a11y ?? true,
111
+ },
112
+ });
113
+ // Extract route patterns for matching (including nested routes)
114
+ // Flatten the route tree to get all patterns
115
+ const flatRoutes = flattenRouteTree(routes);
116
+ this.routePatterns = flatRoutes.map(entry => entry.node.pattern);
117
+ // Install global link interception once (if enabled)
118
+ if (this.config.interceptLinks) {
119
+ this.setupLinkInterceptor();
120
+ }
121
+ // Initialize with current location
122
+ this.initializeFromCurrentLocation();
123
+ // Set up history listeners
124
+ this.setupHistoryListeners();
125
+ // Enable HMR integration (thin layer delegates to updateRoutes)
126
+ this.setupHMR();
127
+ // Expose router to window for debugging (in dev mode)
128
+ if (this.config.exposeToWindow && typeof window !== 'undefined') {
129
+ const key = this.config.windowKey || 'router';
130
+ Reflect.set(window, key, this);
131
+ }
132
+ }
133
+ /** @codexApi {"parent":"pithy.router.core","name":"state","stability":"stable","signature":"() => Signal<RouterState>","description":"Get current router state as a reactive signal"} */
134
+ /**
135
+ * Get current router state as reactive signal
136
+ */
137
+ state() {
138
+ return this.routerState;
139
+ }
140
+ /** @codexApi {"parent":"pithy.router.core","name":"path","stability":"stable","signature":"() => () => string","description":"Get current path as a computed reactive signal"} */
141
+ /**
142
+ * Get current path as reactive signal (memoized to avoid leaking effects)
143
+ */
144
+ path() {
145
+ return (this._path ?? (this._path = computed(() => this.routerState().path)));
146
+ }
147
+ /** @codexApi {"parent":"pithy.router.core","name":"params","stability":"stable","signature":"() => () => RouteParams","description":"Get current route params as a computed reactive signal"} */
148
+ /**
149
+ * Get current params as reactive signal (memoized to avoid leaking effects)
150
+ */
151
+ params() {
152
+ return (this._params ?? (this._params = computed(() => this.routerState().params)));
153
+ }
154
+ /** @codexApi {"parent":"pithy.router.core","name":"query","stability":"stable","signature":"() => () => QueryParams","description":"Get current query params as a computed reactive signal"} */
155
+ /**
156
+ * Get current query as reactive signal (memoized to avoid leaking effects)
157
+ */
158
+ query() {
159
+ return (this._query ?? (this._query = computed(() => this.routerState().query)));
160
+ }
161
+ /** @codexApi {"parent":"pithy.router.core","name":"loaderData","stability":"stable","signature":"() => () => ResourceState | null","description":"Get current loader data as a computed reactive signal"} */
162
+ /**
163
+ * Get current loader data as reactive signal (memoized to avoid leaking effects)
164
+ */
165
+ loaderData() {
166
+ return (this._loaderData ?? (this._loaderData = computed(() => this.routerState().loaderData)));
167
+ }
168
+ /** @codexApi {"parent":"pithy.router.core","name":"navigate","stability":"stable","signature":"(path: string, options?: NavigationOptions) => Promise<boolean>","description":"Navigate to a path with two-phase commit via the pipeline"} */
169
+ /**
170
+ * Navigate to a new path with two-phase commit and transactional navigation
171
+ * Delegates to NavigationPipeline for orchestration
172
+ */
173
+ async navigate(path, options = {}) {
174
+ // Delegate to NavigationPipeline
175
+ return this.pipeline.execute(path, options, this.createNavigationContext());
176
+ }
177
+ /** @codexApi {"parent":"pithy.router.core","name":"back","stability":"stable","signature":"() => void","description":"Go back one entry in browser history"} */
178
+ /**
179
+ * Go back in history
180
+ */
181
+ back() {
182
+ this.historyManager.back();
183
+ }
184
+ /** @codexApi {"parent":"pithy.router.core","name":"forward","stability":"stable","signature":"() => void","description":"Go forward one entry in browser history"} */
185
+ /**
186
+ * Go forward in history
187
+ */
188
+ forward() {
189
+ this.historyManager.forward();
190
+ }
191
+ /** @codexApi {"parent":"pithy.router.core","name":"mount","stability":"stable","signature":"(rootElement: HTMLElement) => void","description":"Mount router to a DOM element and activate outlet routing"} */
192
+ /**
193
+ * Mount router to DOM element with outlet management
194
+ * Automatically finds outlet element and sets up routing
195
+ *
196
+ * @param rootElement - Root element containing the router outlet
197
+ */
198
+ mount(rootElement) {
199
+ // Make mount idempotent - calling multiple times is safe
200
+ if (this.isMounted) {
201
+ console.warn('[Router] Already mounted, ignoring duplicate mount call');
202
+ return;
203
+ }
204
+ // Find the outlet element within the root element
205
+ const outletSelector = this.config.outletSelector || '[data-router-outlet]';
206
+ const outlet = rootElement.querySelector(outletSelector);
207
+ if (!outlet) {
208
+ throw new Error(`[Router] Outlet not found. Add <div @routerOutlet></div> to your app root. ` +
209
+ `Searched for selector: ${outletSelector}`);
210
+ }
211
+ // Set the outlet as mounted element
212
+ this.mountedElement = outlet;
213
+ this.isMounted = true;
214
+ // Set mounted element on LoadingUIManager
215
+ this.features.getLoadingUI().setMountedElement(outlet);
216
+ // Set router as global for directive binding (if configured)
217
+ if (this.config.setAsGlobal) {
218
+ setGlobalRouter(this);
219
+ }
220
+ // Initial mount of current route
221
+ this.mountCurrentRoute();
222
+ }
223
+ /** @codexApi {"parent":"pithy.router.core","name":"getActiveNavId","stability":"experimental","signature":"() => NavigationId","description":"Get the active navigation ID for outlet manager integration"} */
224
+ /**
225
+ * Get the active navigation ID for outlet manager integration
226
+ */
227
+ getActiveNavId() {
228
+ return this.pipeline.getActiveNavId();
229
+ }
230
+ /** @codexApi {"parent":"pithy.router.core","name":"isNavActive","stability":"experimental","signature":"(navId: NavigationId) => boolean","description":"Check if a navigation ID is still active"} */
231
+ /**
232
+ * Check if a navigation ID is still active
233
+ */
234
+ isNavActive(navId) {
235
+ return this.isNavigationActive(navId);
236
+ }
237
+ /** @codexApi {"parent":"pithy.router.core","name":"prefetchRoute","stability":"experimental","signature":"(href: string, signal: AbortSignal) => Promise<void>","description":"Prefetch route components using shared module cache"} */
238
+ /**
239
+ * Prefetch route components (for PrefetchManager integration)
240
+ * Uses shared module cache from @pithyjs/core for deduplication
241
+ */
242
+ async prefetchRoute(href, signal) {
243
+ if (signal.aborted)
244
+ return;
245
+ const url = new URL(href, window.location.origin);
246
+ const pathname = this.normalizePath(url.pathname);
247
+ const match = findBestMatch(pathname, this.routePatterns, this.config);
248
+ if (!match) {
249
+ return;
250
+ }
251
+ const route = this.routes.find(r => r.pattern === match.pattern);
252
+ if (!route)
253
+ return;
254
+ try {
255
+ // Import module loader utility for shared cache integration
256
+ const { loadRouteModule, getRouteModuleKey } = await import('../features/module-loader');
257
+ // Prefetch page component
258
+ if (route.imports.page && !signal.aborted) {
259
+ await loadRouteModule(getRouteModuleKey(route.pattern, 'page'), route.imports.page);
260
+ }
261
+ // Prefetch layout component if exists
262
+ if (route.imports.layout && !signal.aborted) {
263
+ await loadRouteModule(getRouteModuleKey(route.pattern, 'layout'), route.imports.layout);
264
+ }
265
+ // Prefetch loading component if exists
266
+ if (route.imports.loading && !signal.aborted) {
267
+ await loadRouteModule(getRouteModuleKey(route.pattern, 'loading'), route.imports.loading);
268
+ }
269
+ // Prefetch error component if exists
270
+ if (route.imports.error && !signal.aborted) {
271
+ await loadRouteModule(getRouteModuleKey(route.pattern, 'error'), route.imports.error);
272
+ }
273
+ // Prefetch config and execute prefetchChunks if defined
274
+ if (route.imports.config && !signal.aborted) {
275
+ const configModule = await loadRouteModule(getRouteModuleKey(route.pattern, 'config'), route.imports.config);
276
+ // Execute prefetchChunks if defined in config
277
+ if (configModule?.prefetchChunks &&
278
+ Array.isArray(configModule.prefetchChunks)) {
279
+ const prefetchPromises = [];
280
+ for (const chunk of configModule.prefetchChunks) {
281
+ if (signal.aborted)
282
+ break;
283
+ if (typeof chunk === 'function') {
284
+ prefetchPromises.push(Promise.resolve(chunk()).catch(() => {
285
+ // Silently ignore prefetch chunk failures
286
+ }));
287
+ }
288
+ }
289
+ // Wait for all prefetch chunks to complete (in parallel)
290
+ await Promise.all(prefetchPromises);
291
+ }
292
+ }
293
+ }
294
+ catch (error) {
295
+ if (!signal.aborted) {
296
+ console.warn('[Router] Prefetch failed:', error);
297
+ }
298
+ }
299
+ }
300
+ /** @codexApi {"parent":"pithy.router.core","name":"updateRoutes","stability":"stable","signature":"(newRoutes: RouteNode[], options?: { source?: string }) => Promise<void>","description":"Hot-update routes for HMR, preserving router state"} */
301
+ /**
302
+ * Update routes (for HMR support)
303
+ * Called by HMR manager when route modules are updated
304
+ * Preserves router state and only re-mounts outlets with new code
305
+ *
306
+ * @param newRoutes - Updated route tree from HMR
307
+ * @param options - Optional configuration
308
+ */
309
+ async updateRoutes(newRoutes, _options) {
310
+ const currentMatch = this.routerState().match;
311
+ // Capture scroll position for restoration after remount (uses singleton)
312
+ hmrManager.captureScrollSnapshot();
313
+ // Invalidate module cache for all routes (HMR needs fresh imports)
314
+ // This ensures HMR'd components are actually re-imported
315
+ try {
316
+ const { invalidateAllRouteModules } = await import('../features/module-loader');
317
+ await invalidateAllRouteModules(newRoutes);
318
+ }
319
+ catch {
320
+ // Module loader not available, continue without cache invalidation
321
+ }
322
+ // Update router's internal state with new routes FIRST
323
+ this.routes = newRoutes;
324
+ // Force full remount by clearing previous segment chain
325
+ // This ensures HMR'd components are actually re-imported and re-mounted
326
+ this.previousSegmentChain = [];
327
+ // Navigate to refresh routes using HMR manager singleton
328
+ this.routePatterns = await hmrManager.handleRouteUpdate(newRoutes, currentMatch, (path, opts) => this.navigate(path, opts), () => this.features.clearLoadingUI());
329
+ }
330
+ /** @codexApi {"parent":"pithy.router.core","name":"prefetchLoader","stability":"experimental","signature":"(href: string, signal: AbortSignal) => Promise<void>","description":"Prefetch route loader data with guard checks"} */
331
+ /**
332
+ * Prefetch route loader data (for PrefetchManager integration)
333
+ */
334
+ async prefetchLoader(href, signal) {
335
+ if (signal.aborted)
336
+ return;
337
+ const url = new URL(href, window.location.origin);
338
+ const pathname = this.normalizePath(url.pathname);
339
+ const search = url.search;
340
+ const match = findBestMatch(pathname, this.routePatterns, this.config);
341
+ if (!match) {
342
+ return;
343
+ }
344
+ const route = this.routes.find(r => r.pattern === match.pattern);
345
+ if (!route || !route.imports.loader) {
346
+ // No loader to prefetch
347
+ return;
348
+ }
349
+ try {
350
+ const query = this.parseQueryParams(search);
351
+ // Check guards before prefetching loader
352
+ const guards = await this.features.loadGuards(match.pattern, this.routes);
353
+ if (guards.length > 0) {
354
+ const guardRes = await this.features.executeGuards(guards, {
355
+ path: pathname,
356
+ params: match.params,
357
+ query,
358
+ signal,
359
+ navId: ROUTER_CONFIG.INITIAL_NAV_ID,
360
+ from: this.routerState().path,
361
+ });
362
+ // If guards deny access, don't prefetch the loader
363
+ if (!guardRes.allowed) {
364
+ return;
365
+ }
366
+ }
367
+ // Execute the loader and cache the result using FeatureCoordinator
368
+ await this.features.executeLoaders(match.pattern, {
369
+ params: match.params,
370
+ query,
371
+ signal,
372
+ navId: ROUTER_CONFIG.INITIAL_NAV_ID, // Use special navId for prefetch
373
+ updateRouterState: (updates) => this.updateRouterState(updates),
374
+ }, this.routes);
375
+ }
376
+ catch (error) {
377
+ if (!signal.aborted) {
378
+ console.warn('[Router] Prefetch loader failed:', error);
379
+ }
380
+ }
381
+ }
382
+ /** @codexApi {"parent":"pithy.router.core","name":"getRouteCacheTTL","stability":"experimental","signature":"(href: string) => Promise<number | undefined>","description":"Get the configured cache TTL for a specific route"} */
383
+ /**
384
+ * Get the cache TTL for a specific route (for PrefetchManager integration)
385
+ * Returns the route's configured cache TTL, or undefined if not configured
386
+ */
387
+ async getRouteCacheTTL(href) {
388
+ const url = new URL(href, window.location.origin);
389
+ const pathname = this.normalizePath(url.pathname);
390
+ const match = findBestMatch(pathname, this.routePatterns, this.config);
391
+ if (!match) {
392
+ return undefined;
393
+ }
394
+ const route = this.routes.find(r => r.pattern === match.pattern);
395
+ if (!route || !route.imports.config) {
396
+ return undefined;
397
+ }
398
+ try {
399
+ const configModule = await route.imports.config();
400
+ const cache = configModule?.cache;
401
+ if (cache &&
402
+ typeof cache === 'object' &&
403
+ typeof cache.ttl === 'number') {
404
+ return cache.ttl;
405
+ }
406
+ }
407
+ catch {
408
+ // Ignore config loading errors
409
+ }
410
+ return undefined;
411
+ }
412
+ /** @codexApi {"parent":"pithy.router.core","name":"dispose","stability":"stable","signature":"() => void","description":"Dispose router and clean up all listeners, outlets, and navigations"} */
413
+ /**
414
+ * Dispose of the router and clean up all resources
415
+ *
416
+ * This method should be called when unmounting the router or destroying the app.
417
+ * It cleans up:
418
+ * - Mounted components and their disposers
419
+ * - Event listeners (popstate, click)
420
+ * - HMR subscriptions
421
+ * - Active navigation transactions
422
+ *
423
+ * Note: In typical SPA usage, the router lives for the entire page lifetime,
424
+ * so dispose() is rarely needed. It's mainly useful for:
425
+ * - Testing (to prevent memory leaks between tests)
426
+ * - Multiple router instances
427
+ * - Micro-frontends
428
+ * - Server-side rendering cleanup
429
+ */
430
+ dispose() {
431
+ // 1. Clean up outlet manager (mounted components and disposers)
432
+ this.outletManager.dispose();
433
+ // 2. Remove popstate listener
434
+ if (this.popstateListener) {
435
+ window.removeEventListener('popstate', this.popstateListener);
436
+ this.popstateListener = null;
437
+ }
438
+ // 3. Clean up HMR subscription
439
+ if (this.hmrCleanup) {
440
+ this.hmrCleanup();
441
+ this.hmrCleanup = null;
442
+ }
443
+ // 4. Abort any active navigation
444
+ this.pipeline.cancelActiveNavigation();
445
+ // 5. Uninstall link interceptor
446
+ this.linkInterceptor.uninstall();
447
+ // 6. Clean up scroll/focus manager (including live region announcer)
448
+ scrollFocusManager.updateConfig({
449
+ liveRegion: { enabled: false },
450
+ });
451
+ // 7. Clean up view transition manager (remove injected styles)
452
+ viewTransitionManager.cleanup();
453
+ // 8. Clear mounted element reference
454
+ this.mountedElement = null;
455
+ // 9. Clear previous segment chain
456
+ this.previousSegmentChain = [];
457
+ // 10. Clear outlet snapshot
458
+ this.outletSnapshot = null;
459
+ // 11. Clear memoized computed accessors (prevents retaining Router via closures)
460
+ this._path = null;
461
+ this._params = null;
462
+ this._query = null;
463
+ this._loaderData = null;
464
+ }
465
+ // Private methods for navigation transaction state machine
466
+ /**
467
+ * Save current scroll position to history.state before navigation
468
+ * Delegates to HistoryManager to avoid code duplication
469
+ */
470
+ saveScrollToHistory() {
471
+ this.historyManager.saveScrollPosition();
472
+ }
473
+ /**
474
+ * Generate a stable selector for an element
475
+ */
476
+ isNavigationActive(navId) {
477
+ // Special case: navId 0 is always considered active (initial mount)
478
+ if (navId === ROUTER_CONFIG.INITIAL_NAV_ID)
479
+ return true;
480
+ // Delegate to pipeline for transaction check
481
+ const txActive = this.pipeline.isActive(navId);
482
+ // Fallback: commit already happened for this nav (match set),
483
+ // so mount is still relevant even if tx was cleared.
484
+ const committedForThisNav = this.pipeline.getActiveNavId() === navId && !!this.routerState().match;
485
+ return txActive || committedForThisNav;
486
+ }
487
+ initializeFromCurrentLocation() {
488
+ const currentPath = window.location.pathname;
489
+ const search = window.location.search;
490
+ const fullPath = currentPath + search;
491
+ // Run initial navigation through the same guard system
492
+ // Use replace: true to avoid adding to history
493
+ this.navigate(fullPath, { replace: true }).catch(error => {
494
+ console.error('[Router] Initial navigation failed:', error);
495
+ // Fallback to direct state update if navigation fails
496
+ const normalizedPath = this.normalizePath(currentPath);
497
+ const match = findBestMatch(normalizedPath, this.routePatterns, this.config);
498
+ const queryParams = this.parseQueryParams(search);
499
+ this.updateRouterState({
500
+ path: normalizedPath,
501
+ params: match?.params || {},
502
+ query: queryParams,
503
+ match,
504
+ isNavigating: false,
505
+ activeNavigation: null,
506
+ });
507
+ });
508
+ }
509
+ setupHistoryListeners() {
510
+ this.popstateListener = event => {
511
+ const url = new URL(window.location.href);
512
+ const pathname = this.normalizePath(url.pathname);
513
+ const search = url.search;
514
+ // Start a navigation sourced from POP; never push/replace during this flow
515
+ this.navigate(pathname + search, {
516
+ state: event.state,
517
+ source: 'pop',
518
+ }).catch(err => console.warn('[Router] Popstate failed:', err));
519
+ };
520
+ window.addEventListener('popstate', this.popstateListener);
521
+ }
522
+ /**
523
+ * Set up HMR integration (thin layer pattern)
524
+ * Delegates to HMRManager which handles Vite HMR subscription
525
+ * Preserves router state and only re-mounts outlets
526
+ */
527
+ setupHMR() {
528
+ // Only enable in development with Vite HMR available
529
+ if (typeof window !== 'undefined' &&
530
+ import.meta.hot) {
531
+ // Enable HMR manager singleton with callback to updateRoutes
532
+ this.hmrCleanup = hmrManager.enable(async (newRoutes) => {
533
+ await this.updateRoutes(newRoutes, { source: 'hmr' });
534
+ });
535
+ }
536
+ }
537
+ /**
538
+ * Set up global link interceptor - delegates to LinkInterceptor class
539
+ */
540
+ setupLinkInterceptor() {
541
+ this.linkInterceptor.install(href => this.navigate(href), this.config);
542
+ }
543
+ normalizePath(path) {
544
+ // Apply base path if configured
545
+ let normalizedPath = path;
546
+ if (this.config.basePath) {
547
+ normalizedPath = normalizedPath.replace(new RegExp(`^${this.config.basePath}`), '');
548
+ }
549
+ return normalizePath(normalizedPath, this.config.trailingSlash);
550
+ }
551
+ parseQueryParams(search) {
552
+ return parseQueryParams(search);
553
+ }
554
+ async updateRouterState(updates) {
555
+ const currentState = this.routerState();
556
+ this.routerState.set({
557
+ ...currentState,
558
+ ...updates,
559
+ });
560
+ // Trigger route mounting when state changes
561
+ if (this.mountedElement && updates.match !== undefined) {
562
+ await this.mountCurrentRoute();
563
+ }
564
+ }
565
+ /**
566
+ * Show route loading in outlet using atomic swap (no overlay, no blank)
567
+ * Implements the core "replace" policy for forward navigations
568
+ * NOTE: No View Transition here - loading UI should be plain swap to avoid
569
+ * the spinner becoming the "transition target" and getting stuck
570
+ */
571
+ /**
572
+ * Snapshot committed DOM for safety belt
573
+ */
574
+ snapshotCommittedOutlet() {
575
+ if (!this.mountedElement)
576
+ return;
577
+ const frag = document.createDocumentFragment();
578
+ // Clone nodes to avoid moving them out
579
+ this.mountedElement.childNodes.forEach(node => {
580
+ frag.appendChild(node.cloneNode(true));
581
+ });
582
+ this.outletSnapshot = frag;
583
+ }
584
+ /**
585
+ * Restore snapshot if outlet is empty (safety belt)
586
+ */
587
+ restoreSnapshotIfEmpty() {
588
+ if (!this.mountedElement)
589
+ return;
590
+ if (this.mountedElement.childNodes.length === 0 && this.outletSnapshot) {
591
+ this.mountedElement.appendChild(this.outletSnapshot.cloneNode(true));
592
+ }
593
+ }
594
+ /**
595
+ * Mount the current route using the outlet manager
596
+ */
597
+ async mountCurrentRoute() {
598
+ if (!this.mountedElement || !this.routerState().match) {
599
+ return;
600
+ }
601
+ try {
602
+ const segmentChain = this.buildCurrentSegmentChain();
603
+ if (!segmentChain)
604
+ return;
605
+ const success = await this.outletManager.mountSegments(segmentChain, this.pipeline.getActiveNavId(), this.mountedElement);
606
+ if (success) {
607
+ this.handleMountSuccess(segmentChain);
608
+ }
609
+ else {
610
+ this.handleMountFailure();
611
+ }
612
+ }
613
+ catch (error) {
614
+ console.error('[Router] Mount error:', error);
615
+ }
616
+ }
617
+ /**
618
+ * Build segment chain for the current route
619
+ * @returns Segment chain or null if empty
620
+ */
621
+ buildCurrentSegmentChain() {
622
+ const currentState = this.routerState();
623
+ const segmentChain = buildSegmentChain(this.routes, currentState.path, currentState.params, this.previousSegmentChain);
624
+ if (segmentChain.length === 0) {
625
+ console.warn('[Router] Empty segment chain - no routes to mount');
626
+ return null;
627
+ }
628
+ return segmentChain;
629
+ }
630
+ /**
631
+ * Handle successful route mount
632
+ * Updates state, snapshots DOM, and manages scroll/focus restoration
633
+ */
634
+ handleMountSuccess(segmentChain) {
635
+ const currentState = this.routerState();
636
+ // Update previous segment chain for next diff
637
+ this.previousSegmentChain = segmentChain;
638
+ // Update "committed" logical route
639
+ this.committed = {
640
+ path: currentState.path,
641
+ params: currentState.params,
642
+ query: currentState.query,
643
+ match: currentState.match,
644
+ };
645
+ // Snapshot current committed DOM for safety
646
+ this.snapshotCommittedOutlet();
647
+ // Restore HMR scroll position if needed
648
+ this.restoreHMRScrollIfNeeded();
649
+ // Manage focus after route mount completes
650
+ this.scheduleFocusManagement();
651
+ }
652
+ /**
653
+ * Restore HMR scroll position after successful mount
654
+ * Uses HMRManager singleton to consume the scroll snapshot
655
+ */
656
+ restoreHMRScrollIfNeeded() {
657
+ const state = this.routerState();
658
+ // Consume scroll snapshot from HMR manager
659
+ const scrollSnapshot = hmrManager.consumeScrollSnapshot();
660
+ if (!scrollSnapshot || scrollSnapshot.path !== state.path) {
661
+ return;
662
+ }
663
+ const { x, y } = scrollSnapshot;
664
+ // Wait two frames to ensure layout is stable
665
+ requestAnimationFrame(() => {
666
+ requestAnimationFrame(() => {
667
+ window.scrollTo(x, y);
668
+ });
669
+ });
670
+ }
671
+ /**
672
+ * Schedule focus management after route mount
673
+ */
674
+ scheduleFocusManagement() {
675
+ requestAnimationFrame(() => {
676
+ requestAnimationFrame(() => {
677
+ scrollFocusManager.manageFocus({ preventScroll: true });
678
+ });
679
+ });
680
+ }
681
+ /**
682
+ * Handle failed route mount
683
+ * Clears loading UI but keeps old view
684
+ */
685
+ handleMountFailure() {
686
+ // Mount failed: do not clear DOM; remove overlays, keep old view
687
+ this.features.clearLoadingUI();
688
+ // Transaction is managed by NavigationPipeline
689
+ }
690
+ /**
691
+ * Create navigation context for pipeline
692
+ * @private
693
+ */
694
+ createNavigationContext() {
695
+ return {
696
+ // State access
697
+ getCurrentPath: () => this.routerState().path,
698
+ getCurrentMatch: () => this.routerState().match,
699
+ // State mutation
700
+ updateState: async (updates) => await this.updateRouterState(updates),
701
+ // Router configuration
702
+ config: this.config,
703
+ routes: this.routes,
704
+ routePatterns: this.routePatterns,
705
+ // Feature hooks (delegating to FeatureCoordinator)
706
+ onLoadGuards: (pattern) => this.features.loadGuards(pattern, this.routes),
707
+ onExecuteGuards: (guards, context) => this.features.executeGuards(guards, context),
708
+ onExecuteLoaders: (pattern, context) => this.features.executeLoaders(pattern, {
709
+ ...context,
710
+ updateRouterState: (updates) => this.updateRouterState(updates),
711
+ }, this.routes),
712
+ onShowLoadingUI: async (route, match, navId, _source) => {
713
+ const loadingPolicy = this.config.loadingPolicy || 'replace';
714
+ await this.features.showLoadingUI(route, match, navId, this.mountedElement, loadingPolicy, this.routes);
715
+ },
716
+ onClearLoadingUI: () => this.features.clearLoadingUI(),
717
+ onSaveScrollToHistory: () => this.saveScrollToHistory(),
718
+ onRestoreSnapshotIfEmpty: () => this.restoreSnapshotIfEmpty(),
719
+ // History management hooks
720
+ onHistoryPush: (state, url) => this.historyManager.push(state, url),
721
+ onHistoryReplace: (state, url) => this.historyManager.replace(state, url),
722
+ onHistorySnapBack: () => this.historyManager.snapBack(),
723
+ // Path utilities
724
+ normalizePath: (path) => this.normalizePath(path),
725
+ parseQueryParams: (search) => this.parseQueryParams(search),
726
+ findMatch: (pathname) => findBestMatch(pathname, this.routePatterns, this.config),
727
+ };
728
+ }
729
+ }
730
+ /** @codexApi {"parent":"pithy.router.core","name":"createRouter","stability":"stable","signature":"(routes: RouteNode[], config?: RouterConfig) => Router","description":"Create a router instance with HMR hot-singleton reuse"} */
731
+ /**
732
+ * Create a new router instance with HMR hot-singleton pattern
733
+ *
734
+ * In development with HMR enabled, this function reuses the same router instance
735
+ * across module updates, preserving router state and avoiding duplicate instances.
736
+ *
737
+ * @param routes - Route tree from virtual:pithy/routes.gen
738
+ * @param config - Router configuration options
739
+ * @returns Router instance (may be existing instance if HMR is active)
740
+ */
741
+ export function createRouter(routes, config) {
742
+ const HOT_KEY = '__pithy_router_singleton__';
743
+ // Check for existing router instance in HMR data (hot-singleton pattern)
744
+ const hot = import.meta.hot;
745
+ if (typeof hot !== 'undefined') {
746
+ const existingRouter = hot.data?.[HOT_KEY];
747
+ if (existingRouter) {
748
+ // Reuse existing router instance across HMR updates
749
+ return existingRouter;
750
+ }
751
+ // Create new router and persist it for future HMR updates
752
+ const router = new Router(routes, config);
753
+ // Persist router instance across HMR updates
754
+ hot.dispose((data) => {
755
+ data[HOT_KEY] = router;
756
+ });
757
+ return router;
758
+ }
759
+ // No HMR available, create normal instance
760
+ return new Router(routes, config);
761
+ }
762
+ //# sourceMappingURL=router.js.map