@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.
- package/LICENSE +21 -0
- package/README.md +621 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/router/core/config.d.ts +22 -0
- package/dist/router/core/config.d.ts.map +1 -0
- package/dist/router/core/config.js +54 -0
- package/dist/router/core/config.js.map +1 -0
- package/dist/router/core/constants.d.ts +89 -0
- package/dist/router/core/constants.d.ts.map +1 -0
- package/dist/router/core/constants.js +95 -0
- package/dist/router/core/constants.js.map +1 -0
- package/dist/router/core/history.d.ts +54 -0
- package/dist/router/core/history.d.ts.map +1 -0
- package/dist/router/core/history.js +89 -0
- package/dist/router/core/history.js.map +1 -0
- package/dist/router/core/matcher.d.ts +43 -0
- package/dist/router/core/matcher.d.ts.map +1 -0
- package/dist/router/core/matcher.js +242 -0
- package/dist/router/core/matcher.js.map +1 -0
- package/dist/router/core/params.d.ts +88 -0
- package/dist/router/core/params.d.ts.map +1 -0
- package/dist/router/core/params.js +150 -0
- package/dist/router/core/params.js.map +1 -0
- package/dist/router/core/pipeline.d.ts +90 -0
- package/dist/router/core/pipeline.d.ts.map +1 -0
- package/dist/router/core/pipeline.js +323 -0
- package/dist/router/core/pipeline.js.map +1 -0
- package/dist/router/core/route-tree.d.ts +76 -0
- package/dist/router/core/route-tree.d.ts.map +1 -0
- package/dist/router/core/route-tree.js +143 -0
- package/dist/router/core/route-tree.js.map +1 -0
- package/dist/router/core/router.d.ts +229 -0
- package/dist/router/core/router.d.ts.map +1 -0
- package/dist/router/core/router.js +762 -0
- package/dist/router/core/router.js.map +1 -0
- package/dist/router/core/types.d.ts +387 -0
- package/dist/router/core/types.d.ts.map +1 -0
- package/dist/router/core/types.js +22 -0
- package/dist/router/core/types.js.map +1 -0
- package/dist/router/core/utils.d.ts +66 -0
- package/dist/router/core/utils.d.ts.map +1 -0
- package/dist/router/core/utils.js +152 -0
- package/dist/router/core/utils.js.map +1 -0
- package/dist/router/features/coordinator.d.ts +75 -0
- package/dist/router/features/coordinator.d.ts.map +1 -0
- package/dist/router/features/coordinator.js +203 -0
- package/dist/router/features/coordinator.js.map +1 -0
- package/dist/router/features/errors/error-codes.d.ts +107 -0
- package/dist/router/features/errors/error-codes.d.ts.map +1 -0
- package/dist/router/features/errors/error-codes.js +247 -0
- package/dist/router/features/errors/error-codes.js.map +1 -0
- package/dist/router/features/errors/error-resolution.d.ts +31 -0
- package/dist/router/features/errors/error-resolution.d.ts.map +1 -0
- package/dist/router/features/errors/error-resolution.js +47 -0
- package/dist/router/features/errors/error-resolution.js.map +1 -0
- package/dist/router/features/errors/error-types.d.ts +60 -0
- package/dist/router/features/errors/error-types.d.ts.map +1 -0
- package/dist/router/features/errors/error-types.js +57 -0
- package/dist/router/features/errors/error-types.js.map +1 -0
- package/dist/router/features/errors/global-error-fallback.d.ts +33 -0
- package/dist/router/features/errors/global-error-fallback.d.ts.map +1 -0
- package/dist/router/features/errors/global-error-fallback.js +182 -0
- package/dist/router/features/errors/global-error-fallback.js.map +1 -0
- package/dist/router/features/errors/index.d.ts +15 -0
- package/dist/router/features/errors/index.d.ts.map +1 -0
- package/dist/router/features/errors/index.js +18 -0
- package/dist/router/features/errors/index.js.map +1 -0
- package/dist/router/features/errors/router-errors.d.ts +44 -0
- package/dist/router/features/errors/router-errors.d.ts.map +1 -0
- package/dist/router/features/errors/router-errors.js +53 -0
- package/dist/router/features/errors/router-errors.js.map +1 -0
- package/dist/router/features/guards/engine.d.ts +40 -0
- package/dist/router/features/guards/engine.d.ts.map +1 -0
- package/dist/router/features/guards/engine.js +67 -0
- package/dist/router/features/guards/engine.js.map +1 -0
- package/dist/router/features/guards/index.d.ts +52 -0
- package/dist/router/features/guards/index.d.ts.map +1 -0
- package/dist/router/features/guards/index.js +95 -0
- package/dist/router/features/guards/index.js.map +1 -0
- package/dist/router/features/loaders/cache.d.ts +60 -0
- package/dist/router/features/loaders/cache.d.ts.map +1 -0
- package/dist/router/features/loaders/cache.js +80 -0
- package/dist/router/features/loaders/cache.js.map +1 -0
- package/dist/router/features/loaders/engine.d.ts +17 -0
- package/dist/router/features/loaders/engine.d.ts.map +1 -0
- package/dist/router/features/loaders/engine.js +27 -0
- package/dist/router/features/loaders/engine.js.map +1 -0
- package/dist/router/features/loaders/index.d.ts +49 -0
- package/dist/router/features/loaders/index.d.ts.map +1 -0
- package/dist/router/features/loaders/index.js +112 -0
- package/dist/router/features/loaders/index.js.map +1 -0
- package/dist/router/features/loading-ui-manager.d.ts +69 -0
- package/dist/router/features/loading-ui-manager.d.ts.map +1 -0
- package/dist/router/features/loading-ui-manager.js +209 -0
- package/dist/router/features/loading-ui-manager.js.map +1 -0
- package/dist/router/features/module-loader.d.ts +50 -0
- package/dist/router/features/module-loader.d.ts.map +1 -0
- package/dist/router/features/module-loader.js +121 -0
- package/dist/router/features/module-loader.js.map +1 -0
- package/dist/router/features/outlet/errors.d.ts +24 -0
- package/dist/router/features/outlet/errors.d.ts.map +1 -0
- package/dist/router/features/outlet/errors.js +96 -0
- package/dist/router/features/outlet/errors.js.map +1 -0
- package/dist/router/features/outlet/index.d.ts +45 -0
- package/dist/router/features/outlet/index.d.ts.map +1 -0
- package/dist/router/features/outlet/index.js +95 -0
- package/dist/router/features/outlet/index.js.map +1 -0
- package/dist/router/features/outlet/mount.d.ts +38 -0
- package/dist/router/features/outlet/mount.d.ts.map +1 -0
- package/dist/router/features/outlet/mount.js +301 -0
- package/dist/router/features/outlet/mount.js.map +1 -0
- package/dist/router/features/outlet/segment-chain.d.ts +16 -0
- package/dist/router/features/outlet/segment-chain.d.ts.map +1 -0
- package/dist/router/features/outlet/segment-chain.js +62 -0
- package/dist/router/features/outlet/segment-chain.js.map +1 -0
- package/dist/router/features/prefetch/index.d.ts +97 -0
- package/dist/router/features/prefetch/index.d.ts.map +1 -0
- package/dist/router/features/prefetch/index.js +345 -0
- package/dist/router/features/prefetch/index.js.map +1 -0
- package/dist/router/features/ux/focus.d.ts +62 -0
- package/dist/router/features/ux/focus.d.ts.map +1 -0
- package/dist/router/features/ux/focus.js +136 -0
- package/dist/router/features/ux/focus.js.map +1 -0
- package/dist/router/features/ux/live-region.d.ts +81 -0
- package/dist/router/features/ux/live-region.d.ts.map +1 -0
- package/dist/router/features/ux/live-region.js +166 -0
- package/dist/router/features/ux/live-region.js.map +1 -0
- package/dist/router/features/ux/scroll-focus.d.ts +105 -0
- package/dist/router/features/ux/scroll-focus.d.ts.map +1 -0
- package/dist/router/features/ux/scroll-focus.js +111 -0
- package/dist/router/features/ux/scroll-focus.js.map +1 -0
- package/dist/router/features/ux/scroll.d.ts +71 -0
- package/dist/router/features/ux/scroll.d.ts.map +1 -0
- package/dist/router/features/ux/scroll.js +137 -0
- package/dist/router/features/ux/scroll.js.map +1 -0
- package/dist/router/features/ux/transitions.d.ts +120 -0
- package/dist/router/features/ux/transitions.d.ts.map +1 -0
- package/dist/router/features/ux/transitions.js +235 -0
- package/dist/router/features/ux/transitions.js.map +1 -0
- package/dist/router/integration/directive-binder.d.ts +20 -0
- package/dist/router/integration/directive-binder.d.ts.map +1 -0
- package/dist/router/integration/directive-binder.js +139 -0
- package/dist/router/integration/directive-binder.js.map +1 -0
- package/dist/router/integration/directives.d.ts +42 -0
- package/dist/router/integration/directives.d.ts.map +1 -0
- package/dist/router/integration/directives.js +109 -0
- package/dist/router/integration/directives.js.map +1 -0
- package/dist/router/integration/history-adapter.d.ts +76 -0
- package/dist/router/integration/history-adapter.d.ts.map +1 -0
- package/dist/router/integration/history-adapter.js +157 -0
- package/dist/router/integration/history-adapter.js.map +1 -0
- package/dist/router/integration/hmr.d.ts +83 -0
- package/dist/router/integration/hmr.d.ts.map +1 -0
- package/dist/router/integration/hmr.js +160 -0
- package/dist/router/integration/hmr.js.map +1 -0
- package/dist/router/integration/link-interceptor.d.ts +49 -0
- package/dist/router/integration/link-interceptor.d.ts.map +1 -0
- package/dist/router/integration/link-interceptor.js +94 -0
- package/dist/router/integration/link-interceptor.js.map +1 -0
- 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
|