@viewfly/router 3.0.0-alpha.8 → 3.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.
package/dist/index.esm.js CHANGED
@@ -1,14 +1,39 @@
1
- import { Injectable, comparePropsWithCallbacks, createContext, inject, internalWrite, jsx, makeError, onUnmounted, reactive, readonlyProxyHandler, shallowReactive } from "@viewfly/core";
2
- import { Subject, Subscription, fromEvent } from "@tanbo/stream";
1
+ import { Injectable, InjectionToken, comparePropsWithCallbacks, createContext, inject, internalWrite, jsx, makeError, onUnmounted, reactive, readonlyProxyHandler, shallowReactive, watch } from "@viewfly/core";
2
+ import { Subject, Subscription, fromEvent, microTask } from "@tanbo/stream";
3
3
  //#region src/providers/router.ts
4
4
  var routerErrorFn$1 = makeError("Router");
5
- var Router = class {
5
+ /** matchRoute pathname 参数同粒度比较(去掉首尾 `/`) */
6
+ function normalizeRoutePathSegment(path) {
7
+ return path.replace(/^\/+|\/+$/g, "") || "";
8
+ }
9
+ var Router = class Router {
6
10
  onRefresh;
11
+ lastResolvedParams = null;
12
+ /** 当前重定向链上已解析过的 path 段(规范化后);非重定向成功匹配后清空 */
13
+ redirectTrail = null;
14
+ static maxRedirectHops = 32;
15
+ /** 当前层最近一次匹配所消费的 URL 段数(默认 1,保持历史行为) */
16
+ consumedSegments = 1;
17
+ routeParams = {};
18
+ /**
19
+ * 本次 `resolve` 命中的 `route.path` 上的动态段(供子 Router、`canActivate`、`redirectTo`)。
20
+ * 与 `params` 按层隔离(对齐 Angular):有 `parent` 时 `params` 只表示本层注入域,不由子级匹配覆盖。
21
+ */
22
+ lastResolvePathParams = {};
7
23
  get deep() {
8
- return this.parent ? this.parent.deep + 1 : 0;
24
+ return this.parent ? this.parent.deep + this.parent.consumedSegments : 0;
25
+ }
26
+ get remainingPaths() {
27
+ return this.navigator.urlTree.paths.slice(this.deep);
9
28
  }
10
29
  get path() {
11
- return this.navigator.urlTree.paths.at(this.deep) || "";
30
+ return this.remainingPaths[0] || "";
31
+ }
32
+ get params() {
33
+ return this.routeParams;
34
+ }
35
+ setParams(params) {
36
+ this.routeParams = { ...params };
12
37
  }
13
38
  refreshEvent = new Subject();
14
39
  constructor(navigator, parent) {
@@ -16,17 +41,17 @@ var Router = class {
16
41
  this.parent = parent;
17
42
  this.onRefresh = this.refreshEvent.asObservable();
18
43
  }
19
- navigateTo(path, params, fragment) {
20
- this.navigator.to(path, this, params, fragment || void 0);
44
+ navigateTo(path, queryParams, hash) {
45
+ this.navigator.to(path, this, queryParams, hash);
21
46
  }
22
- replaceTo(path, params) {
23
- this.navigator.replace(path, this, params);
47
+ replaceTo(path, queryParams, hash) {
48
+ this.navigator.replace(path, this, queryParams, hash);
24
49
  }
25
50
  refresh() {
26
51
  this.refreshEvent.next();
27
52
  }
28
- consumeConfig(routes) {
29
- return this.matchRoute(routes, this.path);
53
+ resolve(routes) {
54
+ return this.matchRoute(routes, this.remainingPaths);
30
55
  }
31
56
  back() {
32
57
  this.navigator.back();
@@ -37,35 +62,217 @@ var Router = class {
37
62
  go(offset) {
38
63
  this.navigator.go(offset);
39
64
  }
40
- matchRoute(configs, pathname) {
41
- let matchedConfig = null;
42
- let defaultConfig = null;
43
- let fallbackConfig = null;
44
- for (const item of configs) if (item.path === pathname) {
45
- matchedConfig = item;
46
- break;
47
- } else if (item.path === "*") {
48
- if (!fallbackConfig) fallbackConfig = item;
49
- } else if (item.path === "") {
50
- if (!defaultConfig) defaultConfig = item;
65
+ clearRedirectTrail() {
66
+ this.redirectTrail = null;
67
+ }
68
+ getNavigatorParams() {
69
+ const pathname = "/" + this.navigator.urlTree.paths.join("/");
70
+ return {
71
+ pathname: pathname === "/" ? pathname : pathname.replace(/\/+/g, "/"),
72
+ queryParams: this.navigator.urlTree.queryParams,
73
+ hash: this.navigator.urlTree.hash
74
+ };
75
+ }
76
+ cloneNavigatorParams(params) {
77
+ return {
78
+ pathname: params.pathname,
79
+ queryParams: { ...params.queryParams },
80
+ hash: params.hash
81
+ };
82
+ }
83
+ beginRedirectResolution(pathname) {
84
+ const key = normalizeRoutePathSegment(pathname);
85
+ if (this.redirectTrail === null) this.redirectTrail = /* @__PURE__ */ new Set();
86
+ if (this.redirectTrail.size >= Router.maxRedirectHops) {
87
+ const chain = [...this.redirectTrail].join(" -> ");
88
+ this.clearRedirectTrail();
89
+ throw routerErrorFn$1(`Redirect chain exceeded ${Router.maxRedirectHops} hops (last segment: '${pathname}', chain: ${chain})`);
90
+ }
91
+ this.redirectTrail.add(key);
92
+ }
93
+ assertRedirectTarget(pathname, target) {
94
+ const cur = normalizeRoutePathSegment(pathname);
95
+ const next = normalizeRoutePathSegment(target);
96
+ if (next === cur) {
97
+ this.clearRedirectTrail();
98
+ throw routerErrorFn$1(`Self-redirect at '${pathname}' (redirect target equals current path)`);
51
99
  }
52
- const config = matchedConfig || defaultConfig || fallbackConfig;
53
- if (!config) return config;
54
- if (typeof config.redirectTo === "function") {
55
- const p = config.redirectTo(pathname);
56
- if (typeof p === "string") this.navigateTo(p);
57
- else if (typeof p === "object") this.navigateTo(p.pathname, p.queryParams, p.fragment);
58
- else throw routerErrorFn$1(`Router redirect to '${pathname}' not supported`);
100
+ if (this.redirectTrail.has(next)) {
101
+ const chain = `${[...this.redirectTrail].join(" -> ")} -> ${next}`;
102
+ this.clearRedirectTrail();
103
+ throw routerErrorFn$1(`Redirect cycle detected (chain: ${chain})`);
104
+ }
105
+ }
106
+ splitRoutePath(path) {
107
+ const normalized = normalizeRoutePathSegment(path);
108
+ return normalized ? normalized.split("/") : [];
109
+ }
110
+ /** 单段路由:`user` | `:id` | `:id?`(`?` 仅表示可选;整条 path 里 `:…?` 只允许出现在最后一段,由 matchRoutePath 校验) */
111
+ parseRouteSegment(segment) {
112
+ if (!segment) return null;
113
+ if (segment.startsWith(":")) {
114
+ const optionalMatch = /^:([^:?]+)\?$/.exec(segment);
115
+ if (optionalMatch) {
116
+ const name = optionalMatch[1];
117
+ return name ? {
118
+ kind: "param",
119
+ name,
120
+ optional: true
121
+ } : null;
122
+ }
123
+ const name = segment.slice(1);
124
+ if (!name || name.includes("?")) return null;
125
+ return {
126
+ kind: "param",
127
+ name,
128
+ optional: false
129
+ };
130
+ }
131
+ return {
132
+ kind: "static",
133
+ value: segment
134
+ };
135
+ }
136
+ matchRoutePath(routePath, remainingPaths) {
137
+ if (!routePath || routePath === "*") return null;
138
+ const rawSegments = this.splitRoutePath(routePath);
139
+ const segments = [];
140
+ for (const s of rawSegments) {
141
+ const parsed = this.parseRouteSegment(s);
142
+ if (!parsed) {
143
+ if (s.startsWith(":")) throw routerErrorFn$1(`Empty or invalid path parameter segment '${s}' in '${routePath}' (use ':name' with a non-empty name).`);
144
+ return null;
145
+ }
146
+ segments.push(parsed);
147
+ }
148
+ for (let i = 0; i < segments.length; i++) {
149
+ const s = segments[i];
150
+ if (s.kind === "param" && s.optional && i !== segments.length - 1) throw routerErrorFn$1(`Optional path parameter ':${s.name}?' must be the last segment of '${routePath}' (optional params are only allowed at the end of the path).`);
151
+ }
152
+ const seenParamNames = /* @__PURE__ */ new Set();
153
+ for (const s of segments) if (s.kind === "param") {
154
+ if (seenParamNames.has(s.name)) throw routerErrorFn$1(`Duplicate path parameter ':${s.name}' in '${routePath}' (each name must appear at most once).`);
155
+ seenParamNames.add(s.name);
156
+ }
157
+ const urlLen = remainingPaths.length;
158
+ let ui = 0;
159
+ const pathParams = {};
160
+ for (let ri = 0; ri < segments.length; ri++) {
161
+ const seg = segments[ri];
162
+ if (seg.kind === "static") {
163
+ if (ui >= urlLen || remainingPaths[ui] !== seg.value) return null;
164
+ ui++;
165
+ continue;
166
+ }
167
+ if (!seg.optional) {
168
+ if (ui >= urlLen) return null;
169
+ pathParams[seg.name] = remainingPaths[ui];
170
+ ui++;
171
+ continue;
172
+ }
173
+ if (ui >= urlLen) {
174
+ pathParams[seg.name] = "";
175
+ continue;
176
+ }
177
+ pathParams[seg.name] = remainingPaths[ui];
178
+ ui++;
179
+ }
180
+ return {
181
+ consumedSegments: ui,
182
+ params: pathParams
183
+ };
184
+ }
185
+ matchRoute(routes, remainingPaths) {
186
+ this.lastResolvePathParams = {};
187
+ const pathname = remainingPaths[0] || "";
188
+ this.beginRedirectResolution(pathname);
189
+ let matchedRoute = null;
190
+ let matchedRouteResult = null;
191
+ let defaultRoute = null;
192
+ let fallbackRoute = null;
193
+ for (const item of routes) {
194
+ const matchResult = this.matchRoutePath(item.path, remainingPaths);
195
+ if (matchResult) {
196
+ matchedRoute = item;
197
+ matchedRouteResult = matchResult;
198
+ break;
199
+ } else if (item.path === "*") {
200
+ if (!fallbackRoute) fallbackRoute = item;
201
+ } else if (item.path === "") {
202
+ if (!defaultRoute) defaultRoute = item;
203
+ }
204
+ }
205
+ const route = matchedRoute || defaultRoute || fallbackRoute;
206
+ if (!route) {
207
+ this.consumedSegments = 0;
208
+ this.routeParams = {};
209
+ this.lastResolvePathParams = {};
210
+ this.clearRedirectTrail();
211
+ this.lastResolvedParams = this.cloneNavigatorParams(this.getNavigatorParams());
212
+ return route;
213
+ }
214
+ if (route === defaultRoute) {
215
+ this.consumedSegments = 0;
216
+ this.routeParams = {};
217
+ this.lastResolvePathParams = {};
218
+ } else if (route === matchedRoute && matchedRouteResult) {
219
+ this.consumedSegments = matchedRouteResult.consumedSegments;
220
+ this.lastResolvePathParams = { ...matchedRouteResult.params };
221
+ if (this.parent === null) this.routeParams = { ...matchedRouteResult.params };
222
+ } else {
223
+ this.consumedSegments = remainingPaths.length > 0 ? 1 : 0;
224
+ this.routeParams = {};
225
+ this.lastResolvePathParams = {};
226
+ }
227
+ if (typeof route.redirectTo === "function") {
228
+ const to = this.cloneNavigatorParams(this.getNavigatorParams());
229
+ const from = this.lastResolvedParams ? this.cloneNavigatorParams(this.lastResolvedParams) : null;
230
+ const p = route.redirectTo({
231
+ to,
232
+ from,
233
+ router: this,
234
+ params: this.lastResolvePathParams
235
+ });
236
+ if (typeof p === "string") {
237
+ this.assertRedirectTarget(pathname, p);
238
+ this.navigateTo(p);
239
+ } else if (typeof p === "object") {
240
+ this.assertRedirectTarget(pathname, p.pathname);
241
+ this.navigateTo(p.pathname, p.queryParams, p.hash);
242
+ } else {
243
+ this.clearRedirectTrail();
244
+ throw routerErrorFn$1(`Router redirect to '${pathname}' not supported`);
245
+ }
59
246
  return null;
60
247
  }
61
- if (typeof config.redirectTo === "string") {
62
- this.navigateTo(config.redirectTo);
248
+ if (typeof route.redirectTo === "string") {
249
+ this.assertRedirectTarget(pathname, route.redirectTo);
250
+ this.navigateTo(route.redirectTo);
63
251
  return null;
64
252
  }
65
- return config;
253
+ this.clearRedirectTrail();
254
+ this.lastResolvedParams = this.cloneNavigatorParams(this.getNavigatorParams());
255
+ return route;
66
256
  }
67
257
  };
68
258
  //#endregion
259
+ //#region src/providers/query-encoding.ts
260
+ /** 查询串组件:写入 URL 时 encode,从 URL 解析出对象时 decode(与常见路由约定一致)。 */
261
+ function encodeQueryParamComponent(value) {
262
+ try {
263
+ return encodeURIComponent(value);
264
+ } catch {
265
+ return value;
266
+ }
267
+ }
268
+ function decodeQueryParamComponent(value) {
269
+ try {
270
+ return decodeURIComponent(value.replace(/\+/g, " "));
271
+ } catch {
272
+ return value;
273
+ }
274
+ }
275
+ //#endregion
69
276
  //#region src/providers/url-parser.ts
70
277
  var UrlParser = class {
71
278
  index = 0;
@@ -84,7 +291,7 @@ var UrlParser = class {
84
291
  this.index++;
85
292
  this.tokens.push({
86
293
  type: "query",
87
- params: this.readQuery()
294
+ queryParams: this.readQuery()
88
295
  });
89
296
  } else if (this.peek("#")) {
90
297
  this.index++;
@@ -114,7 +321,7 @@ var UrlParser = class {
114
321
  urlTree.paths.push(item.value);
115
322
  break;
116
323
  case "query":
117
- urlTree.queryParams = item.params;
324
+ urlTree.queryParams = item.queryParams;
118
325
  break;
119
326
  case "hash": urlTree.hash = item.value;
120
327
  }
@@ -128,11 +335,11 @@ var UrlParser = class {
128
335
  readQuery() {
129
336
  const query = {};
130
337
  while (this.index < this.url.length) {
131
- const key = this.readQueryKey();
338
+ const key = decodeQueryParamComponent(this.readQueryKey());
132
339
  let value = "";
133
340
  if (this.peek("=")) {
134
341
  this.index++;
135
- value = this.readQueryValue();
342
+ value = decodeQueryParamComponent(this.readQueryValue());
136
343
  }
137
344
  const oldValue = query[key];
138
345
  if (oldValue) if (Array.isArray(oldValue)) oldValue.push(value);
@@ -215,33 +422,33 @@ var Navigator = class {
215
422
  };
216
423
  function formatUrl(pathname, urlFormatParams) {
217
424
  pathname = pathname.replace(/\/+/g, "/");
218
- const { queryParams, fragment } = urlFormatParams;
219
- return pathname + (queryParams ? "?" + formatQueryParams(queryParams) : "") + (fragment ? "#" + fragment : "");
425
+ const { queryParams, hash } = urlFormatParams;
426
+ return pathname + (queryParams ? "?" + formatQueryParams(queryParams) : "") + (hash !== void 0 && hash !== null ? "#" + hash : "");
220
427
  }
221
428
  function formatQueryParams(queryParams) {
222
429
  const params = [];
223
430
  Object.keys(queryParams).forEach((key) => {
431
+ const encKey = encodeQueryParamComponent(key);
224
432
  const values = queryParams[key];
225
433
  if (Array.isArray(values)) values.forEach((i) => {
226
- params.push(`${key}=${decodeURIComponent(i)}`);
434
+ params.push(`${encKey}=${encodeQueryParamComponent(i)}`);
227
435
  });
228
- else params.push(`${key}=${decodeURIComponent(values)}`);
436
+ else params.push(`${encKey}=${encodeQueryParamComponent(values)}`);
229
437
  });
230
438
  return params.join("&");
231
439
  }
232
440
  var BrowserNavigator = class BrowserNavigator extends Navigator {
233
441
  onUrlChanged;
442
+ pendingNavigation = null;
234
443
  /** 挂载在 location 上的路径前缀;'' 或 '/' 表示站点根,不做剥离 */
235
444
  get basePathPrefix() {
236
445
  return this.baseUrl === "/" || this.baseUrl === "" ? "" : this.baseUrl;
237
446
  }
238
447
  get pathname() {
239
- const pathname = location.pathname;
240
- if (!this.basePathPrefix) return pathname;
241
- return pathname.startsWith(this.basePathPrefix) ? pathname.substring(this.basePathPrefix.length) : pathname;
448
+ return this.stripBaseFromLocationPathname(location.pathname);
242
449
  }
243
450
  urlParser = new UrlParser();
244
- urlTree = this.getUrlTree();
451
+ urlTree = this.readUrlTreeFromLocation();
245
452
  urlChangeEvent = new Subject();
246
453
  subscription = new Subscription();
247
454
  constructor(baseUrl, hooks = {}) {
@@ -249,51 +456,62 @@ var BrowserNavigator = class BrowserNavigator extends Navigator {
249
456
  this.hooks = hooks;
250
457
  this.onUrlChanged = this.urlChangeEvent.asObservable();
251
458
  this.subscription.add(fromEvent(window, "popstate").subscribe(() => {
252
- this.urlTree = this.getUrlTree();
459
+ this.urlTree = this.readUrlTreeFromLocation();
253
460
  this.urlChangeEvent.next();
254
461
  }));
255
- if (this.basePathPrefix && !location.pathname.startsWith(this.basePathPrefix)) history.replaceState(null, "", this.baseUrl);
462
+ if (this.basePathPrefix && !location.pathname.startsWith(this.basePathPrefix)) {
463
+ history.replaceState(null, "", this.baseUrl);
464
+ this.urlTree = this.readUrlTreeFromHistoryHref(this.baseUrl);
465
+ }
256
466
  }
257
- to(pathName, relative, queryParams, fragment) {
258
- const url = this.join(pathName, relative, queryParams, fragment);
467
+ to(pathName, relative, queryParams, hash) {
468
+ const url = this.join(pathName, relative, queryParams, hash);
259
469
  if (location.origin + url === location.href) return true;
260
470
  this.runHooks({
261
471
  pathname: this.pathname,
262
472
  queryParams: this.urlTree.queryParams,
263
- fragment: this.urlTree.hash
473
+ hash: this.urlTree.hash
264
474
  }, {
265
475
  pathname: pathName,
266
476
  queryParams: queryParams || {},
267
- fragment: fragment || null
477
+ hash: hash ?? null
268
478
  }, () => {
479
+ this.pendingNavigation = {
480
+ type: "push",
481
+ from: this.getCurrentUrl()
482
+ };
269
483
  history.pushState(null, "", url);
270
- this.urlTree = this.getUrlTree();
484
+ this.urlTree = this.readUrlTreeFromHistoryHref(url);
271
485
  this.urlChangeEvent.next();
272
486
  });
273
487
  return true;
274
488
  }
275
- replace(pathName, relative, queryParams, fragment) {
276
- const url = this.join(pathName, relative, queryParams, fragment);
489
+ replace(pathName, relative, queryParams, hash) {
490
+ const url = this.join(pathName, relative, queryParams, hash);
277
491
  if (location.origin + url === location.href) return true;
278
492
  this.runHooks({
279
493
  pathname: this.pathname,
280
494
  queryParams: this.urlTree.queryParams,
281
- fragment: this.urlTree.hash
495
+ hash: this.urlTree.hash
282
496
  }, {
283
497
  pathname: pathName,
284
498
  queryParams: queryParams || {},
285
- fragment: fragment || null
499
+ hash: hash ?? null
286
500
  }, () => {
501
+ this.pendingNavigation = {
502
+ type: "replace",
503
+ from: this.getCurrentUrl()
504
+ };
287
505
  history.replaceState(null, "", url);
288
- this.urlTree = this.getUrlTree();
506
+ this.urlTree = this.readUrlTreeFromHistoryHref(url);
289
507
  this.urlChangeEvent.next();
290
508
  });
291
509
  return true;
292
510
  }
293
- join(pathname, relative, queryParams, fragment) {
511
+ join(pathname, relative, queryParams, hash) {
294
512
  if (pathname.startsWith("/")) return formatUrl(this.baseUrl + pathname, {
295
513
  queryParams,
296
- fragment
514
+ hash
297
515
  });
298
516
  const beforePath = this.urlTree.paths.slice(0, relative.deep);
299
517
  while (true) {
@@ -308,9 +526,11 @@ var BrowserNavigator = class BrowserNavigator extends Navigator {
308
526
  }
309
527
  break;
310
528
  }
311
- return formatUrl(this.baseUrl + "/" + beforePath.join("/") + "/" + pathname, {
529
+ const tail = [...beforePath, pathname].join("/");
530
+ const base = this.baseUrl.replace(/\/+$/, "");
531
+ return formatUrl((base ? `${base}/${tail}` : `/${tail}`).replace(/\/+/g, "/"), {
312
532
  queryParams,
313
- fragment
533
+ hash
314
534
  });
315
535
  }
316
536
  back() {
@@ -322,22 +542,64 @@ var BrowserNavigator = class BrowserNavigator extends Navigator {
322
542
  go(offset) {
323
543
  history.go(offset);
324
544
  }
545
+ confirmNavigation() {
546
+ this.pendingNavigation = null;
547
+ }
548
+ cancelNavigation() {
549
+ const pending = this.pendingNavigation;
550
+ this.pendingNavigation = null;
551
+ if (!pending) return;
552
+ if (pending.type === "push") {
553
+ history.back();
554
+ return;
555
+ }
556
+ history.replaceState(null, "", pending.from);
557
+ this.urlTree = this.readUrlTreeFromHistoryHref(pending.from);
558
+ this.urlChangeEvent.next();
559
+ }
325
560
  destroy() {
326
561
  this.subscription.unsubscribe();
327
562
  }
328
563
  runHooks(beforeParams, currentParams, next) {
329
- if (typeof this.hooks.beforeEach === "function") this.hooks.beforeEach?.(beforeParams, currentParams, () => {
330
- next();
331
- this.hooks.afterEach?.(currentParams);
332
- });
333
- else {
564
+ if (typeof this.hooks.beforeEach === "function") {
565
+ let called = false;
566
+ let warningTimer = null;
567
+ const proceed = () => {
568
+ if (called) return;
569
+ called = true;
570
+ if (warningTimer) clearTimeout(warningTimer);
571
+ next();
572
+ this.hooks.afterEach?.(currentParams);
573
+ };
574
+ this.hooks.beforeEach?.(beforeParams, currentParams, proceed);
575
+ const env = globalThis?.process?.env;
576
+ if (!(env?.env === "production" || env?.NODE_ENV === "production") && !called) warningTimer = setTimeout(() => {
577
+ if (!called) console.warn("[Viewfly Router] NavigatorHooks.beforeEach did not call next(); navigation remains pending.");
578
+ }, 300);
579
+ } else {
334
580
  next();
335
581
  this.hooks.afterEach?.(currentParams);
336
582
  }
337
583
  }
338
- getUrlTree() {
584
+ stripBaseFromLocationPathname(fullPathname) {
585
+ if (!this.basePathPrefix) return fullPathname;
586
+ return fullPathname.startsWith(this.basePathPrefix) ? fullPathname.substring(this.basePathPrefix.length) : fullPathname;
587
+ }
588
+ /**
589
+ * 按与 `history.pushState` / `replaceState` 写入会话的 URL 解析 urlTree(与传入 `join` 的相对路径一致)。
590
+ * History 更新后若仍仅从 `Location` 读路径,可能与实际会话 URL 不一致(如 Location 与 document 的同步时序、部分宿主环境)。
591
+ */
592
+ readUrlTreeFromHistoryHref(hrefRelativeToOrigin) {
593
+ const abs = new URL(hrefRelativeToOrigin, location.origin);
594
+ const logicalPathname = this.stripBaseFromLocationPathname(abs.pathname);
595
+ return this.urlParser.parse(`${logicalPathname}${abs.search}${abs.hash}`);
596
+ }
597
+ readUrlTreeFromLocation() {
339
598
  return this.urlParser.parse(this.pathname + location.search + location.hash);
340
599
  }
600
+ getCurrentUrl() {
601
+ return location.pathname + location.search + location.hash;
602
+ }
341
603
  };
342
604
  BrowserNavigator = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [String, Object])], BrowserNavigator);
343
605
  //#endregion
@@ -368,12 +630,68 @@ function useQueryParams() {
368
630
  return queryParams;
369
631
  }
370
632
  //#endregion
633
+ //#region src/hooks/use-params.ts
634
+ function useParams() {
635
+ const router = inject(Router);
636
+ const pathParams = { ...router.params };
637
+ const readonlyParams = new Proxy(pathParams, readonlyProxyHandler);
638
+ const subscription = router.onRefresh.subscribe(() => {
639
+ comparePropsWithCallbacks(pathParams, router.params, (key) => {
640
+ internalWrite(() => {
641
+ Reflect.deleteProperty(pathParams, key);
642
+ });
643
+ }, (key, value) => {
644
+ internalWrite(() => {
645
+ pathParams[key] = value;
646
+ });
647
+ }, (key, value) => {
648
+ internalWrite(() => {
649
+ pathParams[key] = value;
650
+ });
651
+ });
652
+ });
653
+ onUnmounted(() => {
654
+ subscription.unsubscribe();
655
+ });
656
+ return readonlyParams;
657
+ }
658
+ //#endregion
659
+ //#region src/providers/routes.ts
660
+ var Routes = new InjectionToken("Routes");
661
+ //#endregion
371
662
  //#region src/link.tsx
372
663
  function Link(props) {
373
664
  const navigator = inject(Navigator);
374
665
  const router = inject(Router);
666
+ function buildDomAttrs() {
667
+ const attrs = {};
668
+ Object.keys(props).forEach((key) => {
669
+ switch (key) {
670
+ case "to":
671
+ case "active":
672
+ case "exact":
673
+ case "queryParams":
674
+ case "hash":
675
+ case "tag":
676
+ case "children": return;
677
+ default: attrs[key] = props[key];
678
+ }
679
+ });
680
+ return attrs;
681
+ }
682
+ function normalizePathname(path) {
683
+ const pathname = (path.split("#")[0].split("?")[0] || "/").replace(/\/+$/, "") || "/";
684
+ const baseUrl = navigator.baseUrl === "/" || navigator.baseUrl === "" ? "" : navigator.baseUrl;
685
+ if (!baseUrl) return pathname;
686
+ const base = baseUrl.replace(/\/+$/, "") || "/";
687
+ if (pathname === base) return "/";
688
+ if (pathname.startsWith(base + "/")) return pathname.substring(base.length);
689
+ return pathname;
690
+ }
375
691
  function getActive() {
376
- return props.exact ? navigator.pathname === navigator.join(props.to, router) || navigator.pathname + "/" === navigator.join(props.to, router) : navigator.pathname.startsWith(navigator.join(props.to, router));
692
+ const currentPathname = normalizePathname(navigator.pathname);
693
+ const targetPathname = normalizePathname(navigator.join(props.to, router));
694
+ return props.exact ? currentPathname === targetPathname : currentPathname === targetPathname || currentPathname.startsWith(targetPathname + "/");
377
695
  }
378
696
  const isActive = reactive({ value: getActive() });
379
697
  const subscription = navigator.onUrlChanged.subscribe(() => {
@@ -382,21 +700,27 @@ function Link(props) {
382
700
  onUnmounted(() => {
383
701
  subscription.unsubscribe();
384
702
  });
703
+ /** 仅左键且无修饰键时由 SPA 接管,其余交给浏览器(新标签、下载默认等) */
704
+ function shouldHandleSpaClick(ev) {
705
+ if (ev.button !== 0) return false;
706
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false;
707
+ return true;
708
+ }
385
709
  function navigate(ev) {
386
- if ((!props.tag || props.tag === "a") && props.target === "_blank") return;
387
- ev.preventDefault();
388
- router.navigateTo(props.to, props.queryParams, props.fragment);
710
+ const isAnchorTag = !props.tag || props.tag === "a";
711
+ if (isAnchorTag && props.target === "_blank") return;
712
+ if (isAnchorTag && ev.cancelable) ev.preventDefault();
713
+ router.navigateTo(props.to, props.queryParams, props.hash);
389
714
  }
390
715
  return () => {
391
716
  const Tag = props.tag || "a";
392
- const attrs = Object.assign({}, props, {
393
- onClick(ev) {
394
- navigate(ev);
395
- props.onClick?.(ev);
396
- },
397
- ...props
398
- });
399
- if (Tag === "a") attrs.href = navigator.join(props.to, router, props.queryParams, props.fragment);
717
+ const attrs = Object.assign(buildDomAttrs(), { onClick(ev) {
718
+ props.onClick?.(ev);
719
+ if (ev.defaultPrevented) return;
720
+ if (!shouldHandleSpaClick(ev)) return;
721
+ navigate(ev);
722
+ } });
723
+ if (Tag === "a") attrs.href = navigator.join(props.to, router, props.queryParams, props.hash);
400
724
  if (isActive.value && props.active) attrs.class = [attrs.class, props.active];
401
725
  return /* @__PURE__ */ jsx(Tag, {
402
726
  ...attrs,
@@ -409,9 +733,9 @@ function Link(props) {
409
733
  var RouterModule = class {
410
734
  subscription = new Subscription();
411
735
  navigator;
412
- constructor(baseUrl = "", hooks = {}) {
413
- this.baseUrl = baseUrl;
414
- this.navigator = new BrowserNavigator(this.baseUrl, hooks);
736
+ constructor(config) {
737
+ this.config = config;
738
+ this.navigator = new BrowserNavigator(config.baseUrl || "", config.hooks);
415
739
  }
416
740
  setup(app) {
417
741
  const navigator = this.navigator;
@@ -419,13 +743,20 @@ var RouterModule = class {
419
743
  this.subscription.add(navigator.onUrlChanged.subscribe(() => {
420
744
  router.refresh();
421
745
  }));
422
- app.provide([{
423
- provide: Navigator,
424
- useValue: navigator
425
- }, {
426
- provide: Router,
427
- useValue: router
428
- }]);
746
+ app.provide([
747
+ {
748
+ provide: Navigator,
749
+ useValue: navigator
750
+ },
751
+ {
752
+ provide: Router,
753
+ useValue: router
754
+ },
755
+ {
756
+ provide: Routes,
757
+ useValue: this.config.routes || []
758
+ }
759
+ ]);
429
760
  }
430
761
  onDestroy() {
431
762
  this.subscription.unsubscribe();
@@ -438,37 +769,127 @@ var routerErrorFn = makeError("RouterOutlet");
438
769
  function RouterOutlet(props) {
439
770
  const router = inject(Router, null);
440
771
  if (router === null) throw routerErrorFn("cannot found parent Router.");
441
- const childRouter = new Router(inject(Navigator), router);
772
+ const currentRouter = router;
773
+ const routes = inject(Routes, []);
774
+ const navigator = inject(Navigator);
775
+ const childRouter = new Router(navigator, currentRouter);
442
776
  const Context = createContext([{
443
777
  provide: Router,
444
778
  useValue: childRouter
445
779
  }]);
446
780
  const children = shallowReactive({ value: null });
447
- const subscription = router.onRefresh.subscribe(() => {
781
+ let confirmedParams = null;
782
+ const subscription = currentRouter.onRefresh.pipe(microTask()).subscribe(() => {
448
783
  updateChildren();
449
784
  });
785
+ /** 每次 `updateChildren` 调用递增;并发/卸载时旧的一次性任务应放弃写 UI */
786
+ let navigationGeneration = 0;
450
787
  onUnmounted(() => {
788
+ navigationGeneration += 1;
451
789
  subscription.unsubscribe();
452
790
  });
453
- let currentComponent = null;
791
+ let activateRoute = null;
792
+ let activateRouteParamsKey = "";
793
+ watch(() => props.name, () => {
794
+ activateRoute = null;
795
+ updateChildren();
796
+ });
797
+ watch(() => props.children, () => {
798
+ if (activateRoute) return;
799
+ updateChildren();
800
+ });
801
+ function isStaleNavigation(token) {
802
+ return token !== navigationGeneration;
803
+ }
804
+ function getParamsKey(params) {
805
+ return Object.keys(params).sort().map((key) => `${key}=${params[key]}`).join("&");
806
+ }
807
+ function getNavigatorParams() {
808
+ const pathname = "/" + navigator.urlTree.paths.join("/");
809
+ return {
810
+ pathname: pathname === "/" ? pathname : pathname.replace(/\/+/g, "/"),
811
+ queryParams: navigator.urlTree.queryParams,
812
+ hash: navigator.urlTree.hash
813
+ };
814
+ }
454
815
  async function updateChildren() {
455
- const routeConfig = router.consumeConfig(props.config);
456
- if (!routeConfig) {
457
- currentComponent = null;
816
+ const token = navigationGeneration += 1;
817
+ const to = getNavigatorParams();
818
+ const route = currentRouter.resolve(routes);
819
+ if (!route) {
820
+ navigator.confirmNavigation();
821
+ childRouter.setParams({});
822
+ confirmedParams = to;
823
+ activateRoute = null;
824
+ activateRouteParamsKey = "";
458
825
  children.value = props.children || null;
459
826
  return;
460
827
  }
461
- if (typeof routeConfig.beforeEach === "function") {
462
- if (!await routeConfig.beforeEach()) return;
828
+ childRouter.setParams({ ...currentRouter.lastResolvePathParams });
829
+ if (route === activateRoute && activateRouteParamsKey === getParamsKey(currentRouter.lastResolvePathParams)) {
830
+ navigator.confirmNavigation();
831
+ confirmedParams = to;
832
+ childRouter.refresh();
833
+ return;
463
834
  }
464
- if (routeConfig.component) _updateChildren(routeConfig.component);
465
- else if (routeConfig.asyncComponent) _updateChildren(await routeConfig.asyncComponent());
466
- if (typeof routeConfig.afterEach === "function") routeConfig.afterEach();
467
- }
468
- function _updateChildren(Component) {
469
- childRouter.refresh();
470
- if (Component !== currentComponent) children.value = /* @__PURE__ */ jsx(Component, {});
471
- currentComponent = Component;
835
+ if (typeof route.canActivate === "function") {
836
+ const ok = await route.canActivate({
837
+ to,
838
+ from: confirmedParams,
839
+ router: childRouter,
840
+ params: currentRouter.lastResolvePathParams
841
+ });
842
+ if (isStaleNavigation(token)) return;
843
+ if (!ok) {
844
+ navigator.cancelNavigation();
845
+ return;
846
+ }
847
+ }
848
+ await applyRoute(route, token);
849
+ if (isStaleNavigation(token)) return;
850
+ navigator.confirmNavigation();
851
+ confirmedParams = to;
852
+ }
853
+ async function applyRoute(route, token) {
854
+ let Component = null;
855
+ if (props.name) {
856
+ const namedComponents = route.namedComponents || [];
857
+ for (const named of namedComponents) if (named.name === props.name) {
858
+ if (named.component) Component = named.component;
859
+ else if (typeof named.asyncComponent === "function") {
860
+ Component = await named.asyncComponent();
861
+ if (isStaleNavigation(token)) return;
862
+ }
863
+ break;
864
+ }
865
+ } else if (route.component) Component = route.component;
866
+ else if (typeof route.asyncComponent === "function") {
867
+ Component = await route.asyncComponent();
868
+ if (isStaleNavigation(token)) return;
869
+ if (!Component) {
870
+ activateRoute = null;
871
+ children.value = props.children || null;
872
+ return;
873
+ }
874
+ }
875
+ if (!Component) Component = RouterOutlet;
876
+ let subRoutes = [];
877
+ if (route.path !== "*") {
878
+ if (Array.isArray(route.children)) subRoutes = route.children;
879
+ else if (typeof route.children === "function") {
880
+ subRoutes = await route.children();
881
+ if (isStaleNavigation(token)) return;
882
+ }
883
+ }
884
+ if (!Array.isArray(subRoutes)) subRoutes = [];
885
+ const Context = createContext([{
886
+ provide: Routes,
887
+ useValue: subRoutes
888
+ }]);
889
+ if (isStaleNavigation(token)) return;
890
+ activateRoute = route;
891
+ activateRouteParamsKey = getParamsKey(currentRouter.lastResolvePathParams);
892
+ children.value = /* @__PURE__ */ jsx(Context, { children: /* @__PURE__ */ jsx(Component, {}) });
472
893
  }
473
894
  updateChildren();
474
895
  return () => {
@@ -476,4 +897,4 @@ function RouterOutlet(props) {
476
897
  };
477
898
  }
478
899
  //#endregion
479
- export { BrowserNavigator, Link, Navigator, Router, RouterModule, RouterOutlet, UrlParser, formatQueryParams, formatUrl, useQueryParams };
900
+ export { BrowserNavigator, Link, Navigator, Router, RouterModule, RouterOutlet, Routes, UrlParser, formatQueryParams, formatUrl, useParams, useQueryParams };