bobe-router 0.0.67 → 0.0.69

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