bobe-router 0.0.66 → 0.0.68

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/dist/index.umd.js CHANGED
@@ -4,6 +4,13 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.BobeRouter = {}, global.Aoye));
5
5
  })(this, (function (exports, aoye) { 'use strict';
6
6
 
7
+ let GlobalKey = function (GlobalKey) {
8
+ GlobalKey["Routes"] = "__BOBE_INIT_ROUTES__";
9
+ GlobalKey["Menus"] = "__BOBE_INIT_MENUS__";
10
+ GlobalKey["Path"] = "__BOBE_INIT_PATH__";
11
+ return GlobalKey;
12
+ }({});
13
+
7
14
  function compilePattern(pattern) {
8
15
  const paramNames = [];
9
16
  const regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/:([a-zA-Z_]\w*)/g, (_, name) => {
@@ -40,7 +47,11 @@
40
47
  return compiledCache;
41
48
  }
42
49
  function match(path, map) {
43
- const normalized = path.startsWith('/') ? path : `/${path}`;
50
+ const parsed = new URL(path, 'http://localhost');
51
+ let normalized = parsed.pathname;
52
+ if (normalized.length > 1 && normalized.endsWith('/')) {
53
+ normalized = normalized.slice(0, -1);
54
+ }
44
55
  for (const compiled of compileRoutes(map)) {
45
56
  const m = normalized.match(compiled.regex);
46
57
  if (!m) continue;
@@ -50,19 +61,113 @@
50
61
  }
51
62
  return {
52
63
  path: compiled.pattern,
53
- params
64
+ params,
65
+ url: normalized
54
66
  };
55
67
  }
56
68
  return null;
57
69
  }
58
70
 
59
- let GlobalKey = function (GlobalKey) {
60
- GlobalKey["Routes"] = "__BOBE_INIT_ROUTES__";
61
- GlobalKey["Menus"] = "__BOBE_INIT_MENUS__";
62
- GlobalKey["Path"] = "__BOBE_INIT_PATH__";
63
- return GlobalKey;
64
- }({});
71
+ class NavigationTransactionRunner {
72
+ #nextTokenId = 0;
73
+ #currentTokenId = 0;
74
+ createToken(request) {
75
+ const token = {
76
+ id: ++this.#nextTokenId,
77
+ request,
78
+ cancelled: false
79
+ };
80
+ this.#currentTokenId = token.id;
81
+ return token;
82
+ }
83
+ isTokenValid(ctx) {
84
+ return !ctx.token.cancelled && ctx.token.id === this.#currentTokenId;
85
+ }
86
+ isTokenIdCurrent(tokenId) {
87
+ return tokenId === this.#currentTokenId;
88
+ }
89
+ cancelCurrent() {
90
+ this.#currentTokenId = ++this.#nextTokenId;
91
+ }
92
+ async run(router, handlers, request) {
93
+ const ctx = {
94
+ router,
95
+ request,
96
+ token: this.createToken(request),
97
+ status: 'running',
98
+ rollbackStack: []
99
+ };
100
+ let queue = [...handlers];
101
+ while (queue.length > 0) {
102
+ const handler = queue.shift();
103
+ let result;
104
+ try {
105
+ result = await handler(ctx);
106
+ } catch (error) {
107
+ ctx.error = error;
108
+ ctx.status = 'error';
109
+ await this.#rollback(ctx);
110
+ return {
111
+ status: ctx.status,
112
+ ctx,
113
+ error
114
+ };
115
+ }
116
+ if (!this.isTokenValid(ctx)) {
117
+ ctx.status = 'cancelled';
118
+ return {
119
+ status: ctx.status,
120
+ ctx
121
+ };
122
+ }
123
+ if (!result) continue;
124
+ if (result.type === 'stop') {
125
+ ctx.status = result.status ?? ctx.status;
126
+ return {
127
+ status: ctx.status,
128
+ ctx
129
+ };
130
+ }
131
+ if (result.type === 'prepend') {
132
+ queue.unshift(...result.handlers);
133
+ continue;
134
+ }
135
+ if (result.type === 'replace') {
136
+ queue = [...result.handlers];
137
+ }
138
+ }
139
+ if (ctx.status === 'running') ctx.status = 'completed';
140
+ return {
141
+ status: ctx.status,
142
+ ctx
143
+ };
144
+ }
145
+ stop(status = 'completed') {
146
+ return {
147
+ type: 'stop',
148
+ status
149
+ };
150
+ }
151
+ replace(handlers) {
152
+ return {
153
+ type: 'replace',
154
+ handlers
155
+ };
156
+ }
157
+ prepend(handlers) {
158
+ return {
159
+ type: 'prepend',
160
+ handlers
161
+ };
162
+ }
163
+ async #rollback(ctx) {
164
+ for (let i = ctx.rollbackStack.length - 1; i >= 0; i--) {
165
+ await ctx.rollbackStack[i](ctx);
166
+ }
167
+ }
168
+ }
65
169
 
170
+ const DEFAULT_HISTORY_KEY = '__bobe_router_runtime__';
66
171
  function createRouteRecord(opts = {}) {
67
172
  return {
68
173
  import: opts.import,
@@ -74,264 +179,934 @@
74
179
  };
75
180
  }
76
181
  class Router extends aoye.Store {
77
- static [aoye.StoreIgnoreKeys] = ['routes', 'menus', 'stack', 'ready'];
182
+ static [aoye.StoreIgnoreKeys] = ['routes', 'menus', 'stack', 'ready', 'scrollRootId', 'historyKey', 'maxPreload', 'onError', 'loadTimeout', 'onTimeout'];
78
183
  active = null;
79
184
  routes = {};
80
185
  menus = [];
81
- stack = [];
82
- stackIndex = 0;
83
- idleSet = new Set();
84
186
  maxPreload = 3;
85
- loadingCount = 0;
187
+ historyKey = DEFAULT_HISTORY_KEY;
188
+ #stack = [];
189
+ #stackIndex = 0;
190
+ #ready = false;
191
+ #readyQueue = [];
192
+ #idleSet = new Set();
193
+ #loadingCount = 0;
194
+ #pendingHistoryDelta = null;
195
+ #initialHistoryState = null;
196
+ #transaction = new NavigationTransactionRunner();
197
+ #entryId = 0;
198
+ constructor(opt) {
199
+ super();
200
+ this.routes = opt?.routes || globalThis[GlobalKey.Routes] || {};
201
+ this.menus = globalThis[GlobalKey.Menus] || [];
202
+ this.enterGuard = opt?.enterGuard;
203
+ this.leaveGuard = opt?.leaveGuard;
204
+ this.onError = opt?.onError;
205
+ this.loadTimeout = opt?.loadTimeout;
206
+ this.onTimeout = opt?.onTimeout;
207
+ this.scrollRootId = opt?.scrollRootId;
208
+ this.maxPreload = opt?.maxPreload ?? this.maxPreload;
209
+ this.historyKey = opt?.historyKey || DEFAULT_HISTORY_KEY;
210
+ this.#initialHistoryState = this.#hasBrowser() ? this.#readHistoryState(history.state) : null;
211
+ const initialUrl = opt?.initialPath || globalThis[GlobalKey.Path] || (this.#hasBrowser() ? location.pathname + location.search + location.hash : '/');
212
+ this.#initIdleSet();
213
+ void this.#init(initialUrl);
214
+ }
86
215
  ready(cb) {
87
216
  if (cb) {
88
- if (this.#inited) {
89
- cb();
90
- } else {
91
- this.#readyQueue.push(cb);
92
- }
217
+ if (this.#ready) cb();else this.#readyQueue.push(cb);
93
218
  return;
94
219
  }
95
220
  return new Promise(resolve => this.ready(resolve));
96
221
  }
97
- #inited = false;
98
- #readyQueue = [];
99
- constructor(opt) {
100
- super();
101
- const routes = opt?.routes;
102
- const initialPath = opt?.initialPath;
103
- this.routes = routes || globalThis[GlobalKey.Routes] || {};
104
- const injectedMenus = globalThis[GlobalKey.Menus];
105
- if (injectedMenus) this.menus = injectedMenus;
106
- const path = initialPath || globalThis[GlobalKey.Path] || (typeof location !== 'undefined' ? location.pathname : '/');
107
- this.#init(path);
108
- }
109
- async #init(path) {
110
- this.#initIdleSet();
111
- const result = match(path, this.routes);
112
- if (result) {
113
- const route = this.routes[result.path];
114
- if (route?.component) {
115
- route.status = 'loaded';
116
- } else {
117
- await this.#loadComponent(result.path);
118
- }
119
- this.active = {
120
- path,
121
- params: result.params,
122
- component: route?.component,
123
- meta: route?.meta,
124
- layout: route?.layout
222
+ async pushState(url) {
223
+ const request = {
224
+ source: 'api',
225
+ url,
226
+ historyMode: 'push',
227
+ runGuards: true,
228
+ restoreScroll: false
229
+ };
230
+ return this.#runNavigation(request, [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntry, this.#handleSaveCurrentScroll, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildCommitPlan('push', 'append', () => this.#stackIndex + 1), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
231
+ }
232
+ async replaceState(url) {
233
+ const request = {
234
+ source: 'api',
235
+ url,
236
+ historyMode: 'replace',
237
+ runGuards: true,
238
+ restoreScroll: false
239
+ };
240
+ return this.#runNavigation(request, this.#replaceHandlers(request));
241
+ }
242
+ async back() {
243
+ return this.#goByDelta(-1);
244
+ }
245
+ async forward() {
246
+ return this.#goByDelta(1);
247
+ }
248
+ async go(delta) {
249
+ const request = {
250
+ source: 'api',
251
+ historyMode: 'browser-delta',
252
+ delta,
253
+ runGuards: true,
254
+ restoreScroll: true
255
+ };
256
+ if (delta === 0) {
257
+ return {
258
+ status: 'ignored',
259
+ ctx: {
260
+ router: this,
261
+ request,
262
+ token: {
263
+ id: 0,
264
+ request,
265
+ cancelled: false
266
+ },
267
+ status: 'ignored',
268
+ rollbackStack: []
269
+ }
125
270
  };
126
271
  }
127
- this.stack = [{
128
- path,
129
- params: this.active?.params ?? {}
130
- }];
131
- this.stackIndex = 0;
272
+ return this.#goByDelta(delta, request);
273
+ }
274
+ async #init(url) {
275
+ const request = {
276
+ source: 'init',
277
+ url,
278
+ historyMode: 'replace',
279
+ runGuards: true,
280
+ restoreScroll: false
281
+ };
282
+ await this.#runNavigation(request, [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntry, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildCommitPlan('replace', 'replace-current', ctx => {
283
+ const existingState = this.#initialHistoryState;
284
+ if (existingState && ctx.to) ctx.to.id = existingState.entryId;
285
+ return existingState?.index ?? 0;
286
+ }), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
132
287
  this.#initBrowser();
133
- this.#inited = true;
134
- const q = this.#readyQueue;
288
+ this.#ready = true;
289
+ const queue = this.#readyQueue;
135
290
  this.#readyQueue = [];
136
- for (const cb of q) cb();
291
+ for (const cb of queue) cb();
137
292
  }
138
- #initBrowser() {
139
- if (typeof window === 'undefined') return;
140
- document.addEventListener('click', this.#onClick);
141
- window.addEventListener('popstate', this.#onPopstate);
293
+ async #goByDelta(delta, request) {
294
+ const currentRequest = request || {
295
+ source: 'api',
296
+ historyMode: 'browser-delta',
297
+ delta,
298
+ runGuards: true,
299
+ restoreScroll: true
300
+ };
301
+ if (!this.#hasBrowser()) {
302
+ return {
303
+ status: 'ignored',
304
+ ctx: {
305
+ router: this,
306
+ request: currentRequest,
307
+ token: {
308
+ id: 0,
309
+ request: currentRequest,
310
+ cancelled: false
311
+ },
312
+ status: 'ignored',
313
+ rollbackStack: []
314
+ }
315
+ };
316
+ }
317
+ return this.#runNavigation(currentRequest, [ctx => {
318
+ ctx.historyDelta = {
319
+ delta
320
+ };
321
+ ctx.from = this.active;
322
+ }, this.#handleResolveHistoryDeltaTarget, this.#handleBuildRouteEntryFromStack, this.#handleSaveCurrentScroll, this.#handleRunGuards, this.#handleLoadComponent, this.#handleSetPendingHistoryDelta, ctx => {
323
+ const delta = ctx.historyDelta?.delta;
324
+ if (!this.#hasBrowser() || typeof delta !== 'number') return;
325
+ history.go(delta);
326
+ }]);
142
327
  }
143
- #initIdleSet() {
144
- for (const path of Object.keys(this.routes)) {
145
- if (this.routes[path].status === 'idle') {
146
- this.idleSet.add(path);
147
- }
328
+ async #runNavigation(request, handlers) {
329
+ const result = await this.#transaction.run(this, handlers, request);
330
+ if (result.status === 'error') {
331
+ await this.#notifyNavigationError(result.error, result.ctx);
148
332
  }
149
- if (this.active) {
150
- this.idleSet.delete(this.active.path);
333
+ return result;
334
+ }
335
+ async #notifyNavigationError(error, ctx) {
336
+ if (!this.onError) {
337
+ console.error(error);
338
+ return;
339
+ }
340
+ try {
341
+ await this.onError(error, ctx);
342
+ } catch (notifyError) {
343
+ console.error(notifyError);
151
344
  }
152
345
  }
153
- async pushState(url) {
154
- const result = match(url, this.routes);
155
- if (!result) return;
156
- const target = {
157
- path: url,
158
- params: result.params
159
- };
160
- this.stack.length = this.stackIndex + 1;
161
- this.stack.push(target);
162
- this.stackIndex = this.stack.length - 1;
163
- await this.#navigate(target);
346
+ #replaceHandlers(request) {
347
+ return [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntry, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildCommitPlan('replace', 'replace-current', () => this.#stackIndex), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload];
164
348
  }
165
- async replaceState(url) {
166
- const result = match(url, this.routes);
167
- if (!result) return;
168
- const target = {
169
- path: url,
170
- params: result.params
171
- };
172
- this.stack[this.stackIndex] = target;
173
- await this.#navigate(target, {
174
- replace: true
175
- });
349
+ #hashOnlyScrollHandlers(url) {
350
+ return [ctx => {
351
+ ctx.url = url;
352
+ ctx.from = this.active;
353
+ ctx.to = this.active ?? undefined;
354
+ ctx.scrollIntent = this.#createScrollIntentFromUrl(url, undefined, false);
355
+ ctx.commitPlan = {
356
+ historyAction: 'none',
357
+ stackAction: 'none',
358
+ scrollIntent: ctx.scrollIntent
359
+ };
360
+ if (this.active) {
361
+ const parsed = this.#parseUrl(url);
362
+ this.active.url = url;
363
+ this.active.hash = parsed.hash;
364
+ if (this.#stack[this.#stackIndex]?.id === this.active.id) {
365
+ this.#stack[this.#stackIndex].url = url;
366
+ this.#stack[this.#stackIndex].hash = parsed.hash;
367
+ }
368
+ }
369
+ }, this.#handlePrepareScrollWaiter, this.#handleScroll];
176
370
  }
177
- async back() {
178
- if (this.stackIndex <= 0) return;
179
- if (!(await this.#checkGuard(this.active, 'leave'))) return;
180
- const target = this.stack[this.stackIndex - 1];
181
- if (!(await this.#checkGuard(target, 'enter'))) return;
182
- history.back();
371
+ #handleCreateUrlContext(request) {
372
+ return ctx => {
373
+ ctx.request = request;
374
+ ctx.url = request.url;
375
+ ctx.from = this.active;
376
+ };
183
377
  }
184
- async forward() {
185
- if (this.stackIndex >= this.stack.length - 1) return;
186
- if (!(await this.#checkGuard(this.active, 'leave'))) return;
187
- const target = this.stack[this.stackIndex + 1];
188
- if (!(await this.#checkGuard(target, 'enter'))) return;
189
- history.forward();
378
+ #handleBuildRouteEntry = ctx => {
379
+ if (!ctx.url) return this.#transaction.stop('ignored');
380
+ const routeMatch = this.#matchUrl(ctx.url);
381
+ if (!routeMatch) return this.#transaction.stop('ignored');
382
+ ctx.match = routeMatch;
383
+ ctx.to = this.#createRouteEntry(routeMatch);
384
+ ctx.scrollIntent = this.#createScrollIntentFromUrl(ctx.to.url);
385
+ };
386
+ #handleBuildRouteEntryFromStack = ctx => {
387
+ const target = ctx.historyDelta?.targetEntry;
388
+ if (!target) {
389
+ this.#allowExternalHistoryDelta(ctx);
390
+ return this.#transaction.stop('completed');
391
+ }
392
+ ctx.to = target;
393
+ ctx.url = target.url;
394
+ const routeMatch = this.#matchUrl(target.url);
395
+ if (routeMatch) ctx.match = routeMatch;
396
+ ctx.scrollIntent = target.scroll ? this.#createRestoreScrollIntent(target.scroll) : this.#createScrollIntentFromUrl(target.url);
397
+ };
398
+ #handleBuildRouteEntryFromHistoryState(state) {
399
+ return ctx => {
400
+ const entry = this.#stack[state.index];
401
+ ctx.historyDelta = {
402
+ delta: state.index - this.#stackIndex,
403
+ targetState: state,
404
+ targetEntry: entry
405
+ };
406
+ if (entry && entry.id === state.entryId) {
407
+ ctx.to = entry;
408
+ ctx.url = entry.url;
409
+ const routeMatch = this.#matchUrl(entry.url);
410
+ if (routeMatch) ctx.match = routeMatch;
411
+ ctx.scrollIntent = entry.scroll ? this.#createRestoreScrollIntent(entry.scroll) : this.#createScrollIntentFromUrl(entry.url);
412
+ return;
413
+ }
414
+ const routeMatch = this.#matchUrl(ctx.url || state.url);
415
+ if (!routeMatch) return this.#transaction.stop('ignored');
416
+ ctx.match = routeMatch;
417
+ ctx.to = this.#createRouteEntry(routeMatch, state.entryId);
418
+ ctx.historyDelta.targetEntry = ctx.to;
419
+ ctx.scrollIntent = this.#createScrollIntentFromUrl(ctx.to.url);
420
+ };
190
421
  }
191
- async go(delta) {
192
- if (delta === 0) return;
193
- const newIdx = this.stackIndex + delta;
194
- if (newIdx < 0 || newIdx >= this.stack.length) return;
195
- if (!(await this.#checkGuard(this.active, 'leave'))) return;
196
- if (!(await this.#checkGuard(this.stack[newIdx], 'enter'))) return;
197
- history.go(delta);
422
+ #handleResolveHistoryDeltaTarget = ctx => {
423
+ const delta = ctx.historyDelta?.delta ?? 0;
424
+ const nextIndex = this.#stackIndex + delta;
425
+ if (nextIndex < 0 || nextIndex >= this.#stack.length || !this.#stack[nextIndex]) {
426
+ this.#allowExternalHistoryDelta(ctx);
427
+ return this.#transaction.stop('completed');
428
+ }
429
+ ctx.historyDelta = {
430
+ delta,
431
+ targetEntry: this.#stack[nextIndex],
432
+ targetState: this.#createHistoryState(this.#stack[nextIndex], nextIndex)
433
+ };
434
+ };
435
+ #handleRunGuards = async ctx => {
436
+ if (ctx.request && !ctx.request.runGuards) {
437
+ ctx.guard = {
438
+ type: 'allowed'
439
+ };
440
+ return;
441
+ }
442
+ if (!ctx.to) return this.#transaction.stop('ignored');
443
+ const decision = await this.#runGuardsOnce(ctx.from ?? null, ctx.to);
444
+ if (!this.#transaction.isTokenValid(ctx)) return this.#transaction.stop('cancelled');
445
+ ctx.guard = decision;
446
+ if (decision.type === 'allowed') return;
447
+ if (decision.type === 'blocked') {
448
+ this.#rollbackBrowserHistoryIfNeeded(ctx);
449
+ return this.#transaction.stop('blocked');
450
+ }
451
+ return this.#transaction.replace(this.#replaceHandlers({
452
+ source: 'redirect',
453
+ url: decision.to,
454
+ historyMode: 'replace',
455
+ runGuards: true,
456
+ restoreScroll: false
457
+ }));
458
+ };
459
+ #handleLoadComponent = async ctx => {
460
+ if (!ctx.to) return this.#transaction.stop('ignored');
461
+ const pattern = ctx.match?.pattern || this.#matchUrl(ctx.to.url)?.pattern || ctx.to.path;
462
+ await this.#loadComponentWithTimeout(pattern, ctx);
463
+ if (!this.#transaction.isTokenValid(ctx)) return this.#transaction.stop('cancelled');
464
+ this.#syncRouteRecordToEntry(ctx.to, pattern);
465
+ };
466
+ #handleSaveCurrentScroll = () => {
467
+ this.#saveScroll(this.#stackIndex);
468
+ };
469
+ #handleBuildCommitPlan(historyAction, stackAction, getNextStackIndex) {
470
+ return ctx => {
471
+ if (!ctx.to) return this.#transaction.stop('ignored');
472
+ const nextStackIndex = getNextStackIndex(ctx);
473
+ ctx.commitPlan = {
474
+ historyState: this.#createHistoryState(ctx.to, nextStackIndex),
475
+ historyAction,
476
+ stackAction,
477
+ nextStackIndex,
478
+ scrollIntent: ctx.scrollIntent
479
+ };
480
+ };
198
481
  }
199
- navId = 0;
200
- async #navigate(target, opts = {}) {
201
- const id = ++this.navId;
202
- if (!(await this.#checkGuard(this.active, 'leave'))) return;
203
- if (id !== this.navId) return;
204
- if (!(await this.#checkGuard(target, 'enter'))) return;
205
- if (id !== this.navId) return;
206
- if (this.active && this.stack[this.stackIndex]) {
207
- this.stack[this.stackIndex].scroll = window.scrollY;
208
- }
209
- if (!opts.replace) {
210
- history.pushState(null, '', target.path);
482
+ #handleBuildPopstateMoveCommitPlan(state) {
483
+ return ctx => {
484
+ if (!ctx.to) return this.#transaction.stop('ignored');
485
+ const hasMemoryEntry = !!this.#stack[state.index] && this.#stack[state.index].id === state.entryId;
486
+ ctx.commitPlan = {
487
+ historyState: hasMemoryEntry ? state : this.#createHistoryState(ctx.to, 0),
488
+ historyAction: hasMemoryEntry ? 'none' : 'replace',
489
+ stackAction: hasMemoryEntry ? 'move-index' : 'reset',
490
+ nextStackIndex: hasMemoryEntry ? state.index : 0,
491
+ scrollIntent: ctx.scrollIntent
492
+ };
493
+ };
494
+ }
495
+ #handleCommitHistory = ctx => {
496
+ const action = ctx.commitPlan?.historyAction;
497
+ if (!action || action === 'none') return;
498
+ if (!this.#hasBrowser() || !ctx.to || !ctx.commitPlan?.historyState) return;
499
+ const previousState = history.state;
500
+ const previousUrl = this.#toUrlString(location);
501
+ if (action === 'push') {
502
+ history.pushState(ctx.commitPlan.historyState, '', ctx.to.url);
503
+ ctx.rollbackStack.push(() => {
504
+ if (this.#hasBrowser()) history.go(-1);
505
+ });
211
506
  } else {
212
- history.replaceState(null, '', target.path);
213
- }
214
- await this.#loadComponent(target.path);
215
- if (id !== this.navId) return;
216
- const route = this.routes[target.path];
217
- target.component = route?.component;
218
- target.meta = route?.meta;
219
- target.layout = route?.layout;
220
- this.active = target;
221
- const hash = new URL(target.path, location.origin).hash;
222
- if (hash) {
223
- const el = document.querySelector(decodeURIComponent(hash));
224
- if (el) el.scrollIntoView({
225
- behavior: 'smooth'
507
+ history.replaceState(ctx.commitPlan.historyState, '', ctx.to.url);
508
+ ctx.rollbackStack.push(() => {
509
+ if (this.#hasBrowser()) history.replaceState(previousState, '', previousUrl);
226
510
  });
227
511
  }
512
+ };
513
+ #handleCommitStack = ctx => {
514
+ const action = ctx.commitPlan?.stackAction;
515
+ if (!action || action === 'none') return;
516
+ const previousStack = this.#stack.slice();
517
+ const previousStackIndex = this.#stackIndex;
518
+ const rollbackStack = () => {
519
+ this.#stack = previousStack;
520
+ this.#stackIndex = previousStackIndex;
521
+ };
522
+ if (action === 'move-index') {
523
+ const nextIndex = ctx.commitPlan?.nextStackIndex;
524
+ if (typeof nextIndex !== 'number' || !this.#stack[nextIndex]) {
525
+ return this.#transaction.stop('ignored');
526
+ }
527
+ this.#stackIndex = nextIndex;
528
+ ctx.to = ctx.to || this.#stack[nextIndex];
529
+ ctx.rollbackStack.push(rollbackStack);
530
+ return;
531
+ }
532
+ if (!ctx.to) return this.#transaction.stop('ignored');
533
+ if (action === 'append') {
534
+ this.#stack.length = this.#stackIndex + 1;
535
+ this.#stack.push(ctx.to);
536
+ this.#stackIndex = this.#stack.length - 1;
537
+ ctx.rollbackStack.push(rollbackStack);
538
+ return;
539
+ }
540
+ if (action === 'replace-current') {
541
+ const nextIndex = ctx.commitPlan?.nextStackIndex ?? this.#stackIndex;
542
+ this.#stack[nextIndex] = ctx.to;
543
+ this.#stackIndex = nextIndex;
544
+ ctx.rollbackStack.push(rollbackStack);
545
+ return;
546
+ }
547
+ if (action === 'reset') {
548
+ this.#stack = [ctx.to];
549
+ this.#stackIndex = 0;
550
+ ctx.rollbackStack.push(rollbackStack);
551
+ }
552
+ };
553
+ #handleSetActive = ctx => {
554
+ if (!ctx.to) return this.#transaction.stop('ignored');
555
+ const previousActive = this.active;
556
+ this.active = ctx.to;
557
+ ctx.rollbackStack.push(() => {
558
+ this.active = previousActive;
559
+ });
560
+ };
561
+ #handleRegisterPopstateRollback = ctx => {
562
+ if (!this.#hasBrowser()) return;
563
+ if (ctx.request?.source === 'popstate') {
564
+ const delta = ctx.historyDelta?.delta;
565
+ if (typeof delta === 'number' && delta !== 0) {
566
+ ctx.rollbackStack.push(() => {
567
+ if (this.#hasBrowser()) history.go(-delta);
568
+ });
569
+ }
570
+ return;
571
+ }
572
+ if (ctx.request?.source === 'external-popstate' && ctx.from) {
573
+ const previousEntry = ctx.from;
574
+ const previousState = this.#createHistoryState(previousEntry, this.#stackIndex);
575
+ ctx.rollbackStack.push(() => {
576
+ if (this.#hasBrowser()) history.replaceState(previousState, '', previousEntry.url);
577
+ });
578
+ }
579
+ };
580
+ #handleSetPendingHistoryDelta = ctx => {
581
+ const delta = ctx.historyDelta?.delta;
582
+ const target = ctx.historyDelta?.targetEntry;
583
+ if (typeof delta !== 'number' || !target) {
584
+ return this.#transaction.stop('ignored');
585
+ }
586
+ const toIndex = this.#stackIndex + delta;
587
+ this.#pendingHistoryDelta = {
588
+ tokenId: ctx.token.id,
589
+ delta,
590
+ fromIndex: this.#stackIndex,
591
+ toIndex,
592
+ targetEntryId: target.id,
593
+ targetUrl: target.url,
594
+ consumed: false
595
+ };
596
+ };
597
+ #handlePrepareScrollWaiter = ctx => {
598
+ const scrollIntent = ctx.scrollIntent || ctx.commitPlan?.scrollIntent;
599
+ if (!scrollIntent) return;
600
+ const needsActiveRender = ctx.commitPlan?.stackAction !== 'none';
601
+ const renderWaiter = needsActiveRender ? this.#createPostEffectWaiter() : this.#createFallbackWaiter(scrollIntent.retry.wait);
602
+ ctx.renderWaiter = renderWaiter;
603
+ if (ctx.commitPlan) ctx.commitPlan.renderWaiter = renderWaiter;
604
+ ctx.rollbackStack.push(() => renderWaiter.dispose?.());
605
+ };
606
+ #handleScroll = async ctx => {
607
+ const scrollIntent = ctx.scrollIntent || ctx.commitPlan?.scrollIntent;
608
+ if (!scrollIntent) return;
609
+ const waiter = ctx.renderWaiter || ctx.commitPlan?.renderWaiter || this.#createFallbackWaiter(scrollIntent.retry.wait);
610
+ await waiter.promise;
611
+ waiter.dispose?.();
612
+ if (!this.#transaction.isTokenValid(ctx)) return this.#transaction.stop('cancelled');
613
+ await this.#applyScrollIntent(ctx, scrollIntent);
614
+ };
615
+ #handleStartIdlePreload = () => {
228
616
  this.#preloadNext();
617
+ };
618
+ #initBrowser() {
619
+ if (!this.#hasBrowser()) return;
620
+ if ('scrollRestoration' in history) {
621
+ history.scrollRestoration = 'manual';
622
+ }
623
+ document.addEventListener('click', this.#onClick);
624
+ window.addEventListener('popstate', this.#onPopstate);
625
+ }
626
+ #onClick = event => {
627
+ const target = event.target;
628
+ if (!(target instanceof Element)) return;
629
+ const link = target.closest('a');
630
+ if (!link) return;
631
+ const href = link.getAttribute('href');
632
+ if (!href) return;
633
+ try {
634
+ const url = new URL(href, location.origin);
635
+ if (url.origin !== location.origin) return;
636
+ if (link.target === '_blank' || link.hasAttribute('download')) return;
637
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
638
+ const nextUrl = this.#toUrlString(url);
639
+ if (href.startsWith('#') && !this.#isHashOnlyForActive(nextUrl)) return;
640
+ event.preventDefault();
641
+ if (this.active && this.#isSameRouteBase(nextUrl, this.active.url) && this.#toUrlString(location) !== nextUrl) {
642
+ void this.#runNavigation(undefined, this.#hashOnlyScrollHandlers(nextUrl));
643
+ this.active.url = nextUrl;
644
+ this.active.hash = url.hash;
645
+ if (this.#stack[this.#stackIndex]) {
646
+ this.#stack[this.#stackIndex].url = nextUrl;
647
+ this.#stack[this.#stackIndex].hash = url.hash;
648
+ }
649
+ history.pushState({
650
+ ...this.#createHistoryState(this.active, this.#stackIndex),
651
+ url: nextUrl
652
+ }, '', nextUrl);
653
+ return;
654
+ }
655
+ void this.pushState(nextUrl);
656
+ } catch {}
657
+ };
658
+ #onPopstate = event => {
659
+ void this.#handlePopstate(event).catch(error => {
660
+ console.error(error);
661
+ });
662
+ };
663
+ async #handlePopstate(event) {
664
+ const url = this.#toUrlString(location);
665
+ const state = this.#readHistoryState(event.state);
666
+ if (state && this.#isValidPendingHistoryDelta(state, url)) {
667
+ const entry = this.#stack[state.index];
668
+ const scrollIntent = entry.scroll ? this.#createRestoreScrollIntent(entry.scroll) : this.#createScrollIntentFromUrl(entry.url);
669
+ await this.#runNavigation(undefined, [ctx => {
670
+ if (!this.#pendingHistoryDelta) return this.#transaction.stop('ignored');
671
+ this.#pendingHistoryDelta.consumed = true;
672
+ this.#pendingHistoryDelta = null;
673
+ }, ctx => {
674
+ ctx.to = entry;
675
+ ctx.url = entry.url;
676
+ ctx.scrollIntent = scrollIntent;
677
+ ctx.commitPlan = {
678
+ historyState: state,
679
+ historyAction: 'none',
680
+ stackAction: 'move-index',
681
+ nextStackIndex: state.index,
682
+ scrollIntent
683
+ };
684
+ }, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
685
+ return;
686
+ }
687
+ if (this.#isHashOnlyForActive(url) && (!state || state.index === this.#stackIndex)) {
688
+ await this.#runNavigation(undefined, this.#hashOnlyScrollHandlers(url));
689
+ return;
690
+ }
691
+ if (!state) {
692
+ const request = {
693
+ source: 'external-popstate',
694
+ url,
695
+ historyMode: 'replace',
696
+ runGuards: true,
697
+ restoreScroll: false
698
+ };
699
+ await this.#runNavigation(undefined, [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntry, this.#handleSaveCurrentScroll, this.#handleRegisterPopstateRollback, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildCommitPlan('replace', 'reset', () => 0), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
700
+ return;
701
+ }
702
+ const request = {
703
+ source: 'popstate',
704
+ url,
705
+ historyMode: 'none',
706
+ runGuards: true,
707
+ restoreScroll: true
708
+ };
709
+ await this.#runNavigation(undefined, [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntryFromHistoryState(state), this.#handleSaveCurrentScroll, this.#handleRegisterPopstateRollback, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildPopstateMoveCommitPlan(state), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
229
710
  }
230
- async #checkGuard(entry, type) {
231
- if (type === 'enter' && this.enterGuard) {
232
- const result = await this.enterGuard(entry);
233
- if (result === false) return false;
234
- if (typeof result === 'object' && !result.ok) return false;
711
+ async #runGuardsOnce(from, to) {
712
+ if (from && this.leaveGuard) {
713
+ const result = await this.leaveGuard(from);
714
+ const decision = this.#toGuardDecision(result);
715
+ if (decision.type !== 'allowed') return decision;
235
716
  }
236
- if (type === 'leave' && this.leaveGuard) {
237
- const result = await this.leaveGuard(entry);
238
- if (result === false) return false;
239
- if (typeof result === 'object' && !result.ok) return false;
717
+ if (this.enterGuard) {
718
+ const result = await this.enterGuard(to);
719
+ return this.#toGuardDecision(result);
240
720
  }
241
- return true;
721
+ return {
722
+ type: 'allowed'
723
+ };
242
724
  }
243
- async #loadComponent(path) {
244
- const route = this.routes[path];
245
- if (!route) return undefined;
725
+ #toGuardDecision(result) {
726
+ if (result === false) return {
727
+ type: 'blocked',
728
+ result
729
+ };
730
+ if (result === true) return {
731
+ type: 'allowed'
732
+ };
733
+ if (result && typeof result === 'object') {
734
+ if (result.redirect) return {
735
+ type: 'redirect',
736
+ to: result.redirect,
737
+ result
738
+ };
739
+ if (!result.ok) return {
740
+ type: 'blocked',
741
+ result
742
+ };
743
+ return {
744
+ type: 'allowed'
745
+ };
746
+ }
747
+ return {
748
+ type: 'allowed'
749
+ };
750
+ }
751
+ async #loadComponent(pattern, counted = false) {
752
+ const route = this.routes[pattern];
753
+ if (!route) {
754
+ if (counted) this.#loadingCount--;
755
+ return undefined;
756
+ }
246
757
  switch (route.status) {
247
758
  case 'loaded':
759
+ if (counted) this.#loadingCount--;
248
760
  return route.component;
249
761
  case 'loading':
762
+ if (counted) this.#loadingCount--;
250
763
  return route.promise;
251
764
  }
252
765
  if (!route.import) {
253
- throw new Error(`Route "${path}" has no import function`);
766
+ if (route.component) {
767
+ route.status = 'loaded';
768
+ if (counted) this.#loadingCount--;
769
+ return route.component;
770
+ }
771
+ if (counted) this.#loadingCount--;
772
+ throw new Error(`Route "${pattern}" has no import function`);
254
773
  }
255
- this.idleSet.delete(path);
774
+ this.#idleSet.delete(pattern);
256
775
  route.status = 'loading';
776
+ if (!counted) this.#loadingCount++;
257
777
  route.promise = route.import().then(mod => {
258
- const Comp = mod.default || mod;
778
+ const record = mod;
779
+ const component = typeof mod === 'function' ? mod : record.default || mod;
259
780
  route.status = 'loaded';
260
- route.component = Comp;
261
- if (!route.meta && mod.routeMeta) {
262
- route.meta = mod.routeMeta;
263
- }
264
- if (!route.layout && mod.layout) {
265
- route.layout = mod.layout;
266
- }
781
+ route.component = component;
782
+ if (!route.meta && record.routeMeta) route.meta = record.routeMeta;
783
+ if (!route.layout && record.layout) route.layout = record.layout;
267
784
  return route.component;
268
- }).catch(err => {
785
+ }).catch(error => {
269
786
  route.status = 'error';
270
- throw err;
787
+ throw error;
271
788
  }).finally(() => {
272
- this.loadingCount--;
789
+ this.#loadingCount--;
273
790
  this.#preloadNext();
274
791
  });
275
792
  return route.promise;
276
793
  }
794
+ async #loadComponentWithTimeout(pattern, ctx) {
795
+ const promise = this.#loadComponent(pattern);
796
+ const timeout = this.loadTimeout;
797
+ if (!timeout || timeout <= 0) {
798
+ return promise;
799
+ }
800
+ let timeoutId;
801
+ const timeoutPromise = new Promise(resolve => {
802
+ timeoutId = setTimeout(() => resolve('timeout'), timeout);
803
+ });
804
+ let result;
805
+ try {
806
+ result = await Promise.race([promise, timeoutPromise]);
807
+ } finally {
808
+ if (timeoutId) clearTimeout(timeoutId);
809
+ }
810
+ if (result !== 'timeout') {
811
+ return result;
812
+ }
813
+ const decision = this.onTimeout ? await this.onTimeout(ctx) : 'continue';
814
+ if (decision === 'cancel') {
815
+ throw new Error(`Route "${pattern}" import timed out after ${timeout}ms`);
816
+ }
817
+ return promise;
818
+ }
277
819
  #preloadNext() {
278
- while (this.loadingCount < this.maxPreload && this.idleSet.size > 0) {
279
- const path = this.idleSet.values().next().value;
280
- this.loadingCount++;
820
+ if (!this.#hasBrowser()) return;
821
+ while (this.#loadingCount < this.maxPreload && this.#idleSet.size > 0) {
822
+ const path = this.#idleSet.values().next().value;
823
+ if (!path) return;
824
+ this.#idleSet.delete(path);
825
+ this.#loadingCount++;
826
+ const task = {
827
+ path};
281
828
  const scheduler = typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : fn => setTimeout(fn, 0);
282
829
  scheduler(() => {
283
- this.#loadComponent(path);
830
+ this.#loadComponent(task.path, true).catch(error => {
831
+ const route = this.routes[task.path];
832
+ if (route) route.status = 'error';
833
+ console.error(error);
834
+ });
284
835
  });
285
836
  }
286
837
  }
287
- #onClick = e => {
288
- const link = e.target.closest('a');
289
- if (!link) return;
290
- const href = link.getAttribute('href');
291
- if (!href) return;
292
- if (href.startsWith('#')) return;
293
- try {
294
- const url = new URL(href, location.origin);
295
- if (url.origin !== location.origin) return;
296
- if (link.target === '_blank') return;
297
- if (link.hasAttribute('download')) return;
298
- if (e.ctrlKey || e.metaKey || e.shiftKey) return;
299
- e.preventDefault();
300
- this.pushState(url.pathname + url.search + url.hash);
301
- } catch {}
302
- };
303
- #onPopstate = () => {
304
- const current = location.pathname + location.search;
305
- const idx = this.stack.findLastIndex(r => r.path === current);
306
- if (idx !== -1) {
307
- if (idx === this.stackIndex) return;
308
- this.stackIndex = idx;
309
- const entry = this.stack[idx];
310
- this.#navigate(entry, {
311
- replace: true
312
- });
313
- if (entry.scroll != null) {
314
- window.scrollTo(0, entry.scroll);
838
+ async #applyScrollIntent(ctx, intent) {
839
+ const retry = {
840
+ ...intent.retry
841
+ };
842
+ while (true) {
843
+ if (!this.#transaction.isTokenValid(ctx)) return;
844
+ if (intent.type === 'hash') {
845
+ const el = this.#queryHashElement(intent.hash);
846
+ if (el) {
847
+ el.scrollIntoView({
848
+ behavior: intent.behavior ?? 'smooth'
849
+ });
850
+ return;
851
+ }
852
+ } else if (intent.type === 'restore') {
853
+ const target = this.#getScrollTarget();
854
+ if (target) {
855
+ this.#setScroll(target.root, intent.top, intent.left);
856
+ return;
857
+ }
858
+ } else {
859
+ const target = this.#getScrollTarget();
860
+ if (target) {
861
+ this.#setScroll(target.root, 0, 0);
862
+ return;
863
+ }
315
864
  }
316
- } else {
317
- const result = match(current, this.routes);
318
- const route = result ? this.routes[result.path] : undefined;
319
- const entry = {
320
- path: current,
321
- params: result?.params ?? {},
322
- meta: route?.meta,
323
- layout: route?.layout
865
+ if (retry.attempts >= retry.maxAttempts) return;
866
+ retry.attempts++;
867
+ await this.#createFallbackWaiter('raf').promise;
868
+ }
869
+ }
870
+ #createPostEffectWaiter() {
871
+ if (!this.#hasBrowser()) return this.#createFallbackWaiter('resolved');
872
+ let dispose;
873
+ const promise = new Promise(resolve => {
874
+ const watcher = aoye.effect(() => {
875
+ resolve();
876
+ queueMicrotask(() => dispose?.());
877
+ }, [() => this.active], {
878
+ type: 'post',
879
+ immediate: false
880
+ });
881
+ dispose = () => watcher.dispose();
882
+ });
883
+ return {
884
+ promise: promise.finally(() => dispose?.()),
885
+ source: 'post-effect',
886
+ dispose: () => dispose?.()
887
+ };
888
+ }
889
+ #createFallbackWaiter(wait) {
890
+ if (wait === 'raf' && typeof requestAnimationFrame !== 'undefined') {
891
+ return {
892
+ promise: new Promise(resolve => requestAnimationFrame(() => resolve())),
893
+ source: 'raf'
324
894
  };
325
- this.stack = [entry];
326
- this.stackIndex = 0;
327
- this.active = entry;
328
895
  }
329
- };
896
+ return {
897
+ promise: Promise.resolve(),
898
+ source: 'resolved'
899
+ };
900
+ }
901
+ #createScrollIntentFromUrl(url, snapshot, renderDependent = true) {
902
+ const parsed = this.#parseUrl(url);
903
+ const retry = this.#createRetry(renderDependent ? 'post-effect' : 'raf');
904
+ if (parsed.hash) {
905
+ return {
906
+ type: 'hash',
907
+ hash: parsed.hash,
908
+ retry,
909
+ behavior: 'smooth'
910
+ };
911
+ }
912
+ if (snapshot) {
913
+ return {
914
+ type: 'restore',
915
+ top: snapshot.top,
916
+ left: snapshot.left,
917
+ retry
918
+ };
919
+ }
920
+ return {
921
+ type: 'top',
922
+ retry
923
+ };
924
+ }
925
+ #createRestoreScrollIntent(snapshot) {
926
+ return {
927
+ type: 'restore',
928
+ top: snapshot.top,
929
+ left: snapshot.left,
930
+ retry: this.#createRetry('post-effect')
931
+ };
932
+ }
933
+ #createRetry(wait) {
934
+ return {
935
+ attempts: 0,
936
+ maxAttempts: 8,
937
+ wait
938
+ };
939
+ }
940
+ #saveScroll(index) {
941
+ const entry = this.#stack[index];
942
+ if (!entry || !this.#hasBrowser()) return;
943
+ entry.scroll = this.#getScrollSnapshot();
944
+ }
945
+ #getScrollSnapshot() {
946
+ const target = this.#getScrollTarget();
947
+ if (!target) return {
948
+ top: 0,
949
+ left: 0
950
+ };
951
+ if (target.root === window) {
952
+ return {
953
+ top: window.scrollY,
954
+ left: window.scrollX
955
+ };
956
+ }
957
+ return {
958
+ top: target.root.scrollTop,
959
+ left: target.root.scrollLeft
960
+ };
961
+ }
962
+ #getScrollTarget() {
963
+ if (!this.#hasBrowser()) return null;
964
+ if (this.scrollRootId) {
965
+ const el = document.getElementById(this.scrollRootId);
966
+ if (el) return {
967
+ root: el,
968
+ scrollRootId: this.scrollRootId
969
+ };
970
+ }
971
+ return {
972
+ root: window,
973
+ scrollRootId: this.scrollRootId
974
+ };
975
+ }
976
+ #setScroll(root, top, left) {
977
+ if (root === window) {
978
+ window.scrollTo(left, top);
979
+ return;
980
+ }
981
+ const el = root;
982
+ el.scrollTop = top;
983
+ el.scrollLeft = left;
984
+ }
985
+ #queryHashElement(hash) {
986
+ if (typeof document === 'undefined' || !hash) return null;
987
+ try {
988
+ return document.querySelector(decodeURIComponent(hash));
989
+ } catch {
990
+ return null;
991
+ }
992
+ }
993
+ #allowExternalHistoryDelta(ctx) {
994
+ const delta = ctx.historyDelta?.delta;
995
+ if (!this.#hasBrowser() || typeof delta !== 'number') return;
996
+ history.go(delta);
997
+ }
998
+ #rollbackBrowserHistoryIfNeeded(ctx) {
999
+ const delta = ctx.historyDelta?.delta;
1000
+ if (ctx.request?.source !== 'popstate') return;
1001
+ if (!this.#hasBrowser() || typeof delta !== 'number') return;
1002
+ history.go(-delta);
1003
+ }
1004
+ #isValidPendingHistoryDelta(state, url) {
1005
+ const pending = this.#pendingHistoryDelta;
1006
+ if (!pending || pending.consumed) return false;
1007
+ if (!this.#transaction.isTokenIdCurrent(pending.tokenId)) return false;
1008
+ if (state.historyKey !== this.historyKey) return false;
1009
+ if (state.index !== pending.toIndex) return false;
1010
+ if (state.entryId !== pending.targetEntryId) return false;
1011
+ if (this.#normalizeUrl(url) !== this.#normalizeUrl(pending.targetUrl)) return false;
1012
+ return true;
1013
+ }
1014
+ #readHistoryState(state) {
1015
+ if (!state || typeof state !== 'object') return null;
1016
+ const value = state;
1017
+ if (!value.__bobeRouter || value.historyKey !== this.historyKey) return null;
1018
+ if (typeof value.entryId !== 'string' || typeof value.index !== 'number') return null;
1019
+ return value;
1020
+ }
1021
+ #createHistoryState(entry, index) {
1022
+ return {
1023
+ __bobeRouter: true,
1024
+ historyKey: this.historyKey,
1025
+ entryId: entry.id,
1026
+ index,
1027
+ url: entry.url
1028
+ };
1029
+ }
1030
+ #createRouteEntry(routeMatch, id = this.#createEntryId()) {
1031
+ return {
1032
+ id,
1033
+ path: routeMatch.url,
1034
+ url: this.#buildUrl(routeMatch.url, routeMatch.search, routeMatch.hash),
1035
+ hash: routeMatch.hash,
1036
+ params: routeMatch.params,
1037
+ component: routeMatch.record.component,
1038
+ meta: routeMatch.record.meta,
1039
+ layout: routeMatch.record.layout
1040
+ };
1041
+ }
1042
+ #syncRouteRecordToEntry(entry, pattern) {
1043
+ const route = this.routes[pattern];
1044
+ if (!route) return;
1045
+ entry.component = route.component;
1046
+ entry.meta = route.meta;
1047
+ entry.layout = route.layout;
1048
+ }
1049
+ #matchUrl(url) {
1050
+ const result = match(url, this.routes);
1051
+ if (!result) return null;
1052
+ const parsed = this.#parseUrl(url);
1053
+ const record = this.routes[result.path];
1054
+ if (!record) return null;
1055
+ return {
1056
+ pattern: result.path,
1057
+ path: result.path,
1058
+ url: result.url,
1059
+ search: parsed.search,
1060
+ hash: parsed.hash,
1061
+ params: result.params,
1062
+ record
1063
+ };
1064
+ }
1065
+ #parseUrl(url) {
1066
+ const base = this.#hasBrowser() ? location.origin : 'http://localhost';
1067
+ return new URL(url, base);
1068
+ }
1069
+ #buildUrl(path, search = '', hash = '') {
1070
+ return `${path}${search}${hash}`;
1071
+ }
1072
+ #normalizePath(path) {
1073
+ const result = match(path, this.routes);
1074
+ return result?.url || path;
1075
+ }
1076
+ #normalizeUrl(url) {
1077
+ const parsed = this.#parseUrl(url);
1078
+ return this.#buildUrl(this.#normalizePath(parsed.pathname), parsed.search, parsed.hash);
1079
+ }
1080
+ #isSameRouteBase(left, right) {
1081
+ const a = this.#parseUrl(left);
1082
+ const b = this.#parseUrl(right);
1083
+ return this.#normalizePath(a.pathname) === this.#normalizePath(b.pathname) && a.search === b.search;
1084
+ }
1085
+ #toUrlString(url) {
1086
+ return `${url.pathname}${url.search}${url.hash}`;
1087
+ }
1088
+ #isHashOnlyForActive(url) {
1089
+ if (!this.active) return false;
1090
+ return this.#isSameRouteBase(url, this.active.url);
1091
+ }
1092
+ #createEntryId() {
1093
+ this.#entryId++;
1094
+ return `${this.historyKey}:${this.#entryId}`;
1095
+ }
1096
+ #initIdleSet() {
1097
+ for (const path of Object.keys(this.routes)) {
1098
+ if (this.routes[path].status === 'idle') {
1099
+ this.#idleSet.add(path);
1100
+ }
1101
+ }
1102
+ }
1103
+ #hasBrowser() {
1104
+ return typeof window !== 'undefined' && typeof document !== 'undefined' && typeof history !== 'undefined';
1105
+ }
330
1106
  }
331
1107
 
332
1108
  exports.Router = Router;
333
1109
  exports.createRouteRecord = createRouteRecord;
334
- exports.match = match;
335
1110
 
336
1111
  }));
337
1112
  //# sourceMappingURL=index.umd.js.map