@uistate/router 1.0.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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/index.js +1 -0
  4. package/package.json +42 -0
  5. package/router.js +365 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 UIstate
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,245 @@
1
+ # @uistate/router
2
+
3
+ SPA router for EventState stores. Routing is just state.
4
+
5
+ `navigate()` writes to store paths. Components subscribe. No framework required.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @uistate/router
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```js
16
+ import { createEventState } from '@uistate/core';
17
+ import { createRouter } from '@uistate/router';
18
+
19
+ const store = createEventState({ state: {} });
20
+
21
+ const router = createRouter({
22
+ routes: [
23
+ { path: '/', view: 'home', component: HomeView },
24
+ { path: '/users', view: 'users', component: UsersView },
25
+ { path: '/users/:id', view: 'user', component: UserView },
26
+ { path: '/users/:id/posts/:postId', view: 'post', component: PostView },
27
+ ],
28
+ store,
29
+ fallback: { path: '/*', view: '404', component: NotFoundView },
30
+ debug: true,
31
+ });
32
+
33
+ router.start();
34
+ ```
35
+
36
+ ## How It Works
37
+
38
+ Every navigation writes to the store:
39
+
40
+ | Store Path | Value |
41
+ |---|---|
42
+ | `ui.route.view` | The matched `view` string (e.g. `'user'`) |
43
+ | `ui.route.path` | The normalized path (e.g. `'/users/42'`) |
44
+ | `ui.route.params` | Extracted params (e.g. `{ id: '42' }`) |
45
+ | `ui.route.query` | Parsed query params (e.g. `{ tab: 'posts' }`) |
46
+ | `ui.route.transitioning` | `true` during navigation, `false` after |
47
+
48
+ Your components subscribe to these paths like any other state:
49
+
50
+ ```js
51
+ store.subscribe('ui.route.view', (view) => {
52
+ console.log('View changed to:', view);
53
+ });
54
+
55
+ store.subscribe('ui.route.params', (params) => {
56
+ console.log('Route params:', params);
57
+ });
58
+
59
+ // Wildcard: react to any route change
60
+ store.subscribe('ui.route.*', ({ path, value }) => {
61
+ console.log('Route state changed:', path, value);
62
+ });
63
+ ```
64
+
65
+ ## Route Patterns
66
+
67
+ Routes support static paths and dynamic `:param` segments:
68
+
69
+ ```js
70
+ { path: '/', view: 'home' } // exact match
71
+ { path: '/users', view: 'users' } // exact match
72
+ { path: '/users/:id', view: 'user' } // dynamic segment
73
+ { path: '/posts/:id/edit', view: 'edit-post' } // mixed
74
+ ```
75
+
76
+ Params are extracted and available at `ui.route.params`:
77
+
78
+ ```js
79
+ // URL: /users/42
80
+ store.get('ui.route.params'); // { id: '42' }
81
+ ```
82
+
83
+ ## View Components
84
+
85
+ A view component is any object with a `boot` method:
86
+
87
+ ```js
88
+ const UserView = {
89
+ async boot({ store, el, signal, params }) {
90
+ el.innerHTML = `<h1>User ${params.id}</h1>`;
91
+
92
+ // Use signal for cleanup-aware async work
93
+ const res = await fetch(`/api/users/${params.id}`, { signal });
94
+ const user = await res.json();
95
+ el.innerHTML = `<h1>${user.name}</h1>`;
96
+
97
+ // Return an unboot function for cleanup
98
+ return () => {
99
+ console.log('UserView unmounted');
100
+ };
101
+ }
102
+ };
103
+ ```
104
+
105
+ The `boot` function receives:
106
+
107
+ | Param | Description |
108
+ |---|---|
109
+ | `store` | The EventState store instance |
110
+ | `el` | The root DOM element (from `rootSelector`) |
111
+ | `signal` | An `AbortSignal` — aborted if the user navigates away before boot finishes |
112
+ | `params` | Extracted route params (e.g. `{ id: '42' }`) |
113
+
114
+ ## API
115
+
116
+ ### `createRouter(config)`
117
+
118
+ Returns a router instance.
119
+
120
+ **Config options:**
121
+
122
+ | Option | Type | Default | Description |
123
+ |---|---|---|---|
124
+ | `routes` | `Array` | `[]` | Route definitions |
125
+ | `store` | `Object` | — | EventState store |
126
+ | `rootSelector` | `string` | `'[data-route-root]'` | CSS selector for the mount point |
127
+ | `fallback` | `Object` | `null` | Fallback route for unmatched paths |
128
+ | `debug` | `boolean` | `false` | Log navigation to console |
129
+ | `linkSelector` | `string` | `'a[data-link]'` | Selector for intercepted link clicks |
130
+
131
+ ### Router Instance
132
+
133
+ #### `router.start()`
134
+
135
+ Starts listening for link clicks and popstate events. Immediately navigates to the current URL.
136
+
137
+ #### `router.stop()`
138
+
139
+ Removes event listeners and calls the current view's unboot function.
140
+
141
+ #### `router.navigate(pathname, opts?)`
142
+
143
+ Programmatic navigation.
144
+
145
+ ```js
146
+ router.navigate('/users/42');
147
+ router.navigate('/search', { search: '?q=hello' });
148
+ router.navigate('/users', { replace: true });
149
+ ```
150
+
151
+ Options: `{ replace, search, restoreScroll }`
152
+
153
+ #### `router.navigateQuery(patch, opts?)`
154
+
155
+ Patch query parameters without changing the path.
156
+
157
+ ```js
158
+ router.navigateQuery({ tab: 'posts' }); // add/update
159
+ router.navigateQuery({ tab: null }); // remove
160
+ router.navigateQuery({ page: '2', sort: 'name' }); // multiple
161
+ ```
162
+
163
+ #### `router.getCurrent()`
164
+
165
+ Returns `{ view, path, search }` for the current route.
166
+
167
+ ## Link Interception
168
+
169
+ Any `<a>` matching `linkSelector` (default: `a[data-link]`) is intercepted for client-side navigation:
170
+
171
+ ```html
172
+ <nav>
173
+ <a href="/" data-link>Home</a>
174
+ <a href="/users" data-link>Users</a>
175
+ <a href="/users/42" data-link>User 42</a>
176
+ </nav>
177
+
178
+ <div data-route-root></div>
179
+ ```
180
+
181
+ Standard browser behavior is preserved for:
182
+ - External links (different origin)
183
+ - Modified clicks (Ctrl, Cmd, Shift, Alt, right-click)
184
+ - Links without `data-link`
185
+
186
+ ## Active Nav (Subscribe, Don't Bake In)
187
+
188
+ The router does **not** manage active nav styles. Instead, subscribe to the route path and manage your own UI:
189
+
190
+ ```js
191
+ store.subscribe('ui.route.path', (path) => {
192
+ document.querySelectorAll('nav a[data-link]').forEach(a => {
193
+ const href = new URL(a.getAttribute('href'), location.href).pathname;
194
+ a.classList.toggle('active', href === path);
195
+ });
196
+ });
197
+ ```
198
+
199
+ This keeps the router focused on state. Your nav, your rules.
200
+
201
+ ## Base Path Support
202
+
203
+ If your app is served from a subdirectory, add a `<base>` tag:
204
+
205
+ ```html
206
+ <base href="/my-app/">
207
+ ```
208
+
209
+ The router automatically detects it and adjusts all path operations.
210
+
211
+ ## Scroll Restoration
212
+
213
+ The router saves scroll positions per route and restores them on back/forward navigation. Forward navigation scrolls to top.
214
+
215
+ ## Accessibility
216
+
217
+ On every navigation, the router:
218
+ 1. Sets `tabindex="-1"` on the root element (if not already set)
219
+ 2. Focuses the root element (with `preventScroll`)
220
+
221
+ This ensures screen readers announce the new content.
222
+
223
+ ## CSS Hooks
224
+
225
+ The router sets attributes on `<html>` for CSS-driven transitions:
226
+
227
+ ```css
228
+ /* Style based on current view */
229
+ [data-view="home"] .hero { display: block; }
230
+ [data-view="user"] .sidebar { display: flex; }
231
+
232
+ /* Transition states */
233
+ [data-transitioning="on"] [data-route-root] {
234
+ opacity: 0.5;
235
+ pointer-events: none;
236
+ }
237
+ ```
238
+
239
+ ## Philosophy
240
+
241
+ Routing is not special. It's a `set` call to a path in a JSON tree. The router writes `ui.route.*`, and anything that cares about routing subscribes to `ui.route.*`. The router doesn't know about your nav, your breadcrumbs, your analytics, or your loading spinners. They all subscribe independently. That's UIState: EventState + Routing.
242
+
243
+ ## License
244
+
245
+ MIT
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createRouter } from './router.js';
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@uistate/router",
3
+ "version": "1.0.0",
4
+ "description": "SPA router for EventState stores — routing is just state",
5
+ "main": "index.js",
6
+ "module": "index.js",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./index.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "router.js",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "router",
19
+ "spa",
20
+ "eventstate",
21
+ "uistate",
22
+ "state-management",
23
+ "path-based",
24
+ "vanilla-js",
25
+ "framework-agnostic"
26
+ ],
27
+ "author": "",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/ImsirovicAjdin/uistate-router"
32
+ },
33
+ "homepage": "https://uistate.com",
34
+ "peerDependencies": {
35
+ "@uistate/core": ">=5.6.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@uistate/core": {
39
+ "optional": true
40
+ }
41
+ }
42
+ }
package/router.js ADDED
@@ -0,0 +1,365 @@
1
+ // @uistate/router — SPA router factory for EventState stores
2
+ // Routing is just state: navigate() writes to store paths, components subscribe.
3
+
4
+ /**
5
+ * Compile a route pattern like '/users/:id/posts/:postId' into a matcher.
6
+ * Returns { regex, paramNames } for extraction.
7
+ */
8
+ function compilePattern(pattern) {
9
+ const paramNames = [];
10
+ const parts = pattern.split(/:([a-zA-Z_][a-zA-Z0-9_]*)/);
11
+ const regexStr = parts
12
+ .map((part, i) => {
13
+ if (i % 2 === 1) {
14
+ paramNames.push(part);
15
+ return '([^/]+)';
16
+ }
17
+ return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
+ })
19
+ .join('');
20
+ return { regex: new RegExp('^' + regexStr + '$'), paramNames };
21
+ }
22
+
23
+ /**
24
+ * Create a SPA router bound to an EventState store.
25
+ *
26
+ * @param {Object} config
27
+ * @param {Array} config.routes - [{ path: '/users/:id', view: 'user', component: UserView }]
28
+ * @param {Object} [config.store] - EventState store instance
29
+ * @param {string} [config.rootSelector='[data-route-root]'] - Root element for view mounting
30
+ * @param {Object} [config.fallback] - Fallback route when nothing matches
31
+ * @param {boolean} [config.debug=false]
32
+ * @param {string} [config.linkSelector='a[data-link]'] - Selector for intercepted links
33
+ * @param {string} [config.navSelector='nav a[data-link]'] - Selector for nav links to toggle .active class
34
+ *
35
+ * Store-driven navigation (requires store):
36
+ * Any code with store access can navigate without importing the router:
37
+ * - store.set('ui.route.go', '/about')
38
+ * - store.set('ui.route.go', { path: '/users/1', search: '?tab=posts' })
39
+ * - store.set('ui.route.go', { query: { tab: 'posts' } }) // patch query only
40
+ */
41
+ export function createRouter(config) {
42
+ const {
43
+ routes = [],
44
+ store,
45
+ rootSelector = '[data-route-root]',
46
+ fallback = null,
47
+ debug = false,
48
+ linkSelector = 'a[data-link]',
49
+ navSelector = 'nav a[data-link]',
50
+ } = config;
51
+
52
+ // Pre-compile route patterns
53
+ const compiled = routes.map(route => ({
54
+ ...route,
55
+ ...compilePattern(route.path),
56
+ }));
57
+
58
+ const compiledFallback = fallback
59
+ ? { ...fallback, ...compilePattern(fallback.path || '/*'), params: {} }
60
+ : null;
61
+
62
+ // Detect base path from <base href> if present
63
+ const BASE_PATH = (() => {
64
+ const b = document.querySelector('base[href]');
65
+ if (!b) return '';
66
+ try {
67
+ const u = new URL(b.getAttribute('href'), location.href);
68
+ let p = u.pathname;
69
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
70
+ return p;
71
+ } catch { return ''; }
72
+ })();
73
+
74
+ function stripBase(pathname) {
75
+ if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
76
+ const rest = pathname.slice(BASE_PATH.length) || '/';
77
+ return rest.startsWith('/') ? rest : ('/' + rest);
78
+ }
79
+ return pathname;
80
+ }
81
+
82
+ function withBase(pathname) {
83
+ if (!BASE_PATH) return pathname;
84
+ if (pathname === '/') return BASE_PATH || '/';
85
+ return BASE_PATH + (pathname.startsWith('/') ? '' : '/') + pathname;
86
+ }
87
+
88
+ function normalizePath(p) {
89
+ if (!p) return '/';
90
+ if (p[0] !== '/') p = '/' + p;
91
+ if (p === '/index.html') return '/';
92
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
93
+ return p;
94
+ }
95
+
96
+ function resolve(pathname) {
97
+ const p = normalizePath(pathname);
98
+ for (const route of compiled) {
99
+ const match = p.match(route.regex);
100
+ if (match) {
101
+ const params = {};
102
+ route.paramNames.forEach((name, i) => {
103
+ params[name] = decodeURIComponent(match[i + 1]);
104
+ });
105
+ return { path: route.path, view: route.view, component: route.component, params };
106
+ }
107
+ }
108
+ if (compiledFallback) return { ...compiledFallback, params: {} };
109
+ return null;
110
+ }
111
+
112
+ function getRoot() {
113
+ const el = document.querySelector(rootSelector);
114
+ if (!el) throw new Error('[router] Route root not found: ' + rootSelector);
115
+ return el;
116
+ }
117
+
118
+ function log(...args) {
119
+ if (debug) console.debug('[router]', ...args);
120
+ }
121
+
122
+ function setActiveNav(pathname) {
123
+ document.querySelectorAll(navSelector).forEach(a => {
124
+ const url = new URL(a.getAttribute('href'), location.href);
125
+ const linkPath = normalizePath(stripBase(url.pathname));
126
+ const here = normalizePath(pathname);
127
+ const isExact = linkPath === here;
128
+ const isParent = !isExact && linkPath !== '/' && here.startsWith(linkPath);
129
+ const active = isExact || isParent;
130
+ a.classList.toggle('active', active);
131
+ if (isExact) a.setAttribute('aria-current', 'page');
132
+ else a.removeAttribute('aria-current');
133
+ });
134
+ }
135
+
136
+ // Internal state
137
+ let current = { viewKey: null, unboot: null, path: null, search: '' };
138
+ let navController = null;
139
+ const scrollPositions = new Map();
140
+ history.scrollRestoration = 'manual';
141
+
142
+ /**
143
+ * Navigate to a pathname.
144
+ * @param {string} pathname
145
+ * @param {Object} [opts]
146
+ * @param {boolean} [opts.replace=false]
147
+ * @param {string} [opts.search='']
148
+ * @param {boolean} [opts.restoreScroll=false]
149
+ */
150
+ async function navigate(pathname, { replace = false, search = '', restoreScroll = false } = {}) {
151
+ const root = getRoot();
152
+ const appPath = normalizePath(stripBase(pathname));
153
+ const resolved = resolve(appPath);
154
+
155
+ if (!resolved) {
156
+ log('no route found for:', appPath);
157
+ return;
158
+ }
159
+
160
+ const viewKey = resolved.view;
161
+ const component = resolved.component;
162
+ const searchStr = search && search.startsWith('?') ? search : (search ? ('?' + search) : '');
163
+
164
+ log('navigate', { from: current.path, to: appPath, view: viewKey, params: resolved.params });
165
+
166
+ // Same-route no-op guard
167
+ if (current.path === appPath && current.search === searchStr) {
168
+ return;
169
+ }
170
+
171
+ // Abort in-flight boot
172
+ if (navController) navController.abort();
173
+ navController = new AbortController();
174
+ const { signal } = navController;
175
+
176
+ // Transition start
177
+ const html = document.documentElement;
178
+ html.setAttribute('data-transitioning', 'on');
179
+ if (store) {
180
+ try { store.set('ui.route.transitioning', true); } catch {}
181
+ }
182
+
183
+ // Save scroll position for current route
184
+ if (current.path) {
185
+ scrollPositions.set(current.path, { x: scrollX, y: scrollY });
186
+ if (scrollPositions.size > 50) scrollPositions.delete(scrollPositions.keys().next().value);
187
+ }
188
+
189
+ // Unboot previous view
190
+ if (typeof current.unboot === 'function') {
191
+ try { await current.unboot(); } catch {}
192
+ }
193
+
194
+ // Clear root
195
+ root.replaceChildren();
196
+
197
+ // Boot new view
198
+ let unboot = null;
199
+ if (component && typeof component.boot === 'function') {
200
+ unboot = await component.boot({ store, el: root, signal, params: resolved.params });
201
+ }
202
+
203
+ // Guard: if navigation was superseded during boot, bail out
204
+ if (signal.aborted) return;
205
+
206
+ const prevViewKey = current.viewKey;
207
+ current = { viewKey, unboot, path: appPath, search: searchStr };
208
+
209
+ // Parse query params
210
+ const fullUrl = new URL(location.origin + withBase(appPath) + searchStr);
211
+ const query = {};
212
+ fullUrl.searchParams.forEach((v, k) => { query[k] = v; });
213
+
214
+ // Update store with route state + end transition atomically
215
+ if (store) {
216
+ try {
217
+ store.setMany({
218
+ 'ui.route.view': viewKey,
219
+ 'ui.route.path': appPath,
220
+ 'ui.route.params': resolved.params || {},
221
+ 'ui.route.query': query,
222
+ 'ui.route.transitioning': false,
223
+ });
224
+ } catch {}
225
+ }
226
+
227
+ // Update browser history
228
+ const useReplace = replace;
229
+ if (useReplace) history.replaceState({}, '', withBase(appPath) + searchStr);
230
+ else history.pushState({}, '', withBase(appPath) + searchStr);
231
+
232
+ // Set view attribute on <html> for CSS hooks
233
+ html.setAttribute('data-view', viewKey);
234
+ html.setAttribute('data-transitioning', 'off');
235
+
236
+ // Update nav active state
237
+ setActiveNav(appPath);
238
+
239
+ // Focus management (accessibility)
240
+ if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1');
241
+ try { root.focus({ preventScroll: true }); } catch {}
242
+
243
+ // Scroll
244
+ if (restoreScroll) {
245
+ const pos = scrollPositions.get(appPath);
246
+ if (pos) scrollTo(pos.x, pos.y);
247
+ } else {
248
+ scrollTo(0, 0);
249
+ }
250
+
251
+ log('routed', { view: viewKey, path: appPath, params: resolved.params, query });
252
+ }
253
+
254
+ /**
255
+ * Patch query parameters without changing the path.
256
+ * Pass null/undefined/'' as a value to remove a key.
257
+ */
258
+ function navigateQuery(patch = {}, { replace = true } = {}) {
259
+ const params = new URLSearchParams(current.search?.replace(/^\?/, '') || '');
260
+ for (const [k, v] of Object.entries(patch)) {
261
+ if (v === null || v === undefined || v === '') params.delete(k);
262
+ else params.set(k, String(v));
263
+ }
264
+ const searchStr = params.toString();
265
+ const prefixed = searchStr ? ('?' + searchStr) : '';
266
+ const path = current.path || normalizePath(stripBase(location.pathname));
267
+ return navigate(path, { search: prefixed, replace });
268
+ }
269
+
270
+ /**
271
+ * Navigate to a new path, keeping the current search string.
272
+ * @param {string} path
273
+ * @param {Object} [opts]
274
+ * @param {boolean} [opts.replace=true]
275
+ */
276
+ function navigatePath(path, { replace = true } = {}) {
277
+ const appPath = normalizePath(stripBase(path));
278
+ const searchStr = current.search || '';
279
+ return navigate(appPath, { search: searchStr, replace });
280
+ }
281
+
282
+ // Event handlers
283
+ function onClick(e) {
284
+ const a = e.target.closest(linkSelector);
285
+ if (!a) return;
286
+ if (e.defaultPrevented) return;
287
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
288
+ const href = a.getAttribute('href');
289
+ if (!href) return;
290
+ const url = new URL(href, location.href);
291
+ if (url.origin !== location.origin) return;
292
+ e.preventDefault();
293
+ log('click', { href, text: a.textContent.trim() });
294
+ navigate(url.pathname, { search: url.search }).catch(() => {});
295
+ }
296
+
297
+ function onPop() {
298
+ navigate(location.pathname, {
299
+ replace: true,
300
+ search: location.search,
301
+ restoreScroll: true,
302
+ }).catch(() => {});
303
+ }
304
+
305
+ // Store-driven navigation: write ui.route.go to navigate from anywhere
306
+ let unsubGo = null;
307
+ let processingGo = false;
308
+ if (store) {
309
+ unsubGo = store.subscribe('ui.route.go', (value) => {
310
+ if (processingGo || !value) return;
311
+ processingGo = true;
312
+ try { store.set('ui.route.go', null); } catch {}
313
+ processingGo = false;
314
+
315
+ if (typeof value === 'string') {
316
+ navigate(value).catch(() => {});
317
+ } else if (typeof value === 'object') {
318
+ if (!value.path && value.query) {
319
+ navigateQuery(value.query, { replace: value.replace ?? true }).catch(() => {});
320
+ } else {
321
+ navigate(value.path || '/', {
322
+ search: value.search || '',
323
+ replace: value.replace || false,
324
+ }).catch(() => {});
325
+ }
326
+ }
327
+ });
328
+ }
329
+
330
+ // Public API
331
+ return {
332
+ navigate,
333
+ navigateQuery,
334
+ navigatePath,
335
+
336
+ start() {
337
+ window.addEventListener('click', onClick);
338
+ window.addEventListener('popstate', onPop);
339
+ navigate(location.pathname, {
340
+ replace: true,
341
+ search: location.search,
342
+ restoreScroll: true,
343
+ });
344
+ return this;
345
+ },
346
+
347
+ stop() {
348
+ window.removeEventListener('click', onClick);
349
+ window.removeEventListener('popstate', onPop);
350
+ if (unsubGo) { unsubGo(); unsubGo = null; }
351
+ if (typeof current.unboot === 'function') {
352
+ try { Promise.resolve(current.unboot()).catch(() => {}); } catch {}
353
+ }
354
+ return this;
355
+ },
356
+
357
+ getCurrent() {
358
+ return {
359
+ view: current.viewKey,
360
+ path: current.path,
361
+ search: current.search,
362
+ };
363
+ },
364
+ };
365
+ }