@native-router/core 1.4.1 → 1.6.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/README.md CHANGED
@@ -27,6 +27,18 @@ const unlisten = listen(router, (view) => {
27
27
  });
28
28
  ```
29
29
 
30
+ `viewStack` is the SPA-navigation counterpart of the browser's [bfcache](https://web.dev/articles/bfcache). The browser snapshots whole documents so cross-document back/forward restores instantly; the router snapshots resolved views so same-document back/forward (`pushState`/POP) does too. The two layers are complementary and never overlap: a same-document navigation never enters the bfcache, and a bfcache restore does not fire `popstate`. Together with your data layer they stack as **bfcache > viewStack > queryCache**, outermost first — any restore short-circuits every inner layer with zero requests, so freshness is compensated at the edges (e.g. refetch-on-focus in the query layer).
31
+
32
+ Snapshots can outlive their validity — after a logout or an account switch, the previous account's resolved views are exactly what a back POP must not restore. `invalidate(router)` drops every snapshot at once: the currently rendered view is untouched (no re-resolve, no re-render), and the next back/forward re-runs the guards and loaders of the landed entry through the same lazy path as out-of-window entries.
33
+
34
+ ```ts
35
+ import {invalidate} from '@native-router/core';
36
+
37
+ // After the session identity changed: keep rendering the current view,
38
+ // but never restore a snapshot of the previous account on back/forward.
39
+ invalidate(router);
40
+ ```
41
+
30
42
  ### Survives a refresh
31
43
 
32
44
  The session stack is serialized into `history.state` as a bounded tail window (`maxStackDepth`, default 100) and restored on `create`. Warm the window once after a refresh with `initHistoryStack`, and every in-window back/forward renders from cache with zero requests. Entries outside the window fall back to a single lazy re-resolve.
@@ -57,8 +69,9 @@ commit(router, entry.task, entry.location); // commit like a click
57
69
  - Framework-agnostic: bring your own `resolveView`, the view type (`V`) is yours — a string, a vdom, anything
58
70
  - Route matching via path-to-regexp: declaration order, layout routes without `path`, index/fallback children with `path: ''`, strict trailing slashes, case-sensitive, nested params merged deep over shallow
59
71
  - Route guards: static `redirect` and async `beforeLoad` on every route level, run shallow → deep; more than 10 chained redirects reject with `RedirectLoopError`
60
- - Cancelable async navigation: a new resolve supersedes the in-flight one (`currentGuard`); `cancel()` aborts it; a history POP cancels it too. A superseded or cancelled `navigate()` promise **never settles** — don't `await` a navigation that might be superseded
72
+ - Cancelable async navigation: a new resolve supersedes the in-flight one (`currentGuard`); `cancel()` aborts it; a history POP cancels it too. A superseded or cancelled `navigate()` promise **never settles** — don't `await` a navigation that might be superseded. Superseding or cancelling also aborts the chain's `AbortSignal`: guards (`beforeLoad` ctx) and view loaders (`ResolveViewContext`) receive it as `ctx.signal`, so their in-flight requests stop instead of only having results dropped; `preload` resolutions are shared and therefore never aborted
61
73
  - Navigation API: `navigate`, `refresh`, `go`/`forward`/`back`, `commit`/`commitReplace`, `createHref`, `getParams`, `match`, `toLocation`, `resolve`, `resolveTo`
74
+ - `invalidate(router)`: drop the session view snapshots in one call — the current view stays rendered (no re-resolve, no re-render) and the next back/forward re-resolves through the guards; the typical call site is right after a logout/account switch, so a POP cannot render the previous account's data or bypass guards that already ran
62
75
  - Search validation via [Standard Schema](https://standardschema.dev): a `search` schema on any route level (zod/valibot/arktype, no hard dependency), parsed with `parseSearch`/`parseSearchSync`; failures throw `SearchError`
63
76
  - `preload(router, to, {ttl})`: resolve a target through the guards ahead of time, sharing one task across concurrent callers (in-flight dedup) with a TTL, default 30s; consumed entries are dropped on commit
64
77
  - `errorHandler` hook turns resolve failures into fallback views
package/dist/index.cjs CHANGED
@@ -75,6 +75,10 @@ const DEFAULT_PRELOAD_TTL = 30_000;
75
75
  * `history index - baseIndex`; entries whose slot falls outside the
76
76
  * memory window re-resolve lazily when landed on.
77
77
  * - `preloadCache`: router-level cache of {@link preload} results.
78
+ * - `resolvingController`: the in-flight chain's AbortController. It is
79
+ * aborted(supersede/cancel) only while the chain is in flight; a
80
+ * settled chain's controller is left alone so its contexts never
81
+ * report `aborted` for a navigation that actually committed.
78
82
  */
79
83
 
80
84
  /**
@@ -108,8 +112,15 @@ function create(routes, history, resolveView, options) {
108
112
  resolveView,
109
113
  history: instanceHistory,
110
114
  locationStack,
111
- // The view stack is window-relative, so it is exactly as long as the
112
- // location window and stays bounded by maxStackDepth with it.
115
+ // The view stack is the SPA-navigation counterpart of the browser's
116
+ // bfcache a resolved-view snapshot per history entry, restored with
117
+ // zero requests on POP. It is window-relative, so it is exactly as
118
+ // long as the location window and stays bounded by maxStackDepth
119
+ // with it; invalidate() drops these snapshots.
120
+ //
121
+ // viewStack 是 SPA 内导航对应的 bfcache——每个 history 条目一份已解析
122
+ // 视图快照,POP 时零请求还原。它按窗口相对位置存放,与 location 窗口
123
+ // 等长、随 maxStackDepth 一同封顶;invalidate() 丢弃这些快照。
113
124
  viewStack: new Array(locationStack.length).fill(null),
114
125
  baseIndex,
115
126
  preloadCache: new Map(),
@@ -274,7 +285,10 @@ function resolve(router, location) {
274
285
  } = router;
275
286
  return (matched ? resolveView(matched, {
276
287
  router,
277
- location
288
+ location,
289
+ // One-shot resolves(warm-up, direct calls) are never superseded
290
+ // or cancelled: their loaders get a signal that never aborts.
291
+ signal: new AbortController().signal
278
292
  }) : Promise.reject(new NotFoundError(location.pathname))).catch(errorHandler);
279
293
  }
280
294
 
@@ -301,22 +315,37 @@ function resolveTo(router, to, state) {
301
315
  * levels re-run on every hop(keep side-effectful guards idempotent) —
302
316
  * carrying the original user state; at most
303
317
  * {@link MAX_REDIRECTS 10} redirects are followed before a
304
- * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
318
+ * An unmatched pathname keeps the
305
319
  * {@link resolve resolve} behavior: the task rejects with a
306
320
  * {@link NotFoundError} and is routed through `router.errorHandler`.
307
321
  *
322
+ * `opts.signal` is the abort signal of the whole chain: guards see it in
323
+ * their {@link GuardContext contexts}, the view task's
324
+ * {@link ResolveViewContext context} carries it on, and it is aborted
325
+ * once the navigation is superseded or cancelled. Standalone callers
326
+ * that pass nothing(e.g. {@link preload}) get a signal that never
327
+ * aborts — their resolution may be shared, so cancelling it on behalf of
328
+ * one consumer is not sound yet.
329
+ *
308
330
  * @group Methods
309
331
  * @category Router
310
332
  * @param router router instance
311
333
  * @param location the location to resolve; the object itself is never
312
334
  * mutated — a redirect rebinds the resolution to a new location
335
+ * @param opts options; `signal` is the chain's abort signal
313
336
  * @returns the terminal location and its resolve task
314
337
  */
315
- async function resolveEntry(router, location) {
338
+ async function resolveEntry(router, location, opts) {
316
339
  const {
317
340
  resolveView,
318
341
  errorHandler
319
342
  } = router;
343
+ // The chain owner(navigate/refresh) passes its controller's signal;
344
+ // standalone resolutions get one more controller whose signal never
345
+ // aborts, so downstream consumers always observe a real signal.
346
+ const {
347
+ signal = new AbortController().signal
348
+ } = opts ?? {};
320
349
  for (let redirects = 0;; redirects++) {
321
350
  if (redirects > MAX_REDIRECTS) {
322
351
  throw new RedirectLoopError(location.pathname);
@@ -340,7 +369,8 @@ async function resolveEntry(router, location) {
340
369
  await route.beforeLoad?.({
341
370
  router,
342
371
  location,
343
- params: mergeMatchedParams(matched, i)
372
+ params: mergeMatchedParams(matched, i),
373
+ signal
344
374
  }));
345
375
  if (target) {
346
376
  location = toLocation(router, target, location.state);
@@ -354,7 +384,8 @@ async function resolveEntry(router, location) {
354
384
  location,
355
385
  task: resolveView(matched, {
356
386
  router,
357
- location
387
+ location,
388
+ signal
358
389
  }).catch(errorHandler)
359
390
  };
360
391
  }
@@ -460,12 +491,12 @@ function commit(router, resolvePromise, location) {
460
491
  task: resolvePromise
461
492
  }), location);
462
493
  }
463
- function pushEntry(router, entryPromise, fromLocation) {
494
+ function pushEntry(router, entryPromise, fromLocation, ac) {
464
495
  const {
465
496
  history
466
497
  } = router;
467
498
  const nextIndex = getHistoryState(router).index + 1;
468
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
499
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
469
500
  const {
470
501
  location
471
502
  } = entry;
@@ -512,14 +543,14 @@ function commitReplace(router, resolvePromise, location) {
512
543
  task: resolvePromise
513
544
  }), location);
514
545
  }
515
- function replaceEntry(router, entryPromise, fromLocation) {
546
+ function replaceEntry(router, entryPromise, fromLocation, ac) {
516
547
  const {
517
548
  history
518
549
  } = router;
519
550
  const {
520
551
  index
521
552
  } = getHistoryState(router);
522
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
553
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
523
554
  const {
524
555
  location
525
556
  } = entry;
@@ -545,7 +576,8 @@ function replaceEntry(router, entryPromise, fromLocation) {
545
576
  });
546
577
  });
547
578
  }
548
- function commitBase(router, entryPromise, location, onResolved) {
579
+ function commitBase(router, entryPromise, location, ac, onResolved) {
580
+ const core = router;
549
581
  const {
550
582
  currentGuard,
551
583
  onLoadingChange = util.noop
@@ -553,8 +585,16 @@ function commitBase(router, entryPromise, location, onResolved) {
553
585
  if (router.resolving) {
554
586
  // Cancel current resolve
555
587
  onLoadingChange();
588
+ // ...and stop its requests: the guard below discards the superseded
589
+ // chain's result, so its in-flight guards/loaders must not keep
590
+ // consuming the network until they settle on their own.
591
+ core.resolvingController?.abort();
556
592
  }
557
593
  router.resolving = location;
594
+ // External commits(plain tasks from resolveTo/preload) carry no
595
+ // controller; clearing the slot keeps a stale one from being aborted
596
+ // by a later supersede.
597
+ core.resolvingController = ac;
558
598
  onLoadingChange('pending');
559
599
  return (
560
600
  // The whole chain — route guards AND the view task — is guarded from
@@ -568,15 +608,19 @@ function commitBase(router, entryPromise, location, onResolved) {
568
608
  entry,
569
609
  resolvedView
570
610
  }) => {
611
+ // This chain settled: it is no longer in flight. Clearing the
612
+ // mark BEFORE onResolved matters because onResolved commits
613
+ // history, which synchronously re-enters cancel() through the
614
+ // router's own listen() handler — an already-settled chain must
615
+ // not be aborted(or fire a cancel signal) as if it were still
616
+ // running. Superseded chains park forever, so the mark is always
617
+ // ours to clear.
618
+ router.resolving = undefined;
571
619
  onResolved(resolvedView, entry);
572
620
  // The navigation consumed this resolution: drop its preload
573
621
  // cache slots so a later preload re-resolves fresh state.
574
622
  evictPreloadCache(router, entry.location);
575
623
  }).then(() => {
576
- // This chain settled, so it is no longer in flight. Superseded
577
- // chains park forever, so only the latest chain can reach here:
578
- // the mark is always ours to clear.
579
- router.resolving = undefined;
580
624
  onLoadingChange('resolved');
581
625
  }).catch(e => {
582
626
  router.resolving = undefined;
@@ -591,7 +635,10 @@ function commitBase(router, entryPromise, location, onResolved) {
591
635
  * the view resolves; the history entry is committed on the terminal
592
636
  * location when guards redirected. The guard phase is part of the
593
637
  * cancelable navigation: a superseding navigate or a `cancel()` while
594
- * guards are still running discards this navigation.
638
+ * guards are still running discards this navigation — and aborts the
639
+ * chain's `signal`, so guards and loaders observing it({@link
640
+ * GuardContext.signal}, {@link ResolveViewContext.signal}) stop their
641
+ * requests instead of only having their results dropped.
595
642
  * @group Methods
596
643
  * @category Router
597
644
  * @param router router instance
@@ -600,19 +647,29 @@ function commitBase(router, entryPromise, location, onResolved) {
600
647
  */
601
648
  function navigate(router, to, state) {
602
649
  const location = toLocation(router, to, state);
603
- return pushEntry(router, resolveEntry(router, location), location);
650
+ // One controller per navigation round: guards and view loaders of the
651
+ // whole chain(including redirect hops) share its signal.
652
+ const ac = new AbortController();
653
+ return pushEntry(router, resolveEntry(router, location, {
654
+ signal: ac.signal
655
+ }), location, ac);
604
656
  }
605
657
 
606
658
  /**
607
659
  * Refresh the page. Route guards run before the view resolves; a redirect
608
- * replaces the current entry with the terminal location.
660
+ * replaces the current entry with the terminal location. The refresh is a
661
+ * cancelable navigation chain like {@link navigate}: superseding it or
662
+ * `cancel()` aborts its signal.
609
663
  * @group Methods
610
664
  * @category Router
611
665
  * @param router router instance
612
666
  */
613
667
  function refresh(router) {
614
668
  const location = getLocation(router);
615
- return replaceEntry(router, resolveEntry(router, location), location);
669
+ const ac = new AbortController();
670
+ return replaceEntry(router, resolveEntry(router, location, {
671
+ signal: ac.signal
672
+ }), location, ac);
616
673
  }
617
674
 
618
675
  /**
@@ -662,12 +719,22 @@ function createHref({
662
719
  }
663
720
 
664
721
  /**
665
- * Cancel the current navigate.
722
+ * Cancel the current navigate. The in-flight chain's guards/loaders are
723
+ * aborted through their signal, not merely discarded.
666
724
  * @group Methods
667
725
  * @category Router
668
726
  * @param router router instance
669
727
  */
670
728
  function cancel(router) {
729
+ const core = router;
730
+ // Aborting is reserved for chains that are still running: a chain that
731
+ // just committed re-enters cancel() synchronously through listen()'s
732
+ // history handler and must not have its(possibly still-rendered) view
733
+ // contexts aborted after the fact.
734
+ if (router.resolving) {
735
+ core.resolvingController?.abort();
736
+ core.resolvingController = undefined;
737
+ }
671
738
  // The cancelled chain parks forever, so nothing else will clear the
672
739
  // in-flight mark: drop it here, or a later navigation would fire a
673
740
  // spurious cancel signal(`onLoadingChange()`) for a dead resolve.
@@ -693,6 +760,31 @@ function initHistoryStack(router) {
693
760
  });
694
761
  }
695
762
 
763
+ /**
764
+ * Drop every view snapshot of the session window. The already rendered
765
+ * view is untouched — no re-resolve, no re-render; only future POPs
766
+ * change: with no snapshot to hit, {@link listen} falls back to the same
767
+ * lazy re-resolve path as out-of-window entries, so the landed entry's
768
+ * guards(`redirect`/`beforeLoad`) and loaders run again. Call it when
769
+ * the snapshots stop being valid — e.g. right after a logout or an
770
+ * account switch, so a back POP cannot render the previous account's
771
+ * view or bypass guards that already ran in the session.
772
+ *
773
+ * 丢弃会话窗口内的全部视图快照。已渲染的当前视图不受影响——不重解析、
774
+ * 不重渲染;变化的只有后续 POP:无快照可命中时,listen 落入与窗口外条目
775
+ * 相同的惰性重解析路径,落点条目的守卫与加载器重新执行。快照失效时调用
776
+ * ——例如登出/切换账号后,后退 POP 不再渲染上一账号的视图、也不再绕过
777
+ * 会话内已执行过的守卫。
778
+ * @group Methods
779
+ * @category Router
780
+ * @param router router instance
781
+ */
782
+ function invalidate(router) {
783
+ // Keep the window shape: locationStack stays untouched, so getParams
784
+ // and the serialized window keep working — only the snapshots go.
785
+ router.viewStack = new Array(router.locationStack.length).fill(null);
786
+ }
787
+
696
788
  /**
697
789
  * Listen the history change.
698
790
  * @group Methods
@@ -874,6 +966,7 @@ exports.getLocation = getLocation;
874
966
  exports.getParams = getParams;
875
967
  exports.go = go;
876
968
  exports.initHistoryStack = initHistoryStack;
969
+ exports.invalidate = invalidate;
877
970
  exports.listen = listen;
878
971
  exports.match = match;
879
972
  exports.mergeMatchedParams = mergeMatchedParams;
package/dist/index.mjs CHANGED
@@ -73,6 +73,10 @@ const DEFAULT_PRELOAD_TTL = 30_000;
73
73
  * `history index - baseIndex`; entries whose slot falls outside the
74
74
  * memory window re-resolve lazily when landed on.
75
75
  * - `preloadCache`: router-level cache of {@link preload} results.
76
+ * - `resolvingController`: the in-flight chain's AbortController. It is
77
+ * aborted(supersede/cancel) only while the chain is in flight; a
78
+ * settled chain's controller is left alone so its contexts never
79
+ * report `aborted` for a navigation that actually committed.
76
80
  */
77
81
 
78
82
  /**
@@ -106,8 +110,15 @@ function create(routes, history, resolveView, options) {
106
110
  resolveView,
107
111
  history: instanceHistory,
108
112
  locationStack,
109
- // The view stack is window-relative, so it is exactly as long as the
110
- // location window and stays bounded by maxStackDepth with it.
113
+ // The view stack is the SPA-navigation counterpart of the browser's
114
+ // bfcache a resolved-view snapshot per history entry, restored with
115
+ // zero requests on POP. It is window-relative, so it is exactly as
116
+ // long as the location window and stays bounded by maxStackDepth
117
+ // with it; invalidate() drops these snapshots.
118
+ //
119
+ // viewStack 是 SPA 内导航对应的 bfcache——每个 history 条目一份已解析
120
+ // 视图快照,POP 时零请求还原。它按窗口相对位置存放,与 location 窗口
121
+ // 等长、随 maxStackDepth 一同封顶;invalidate() 丢弃这些快照。
111
122
  viewStack: new Array(locationStack.length).fill(null),
112
123
  baseIndex,
113
124
  preloadCache: new Map(),
@@ -272,7 +283,10 @@ function resolve(router, location) {
272
283
  } = router;
273
284
  return (matched ? resolveView(matched, {
274
285
  router,
275
- location
286
+ location,
287
+ // One-shot resolves(warm-up, direct calls) are never superseded
288
+ // or cancelled: their loaders get a signal that never aborts.
289
+ signal: new AbortController().signal
276
290
  }) : Promise.reject(new NotFoundError(location.pathname))).catch(errorHandler);
277
291
  }
278
292
 
@@ -299,22 +313,37 @@ function resolveTo(router, to, state) {
299
313
  * levels re-run on every hop(keep side-effectful guards idempotent) —
300
314
  * carrying the original user state; at most
301
315
  * {@link MAX_REDIRECTS 10} redirects are followed before a
302
- * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
316
+ * An unmatched pathname keeps the
303
317
  * {@link resolve resolve} behavior: the task rejects with a
304
318
  * {@link NotFoundError} and is routed through `router.errorHandler`.
305
319
  *
320
+ * `opts.signal` is the abort signal of the whole chain: guards see it in
321
+ * their {@link GuardContext contexts}, the view task's
322
+ * {@link ResolveViewContext context} carries it on, and it is aborted
323
+ * once the navigation is superseded or cancelled. Standalone callers
324
+ * that pass nothing(e.g. {@link preload}) get a signal that never
325
+ * aborts — their resolution may be shared, so cancelling it on behalf of
326
+ * one consumer is not sound yet.
327
+ *
306
328
  * @group Methods
307
329
  * @category Router
308
330
  * @param router router instance
309
331
  * @param location the location to resolve; the object itself is never
310
332
  * mutated — a redirect rebinds the resolution to a new location
333
+ * @param opts options; `signal` is the chain's abort signal
311
334
  * @returns the terminal location and its resolve task
312
335
  */
313
- async function resolveEntry(router, location) {
336
+ async function resolveEntry(router, location, opts) {
314
337
  const {
315
338
  resolveView,
316
339
  errorHandler
317
340
  } = router;
341
+ // The chain owner(navigate/refresh) passes its controller's signal;
342
+ // standalone resolutions get one more controller whose signal never
343
+ // aborts, so downstream consumers always observe a real signal.
344
+ const {
345
+ signal = new AbortController().signal
346
+ } = opts ?? {};
318
347
  for (let redirects = 0;; redirects++) {
319
348
  if (redirects > MAX_REDIRECTS) {
320
349
  throw new RedirectLoopError(location.pathname);
@@ -338,7 +367,8 @@ async function resolveEntry(router, location) {
338
367
  await route.beforeLoad?.({
339
368
  router,
340
369
  location,
341
- params: mergeMatchedParams(matched, i)
370
+ params: mergeMatchedParams(matched, i),
371
+ signal
342
372
  }));
343
373
  if (target) {
344
374
  location = toLocation(router, target, location.state);
@@ -352,7 +382,8 @@ async function resolveEntry(router, location) {
352
382
  location,
353
383
  task: resolveView(matched, {
354
384
  router,
355
- location
385
+ location,
386
+ signal
356
387
  }).catch(errorHandler)
357
388
  };
358
389
  }
@@ -458,12 +489,12 @@ function commit(router, resolvePromise, location) {
458
489
  task: resolvePromise
459
490
  }), location);
460
491
  }
461
- function pushEntry(router, entryPromise, fromLocation) {
492
+ function pushEntry(router, entryPromise, fromLocation, ac) {
462
493
  const {
463
494
  history
464
495
  } = router;
465
496
  const nextIndex = getHistoryState(router).index + 1;
466
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
497
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
467
498
  const {
468
499
  location
469
500
  } = entry;
@@ -510,14 +541,14 @@ function commitReplace(router, resolvePromise, location) {
510
541
  task: resolvePromise
511
542
  }), location);
512
543
  }
513
- function replaceEntry(router, entryPromise, fromLocation) {
544
+ function replaceEntry(router, entryPromise, fromLocation, ac) {
514
545
  const {
515
546
  history
516
547
  } = router;
517
548
  const {
518
549
  index
519
550
  } = getHistoryState(router);
520
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
551
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
521
552
  const {
522
553
  location
523
554
  } = entry;
@@ -543,7 +574,8 @@ function replaceEntry(router, entryPromise, fromLocation) {
543
574
  });
544
575
  });
545
576
  }
546
- function commitBase(router, entryPromise, location, onResolved) {
577
+ function commitBase(router, entryPromise, location, ac, onResolved) {
578
+ const core = router;
547
579
  const {
548
580
  currentGuard,
549
581
  onLoadingChange = noop
@@ -551,8 +583,16 @@ function commitBase(router, entryPromise, location, onResolved) {
551
583
  if (router.resolving) {
552
584
  // Cancel current resolve
553
585
  onLoadingChange();
586
+ // ...and stop its requests: the guard below discards the superseded
587
+ // chain's result, so its in-flight guards/loaders must not keep
588
+ // consuming the network until they settle on their own.
589
+ core.resolvingController?.abort();
554
590
  }
555
591
  router.resolving = location;
592
+ // External commits(plain tasks from resolveTo/preload) carry no
593
+ // controller; clearing the slot keeps a stale one from being aborted
594
+ // by a later supersede.
595
+ core.resolvingController = ac;
556
596
  onLoadingChange('pending');
557
597
  return (
558
598
  // The whole chain — route guards AND the view task — is guarded from
@@ -566,15 +606,19 @@ function commitBase(router, entryPromise, location, onResolved) {
566
606
  entry,
567
607
  resolvedView
568
608
  }) => {
609
+ // This chain settled: it is no longer in flight. Clearing the
610
+ // mark BEFORE onResolved matters because onResolved commits
611
+ // history, which synchronously re-enters cancel() through the
612
+ // router's own listen() handler — an already-settled chain must
613
+ // not be aborted(or fire a cancel signal) as if it were still
614
+ // running. Superseded chains park forever, so the mark is always
615
+ // ours to clear.
616
+ router.resolving = undefined;
569
617
  onResolved(resolvedView, entry);
570
618
  // The navigation consumed this resolution: drop its preload
571
619
  // cache slots so a later preload re-resolves fresh state.
572
620
  evictPreloadCache(router, entry.location);
573
621
  }).then(() => {
574
- // This chain settled, so it is no longer in flight. Superseded
575
- // chains park forever, so only the latest chain can reach here:
576
- // the mark is always ours to clear.
577
- router.resolving = undefined;
578
622
  onLoadingChange('resolved');
579
623
  }).catch(e => {
580
624
  router.resolving = undefined;
@@ -589,7 +633,10 @@ function commitBase(router, entryPromise, location, onResolved) {
589
633
  * the view resolves; the history entry is committed on the terminal
590
634
  * location when guards redirected. The guard phase is part of the
591
635
  * cancelable navigation: a superseding navigate or a `cancel()` while
592
- * guards are still running discards this navigation.
636
+ * guards are still running discards this navigation — and aborts the
637
+ * chain's `signal`, so guards and loaders observing it({@link
638
+ * GuardContext.signal}, {@link ResolveViewContext.signal}) stop their
639
+ * requests instead of only having their results dropped.
593
640
  * @group Methods
594
641
  * @category Router
595
642
  * @param router router instance
@@ -598,19 +645,29 @@ function commitBase(router, entryPromise, location, onResolved) {
598
645
  */
599
646
  function navigate(router, to, state) {
600
647
  const location = toLocation(router, to, state);
601
- return pushEntry(router, resolveEntry(router, location), location);
648
+ // One controller per navigation round: guards and view loaders of the
649
+ // whole chain(including redirect hops) share its signal.
650
+ const ac = new AbortController();
651
+ return pushEntry(router, resolveEntry(router, location, {
652
+ signal: ac.signal
653
+ }), location, ac);
602
654
  }
603
655
 
604
656
  /**
605
657
  * Refresh the page. Route guards run before the view resolves; a redirect
606
- * replaces the current entry with the terminal location.
658
+ * replaces the current entry with the terminal location. The refresh is a
659
+ * cancelable navigation chain like {@link navigate}: superseding it or
660
+ * `cancel()` aborts its signal.
607
661
  * @group Methods
608
662
  * @category Router
609
663
  * @param router router instance
610
664
  */
611
665
  function refresh(router) {
612
666
  const location = getLocation(router);
613
- return replaceEntry(router, resolveEntry(router, location), location);
667
+ const ac = new AbortController();
668
+ return replaceEntry(router, resolveEntry(router, location, {
669
+ signal: ac.signal
670
+ }), location, ac);
614
671
  }
615
672
 
616
673
  /**
@@ -660,12 +717,22 @@ function createHref({
660
717
  }
661
718
 
662
719
  /**
663
- * Cancel the current navigate.
720
+ * Cancel the current navigate. The in-flight chain's guards/loaders are
721
+ * aborted through their signal, not merely discarded.
664
722
  * @group Methods
665
723
  * @category Router
666
724
  * @param router router instance
667
725
  */
668
726
  function cancel(router) {
727
+ const core = router;
728
+ // Aborting is reserved for chains that are still running: a chain that
729
+ // just committed re-enters cancel() synchronously through listen()'s
730
+ // history handler and must not have its(possibly still-rendered) view
731
+ // contexts aborted after the fact.
732
+ if (router.resolving) {
733
+ core.resolvingController?.abort();
734
+ core.resolvingController = undefined;
735
+ }
669
736
  // The cancelled chain parks forever, so nothing else will clear the
670
737
  // in-flight mark: drop it here, or a later navigation would fire a
671
738
  // spurious cancel signal(`onLoadingChange()`) for a dead resolve.
@@ -691,6 +758,31 @@ function initHistoryStack(router) {
691
758
  });
692
759
  }
693
760
 
761
+ /**
762
+ * Drop every view snapshot of the session window. The already rendered
763
+ * view is untouched — no re-resolve, no re-render; only future POPs
764
+ * change: with no snapshot to hit, {@link listen} falls back to the same
765
+ * lazy re-resolve path as out-of-window entries, so the landed entry's
766
+ * guards(`redirect`/`beforeLoad`) and loaders run again. Call it when
767
+ * the snapshots stop being valid — e.g. right after a logout or an
768
+ * account switch, so a back POP cannot render the previous account's
769
+ * view or bypass guards that already ran in the session.
770
+ *
771
+ * 丢弃会话窗口内的全部视图快照。已渲染的当前视图不受影响——不重解析、
772
+ * 不重渲染;变化的只有后续 POP:无快照可命中时,listen 落入与窗口外条目
773
+ * 相同的惰性重解析路径,落点条目的守卫与加载器重新执行。快照失效时调用
774
+ * ——例如登出/切换账号后,后退 POP 不再渲染上一账号的视图、也不再绕过
775
+ * 会话内已执行过的守卫。
776
+ * @group Methods
777
+ * @category Router
778
+ * @param router router instance
779
+ */
780
+ function invalidate(router) {
781
+ // Keep the window shape: locationStack stays untouched, so getParams
782
+ // and the serialized window keep working — only the snapshots go.
783
+ router.viewStack = new Array(router.locationStack.length).fill(null);
784
+ }
785
+
694
786
  /**
695
787
  * Listen the history change.
696
788
  * @group Methods
@@ -856,4 +948,4 @@ function isThenable(value) {
856
948
  return typeof value?.then === 'function';
857
949
  }
858
950
 
859
- export { NativeRouterError, NotFoundError, RedirectLoopError, SearchError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, listen, match, mergeMatchedParams, navigate, parseSearch, parseSearchInput, parseSearchSync, preload, refresh, resolve, resolveEntry, resolveTo, setOptions, toLocation };
951
+ export { NativeRouterError, NotFoundError, RedirectLoopError, SearchError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, invalidate, listen, match, mergeMatchedParams, navigate, parseSearch, parseSearchInput, parseSearchSync, preload, refresh, resolve, resolveEntry, resolveTo, setOptions, toLocation };
@@ -88,18 +88,29 @@ export declare function resolveTo<R extends BaseRoute = BaseRoute, V = any>(rout
88
88
  * levels re-run on every hop(keep side-effectful guards idempotent) —
89
89
  * carrying the original user state; at most
90
90
  * {@link MAX_REDIRECTS 10} redirects are followed before a
91
- * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
91
+ * An unmatched pathname keeps the
92
92
  * {@link resolve resolve} behavior: the task rejects with a
93
93
  * {@link NotFoundError} and is routed through `router.errorHandler`.
94
94
  *
95
+ * `opts.signal` is the abort signal of the whole chain: guards see it in
96
+ * their {@link GuardContext contexts}, the view task's
97
+ * {@link ResolveViewContext context} carries it on, and it is aborted
98
+ * once the navigation is superseded or cancelled. Standalone callers
99
+ * that pass nothing(e.g. {@link preload}) get a signal that never
100
+ * aborts — their resolution may be shared, so cancelling it on behalf of
101
+ * one consumer is not sound yet.
102
+ *
95
103
  * @group Methods
96
104
  * @category Router
97
105
  * @param router router instance
98
106
  * @param location the location to resolve; the object itself is never
99
107
  * mutated — a redirect rebinds the resolution to a new location
108
+ * @param opts options; `signal` is the chain's abort signal
100
109
  * @returns the terminal location and its resolve task
101
110
  */
102
- export declare function resolveEntry<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>, location: Location): Promise<ResolvedEntry<V>>;
111
+ export declare function resolveEntry<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>, location: Location, opts?: {
112
+ signal?: AbortSignal;
113
+ }): Promise<ResolvedEntry<V>>;
103
114
  /**
104
115
  * Resolve a target through the route guards(`redirect`/`beforeLoad`) and
105
116
  * cache the result at the router level, keyed by `pathname + search`.
@@ -147,7 +158,10 @@ export declare function commitReplace<R extends BaseRoute = BaseRoute, V = any>(
147
158
  * the view resolves; the history entry is committed on the terminal
148
159
  * location when guards redirected. The guard phase is part of the
149
160
  * cancelable navigation: a superseding navigate or a `cancel()` while
150
- * guards are still running discards this navigation.
161
+ * guards are still running discards this navigation — and aborts the
162
+ * chain's `signal`, so guards and loaders observing it({@link
163
+ * GuardContext.signal}, {@link ResolveViewContext.signal}) stop their
164
+ * requests instead of only having their results dropped.
151
165
  * @group Methods
152
166
  * @category Router
153
167
  * @param router router instance
@@ -157,7 +171,9 @@ export declare function commitReplace<R extends BaseRoute = BaseRoute, V = any>(
157
171
  export declare function navigate<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>, to: string, state?: any): Promise<void>;
158
172
  /**
159
173
  * Refresh the page. Route guards run before the view resolves; a redirect
160
- * replaces the current entry with the terminal location.
174
+ * replaces the current entry with the terminal location. The refresh is a
175
+ * cancelable navigation chain like {@link navigate}: superseding it or
176
+ * `cancel()` aborts its signal.
161
177
  * @group Methods
162
178
  * @category Router
163
179
  * @param router router instance
@@ -195,7 +211,8 @@ export declare function back<R extends BaseRoute = BaseRoute, V = any>(router: R
195
211
  */
196
212
  export declare function createHref<R extends BaseRoute = BaseRoute, V = any>({ baseUrl, history }: RouterInstance<R, V>, to: string): string;
197
213
  /**
198
- * Cancel the current navigate.
214
+ * Cancel the current navigate. The in-flight chain's guards/loaders are
215
+ * aborted through their signal, not merely discarded.
199
216
  * @group Methods
200
217
  * @category Router
201
218
  * @param router router instance
@@ -213,6 +230,26 @@ export declare function cancel<R extends BaseRoute = BaseRoute, V = any>(router:
213
230
  * @param router router instance
214
231
  */
215
232
  export declare function initHistoryStack<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>): Promise<void>;
233
+ /**
234
+ * Drop every view snapshot of the session window. The already rendered
235
+ * view is untouched — no re-resolve, no re-render; only future POPs
236
+ * change: with no snapshot to hit, {@link listen} falls back to the same
237
+ * lazy re-resolve path as out-of-window entries, so the landed entry's
238
+ * guards(`redirect`/`beforeLoad`) and loaders run again. Call it when
239
+ * the snapshots stop being valid — e.g. right after a logout or an
240
+ * account switch, so a back POP cannot render the previous account's
241
+ * view or bypass guards that already ran in the session.
242
+ *
243
+ * 丢弃会话窗口内的全部视图快照。已渲染的当前视图不受影响——不重解析、
244
+ * 不重渲染;变化的只有后续 POP:无快照可命中时,listen 落入与窗口外条目
245
+ * 相同的惰性重解析路径,落点条目的守卫与加载器重新执行。快照失效时调用
246
+ * ——例如登出/切换账号后,后退 POP 不再渲染上一账号的视图、也不再绕过
247
+ * 会话内已执行过的守卫。
248
+ * @group Methods
249
+ * @category Router
250
+ * @param router router instance
251
+ */
252
+ export declare function invalidate<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>): void;
216
253
  /**
217
254
  * Listen the history change.
218
255
  * @group Methods
@@ -79,32 +79,61 @@ export type SearchInput = Record<string, string | string[]>;
79
79
  * @category Route
80
80
  */
81
81
  export type SearchOutputOf<S> = S extends StandardSchemaV1<any, infer Output> ? Output : never;
82
+ /** ASCII approximation of path-to-regexp's `ID_Start`. */
83
+ type ParamStartChar = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' | '_' | '$';
84
+ /** ASCII approximation of path-to-regexp's `ID_Continue`. */
85
+ type ParamContinueChar = ParamStartChar | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
86
+ type ParamValueOf<Name extends string, Mode extends 'param' | 'wildcard'> = Mode extends 'wildcard' ? {
87
+ [K in Name]: string[];
88
+ } : {
89
+ [K in Name]: string;
90
+ };
91
+ /**
92
+ * Scan a segment char by char: `\\x` escapes the next char, `:name`
93
+ * starts a param, `*name` starts a wildcard, anything else is static
94
+ * text.
95
+ */
96
+ type SegmentParamsOf<Seg extends string> = Seg extends `${infer Char}${infer Rest}` ? Char extends '\\' ? Rest extends `${string}${infer Tail}` ? SegmentParamsOf<Tail> : {} : Char extends ':' | '*' ? Rest extends `${infer First}${infer Rest2}` ? First extends ParamStartChar ? ParamNameOf<Rest2, First, Char extends '*' ? 'wildcard' : 'param'> : {} : {} : SegmentParamsOf<Rest> : {};
97
+ /** Consume the identifier run started by a `ParamStartChar`. */
98
+ type ParamNameOf<Rest extends string, Name extends string, Mode extends 'param' | 'wildcard'> = Rest extends `${infer Char}${infer Tail}` ? Char extends ParamContinueChar ? ParamNameOf<Tail, `${Name}${Char}`, Mode> : Char extends '?' | '(' | ')' | '[' | ']' | '+' | '!' | '*' ? {} : ParamValueOf<Name, Mode> & SegmentParamsOf<Rest> : ParamValueOf<Name, Mode>;
82
99
  /**
83
- * Params contributed by a single path segment: `:name` is required,
84
- * `:name?` is optional, anything else(static or wildcard) contributes
85
- * nothing.
100
+ * Params contributed by a single path segment, modeled after the
101
+ * path-to-regexp **8.4.2** string grammar(the version this package
102
+ * locks):
103
+ *
104
+ * - `:name` contributes a required `string` param wherever it appears
105
+ * in the segment — `:id`, `page-:id`, `:from-:to` all work at
106
+ * runtime and are modeled;
107
+ * - `*name` contributes a `string[]` wildcard param(the runtime
108
+ * matcher splits a wildcard value by `/`).
109
+ *
110
+ * Everything else contributes nothing:
86
111
  *
87
- * Only the segment-exact forms of the path-to-regexp 6 syntax are
88
- * modeled. Prefix/suffix params(`/page-:id`), repetitions(`:id*`,
89
- * `:id+`) and custom regexes(`:id(\\d+)`) are matched at runtime but
90
- * not modeled here they simply contribute no keys.
112
+ * - the v6-era suffixes `:id?`, `:id+`, `:id*`, `:id(\\d+)` are **not**
113
+ * runtime syntax in 8.4.2 the matcher throws a `PathError` when the
114
+ * path is compiled, so they are deliberately left unmodeled instead
115
+ * of endorsing a path that crashes;
116
+ * - quoted names(`:"x y"`) and non-ASCII identifier chars are not
117
+ * modeled(the scanner only knows ASCII identifiers).
118
+ *
119
+ * Note: wildcard params surface as `string[]` at runtime while the
120
+ * router-level types(`Matched.params`,
121
+ * {@link GuardContext.params}) are `Record<string, string>` — the
122
+ * router does not model wildcard params.
91
123
  * @group Types
92
124
  * @category Route
93
125
  */
94
- export type PathParamsOf<Seg extends string> = Seg extends `:${infer Name}?` ? {
95
- [K in Name & string]?: string;
96
- } : Seg extends `:${infer Name}` ? {
97
- [K in Name & string]: string;
98
- } : {};
126
+ export type PathParamsOf<Seg extends string> = SegmentParamsOf<Seg>;
99
127
  /**
100
128
  * Extract the params shape of a route path pattern. Splits the pattern
101
129
  * into `/`-separated segments and intersects the params of each, e.g.
102
- * `ExtractPathParams<'/users/:id/posts/:postId?'>` is
103
- * `{id: string} & {postId?: string}`.
130
+ * `ExtractPathParams<'/users/:id/files/*rest'>` is
131
+ * `{id: string} & {rest: string[]}`.
104
132
  *
105
- * Within the modeled path-to-regexp 6 syntax scope(see
106
- * {@link PathParamsOf}); wildcards(`*`) and static segments are
107
- * ignored. Distributes over unions of patterns.
133
+ * Within the modeled path-to-regexp 8.4.2 syntax scope(see
134
+ * {@link PathParamsOf}); static segments are ignored and v6-era
135
+ * suffixes(`:id?`, `:id(\\d+)`, …) contribute nothing because the
136
+ * runtime matcher rejects them. Distributes over unions of patterns.
108
137
  * @group Types
109
138
  * @category Route
110
139
  */
@@ -118,6 +147,13 @@ export type GuardContext<R extends BaseRoute = BaseRoute> = {
118
147
  router: RouterInstance<R>;
119
148
  location: Location;
120
149
  params: Record<string, string>;
150
+ /**
151
+ * Aborted when this navigation is superseded by a newer one or
152
+ * cancelled(see {@link RouterInstance.cancelAll cancel}); pass it to
153
+ * the guard's requests(e.g. `fetch(url, {signal})`) so a discarded
154
+ * navigation stops consuming the network.
155
+ */
156
+ signal: AbortSignal;
121
157
  };
122
158
  export type BaseRoute<T = any> = {
123
159
  path?: Path;
@@ -147,6 +183,12 @@ export type Matched<R extends BaseRoute = BaseRoute> = {
147
183
  export type ResolveViewContext<R extends BaseRoute> = {
148
184
  router: RouterInstance<R>;
149
185
  location: Location;
186
+ /**
187
+ * The navigation chain's abort signal: aborted when this navigation is
188
+ * superseded by a newer one or cancelled. Frameworks forward it into
189
+ * their data contexts so loaders can abort their requests.
190
+ */
191
+ signal: AbortSignal;
150
192
  };
151
193
  export type ResolveView<R extends BaseRoute, V> = (matched: Matched<R>[], ctx: ResolveViewContext<R>) => Promise<V>;
152
194
  export type Options<V> = {
@@ -191,3 +233,4 @@ export type RouterInstance<R extends BaseRoute, V = any> = {
191
233
  cancelAll(): void;
192
234
  resolving?: Location;
193
235
  } & RequiredOf<Options<V>, 'baseUrl' | 'maxStackDepth'>;
236
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-router/core",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/types/index.d.ts",