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