@native-router/core 1.4.1 → 1.5.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
@@ -57,7 +57,7 @@ commit(router, entry.task, entry.location); // commit like a click
57
57
  - Framework-agnostic: bring your own `resolveView`, the view type (`V`) is yours — a string, a vdom, anything
58
58
  - 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
59
  - 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
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. 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
61
  - Navigation API: `navigate`, `refresh`, `go`/`forward`/`back`, `commit`/`commitReplace`, `createHref`, `getParams`, `match`, `toLocation`, `resolve`, `resolveTo`
62
62
  - 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
63
  - `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
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
  /**
@@ -274,7 +278,10 @@ function resolve(router, location) {
274
278
  } = router;
275
279
  return (matched ? resolveView(matched, {
276
280
  router,
277
- location
281
+ location,
282
+ // One-shot resolves(warm-up, direct calls) are never superseded
283
+ // or cancelled: their loaders get a signal that never aborts.
284
+ signal: new AbortController().signal
278
285
  }) : Promise.reject(new NotFoundError(location.pathname))).catch(errorHandler);
279
286
  }
280
287
 
@@ -301,22 +308,37 @@ function resolveTo(router, to, state) {
301
308
  * levels re-run on every hop(keep side-effectful guards idempotent) —
302
309
  * carrying the original user state; at most
303
310
  * {@link MAX_REDIRECTS 10} redirects are followed before a
304
- * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
311
+ * An unmatched pathname keeps the
305
312
  * {@link resolve resolve} behavior: the task rejects with a
306
313
  * {@link NotFoundError} and is routed through `router.errorHandler`.
307
314
  *
315
+ * `opts.signal` is the abort signal of the whole chain: guards see it in
316
+ * their {@link GuardContext contexts}, the view task's
317
+ * {@link ResolveViewContext context} carries it on, and it is aborted
318
+ * once the navigation is superseded or cancelled. Standalone callers
319
+ * that pass nothing(e.g. {@link preload}) get a signal that never
320
+ * aborts — their resolution may be shared, so cancelling it on behalf of
321
+ * one consumer is not sound yet.
322
+ *
308
323
  * @group Methods
309
324
  * @category Router
310
325
  * @param router router instance
311
326
  * @param location the location to resolve; the object itself is never
312
327
  * mutated — a redirect rebinds the resolution to a new location
328
+ * @param opts options; `signal` is the chain's abort signal
313
329
  * @returns the terminal location and its resolve task
314
330
  */
315
- async function resolveEntry(router, location) {
331
+ async function resolveEntry(router, location, opts) {
316
332
  const {
317
333
  resolveView,
318
334
  errorHandler
319
335
  } = router;
336
+ // The chain owner(navigate/refresh) passes its controller's signal;
337
+ // standalone resolutions get one more controller whose signal never
338
+ // aborts, so downstream consumers always observe a real signal.
339
+ const {
340
+ signal = new AbortController().signal
341
+ } = opts ?? {};
320
342
  for (let redirects = 0;; redirects++) {
321
343
  if (redirects > MAX_REDIRECTS) {
322
344
  throw new RedirectLoopError(location.pathname);
@@ -340,7 +362,8 @@ async function resolveEntry(router, location) {
340
362
  await route.beforeLoad?.({
341
363
  router,
342
364
  location,
343
- params: mergeMatchedParams(matched, i)
365
+ params: mergeMatchedParams(matched, i),
366
+ signal
344
367
  }));
345
368
  if (target) {
346
369
  location = toLocation(router, target, location.state);
@@ -354,7 +377,8 @@ async function resolveEntry(router, location) {
354
377
  location,
355
378
  task: resolveView(matched, {
356
379
  router,
357
- location
380
+ location,
381
+ signal
358
382
  }).catch(errorHandler)
359
383
  };
360
384
  }
@@ -460,12 +484,12 @@ function commit(router, resolvePromise, location) {
460
484
  task: resolvePromise
461
485
  }), location);
462
486
  }
463
- function pushEntry(router, entryPromise, fromLocation) {
487
+ function pushEntry(router, entryPromise, fromLocation, ac) {
464
488
  const {
465
489
  history
466
490
  } = router;
467
491
  const nextIndex = getHistoryState(router).index + 1;
468
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
492
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
469
493
  const {
470
494
  location
471
495
  } = entry;
@@ -512,14 +536,14 @@ function commitReplace(router, resolvePromise, location) {
512
536
  task: resolvePromise
513
537
  }), location);
514
538
  }
515
- function replaceEntry(router, entryPromise, fromLocation) {
539
+ function replaceEntry(router, entryPromise, fromLocation, ac) {
516
540
  const {
517
541
  history
518
542
  } = router;
519
543
  const {
520
544
  index
521
545
  } = getHistoryState(router);
522
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
546
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
523
547
  const {
524
548
  location
525
549
  } = entry;
@@ -545,7 +569,8 @@ function replaceEntry(router, entryPromise, fromLocation) {
545
569
  });
546
570
  });
547
571
  }
548
- function commitBase(router, entryPromise, location, onResolved) {
572
+ function commitBase(router, entryPromise, location, ac, onResolved) {
573
+ const core = router;
549
574
  const {
550
575
  currentGuard,
551
576
  onLoadingChange = util.noop
@@ -553,8 +578,16 @@ function commitBase(router, entryPromise, location, onResolved) {
553
578
  if (router.resolving) {
554
579
  // Cancel current resolve
555
580
  onLoadingChange();
581
+ // ...and stop its requests: the guard below discards the superseded
582
+ // chain's result, so its in-flight guards/loaders must not keep
583
+ // consuming the network until they settle on their own.
584
+ core.resolvingController?.abort();
556
585
  }
557
586
  router.resolving = location;
587
+ // External commits(plain tasks from resolveTo/preload) carry no
588
+ // controller; clearing the slot keeps a stale one from being aborted
589
+ // by a later supersede.
590
+ core.resolvingController = ac;
558
591
  onLoadingChange('pending');
559
592
  return (
560
593
  // The whole chain — route guards AND the view task — is guarded from
@@ -568,15 +601,19 @@ function commitBase(router, entryPromise, location, onResolved) {
568
601
  entry,
569
602
  resolvedView
570
603
  }) => {
604
+ // This chain settled: it is no longer in flight. Clearing the
605
+ // mark BEFORE onResolved matters because onResolved commits
606
+ // history, which synchronously re-enters cancel() through the
607
+ // router's own listen() handler — an already-settled chain must
608
+ // not be aborted(or fire a cancel signal) as if it were still
609
+ // running. Superseded chains park forever, so the mark is always
610
+ // ours to clear.
611
+ router.resolving = undefined;
571
612
  onResolved(resolvedView, entry);
572
613
  // The navigation consumed this resolution: drop its preload
573
614
  // cache slots so a later preload re-resolves fresh state.
574
615
  evictPreloadCache(router, entry.location);
575
616
  }).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
617
  onLoadingChange('resolved');
581
618
  }).catch(e => {
582
619
  router.resolving = undefined;
@@ -591,7 +628,10 @@ function commitBase(router, entryPromise, location, onResolved) {
591
628
  * the view resolves; the history entry is committed on the terminal
592
629
  * location when guards redirected. The guard phase is part of the
593
630
  * cancelable navigation: a superseding navigate or a `cancel()` while
594
- * guards are still running discards this navigation.
631
+ * guards are still running discards this navigation — and aborts the
632
+ * chain's `signal`, so guards and loaders observing it({@link
633
+ * GuardContext.signal}, {@link ResolveViewContext.signal}) stop their
634
+ * requests instead of only having their results dropped.
595
635
  * @group Methods
596
636
  * @category Router
597
637
  * @param router router instance
@@ -600,19 +640,29 @@ function commitBase(router, entryPromise, location, onResolved) {
600
640
  */
601
641
  function navigate(router, to, state) {
602
642
  const location = toLocation(router, to, state);
603
- return pushEntry(router, resolveEntry(router, location), location);
643
+ // One controller per navigation round: guards and view loaders of the
644
+ // whole chain(including redirect hops) share its signal.
645
+ const ac = new AbortController();
646
+ return pushEntry(router, resolveEntry(router, location, {
647
+ signal: ac.signal
648
+ }), location, ac);
604
649
  }
605
650
 
606
651
  /**
607
652
  * Refresh the page. Route guards run before the view resolves; a redirect
608
- * replaces the current entry with the terminal location.
653
+ * replaces the current entry with the terminal location. The refresh is a
654
+ * cancelable navigation chain like {@link navigate}: superseding it or
655
+ * `cancel()` aborts its signal.
609
656
  * @group Methods
610
657
  * @category Router
611
658
  * @param router router instance
612
659
  */
613
660
  function refresh(router) {
614
661
  const location = getLocation(router);
615
- return replaceEntry(router, resolveEntry(router, location), location);
662
+ const ac = new AbortController();
663
+ return replaceEntry(router, resolveEntry(router, location, {
664
+ signal: ac.signal
665
+ }), location, ac);
616
666
  }
617
667
 
618
668
  /**
@@ -662,12 +712,22 @@ function createHref({
662
712
  }
663
713
 
664
714
  /**
665
- * Cancel the current navigate.
715
+ * Cancel the current navigate. The in-flight chain's guards/loaders are
716
+ * aborted through their signal, not merely discarded.
666
717
  * @group Methods
667
718
  * @category Router
668
719
  * @param router router instance
669
720
  */
670
721
  function cancel(router) {
722
+ const core = router;
723
+ // Aborting is reserved for chains that are still running: a chain that
724
+ // just committed re-enters cancel() synchronously through listen()'s
725
+ // history handler and must not have its(possibly still-rendered) view
726
+ // contexts aborted after the fact.
727
+ if (router.resolving) {
728
+ core.resolvingController?.abort();
729
+ core.resolvingController = undefined;
730
+ }
671
731
  // The cancelled chain parks forever, so nothing else will clear the
672
732
  // in-flight mark: drop it here, or a later navigation would fire a
673
733
  // spurious cancel signal(`onLoadingChange()`) for a dead resolve.
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
  /**
@@ -272,7 +276,10 @@ function resolve(router, location) {
272
276
  } = router;
273
277
  return (matched ? resolveView(matched, {
274
278
  router,
275
- location
279
+ location,
280
+ // One-shot resolves(warm-up, direct calls) are never superseded
281
+ // or cancelled: their loaders get a signal that never aborts.
282
+ signal: new AbortController().signal
276
283
  }) : Promise.reject(new NotFoundError(location.pathname))).catch(errorHandler);
277
284
  }
278
285
 
@@ -299,22 +306,37 @@ function resolveTo(router, to, state) {
299
306
  * levels re-run on every hop(keep side-effectful guards idempotent) —
300
307
  * carrying the original user state; at most
301
308
  * {@link MAX_REDIRECTS 10} redirects are followed before a
302
- * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
309
+ * An unmatched pathname keeps the
303
310
  * {@link resolve resolve} behavior: the task rejects with a
304
311
  * {@link NotFoundError} and is routed through `router.errorHandler`.
305
312
  *
313
+ * `opts.signal` is the abort signal of the whole chain: guards see it in
314
+ * their {@link GuardContext contexts}, the view task's
315
+ * {@link ResolveViewContext context} carries it on, and it is aborted
316
+ * once the navigation is superseded or cancelled. Standalone callers
317
+ * that pass nothing(e.g. {@link preload}) get a signal that never
318
+ * aborts — their resolution may be shared, so cancelling it on behalf of
319
+ * one consumer is not sound yet.
320
+ *
306
321
  * @group Methods
307
322
  * @category Router
308
323
  * @param router router instance
309
324
  * @param location the location to resolve; the object itself is never
310
325
  * mutated — a redirect rebinds the resolution to a new location
326
+ * @param opts options; `signal` is the chain's abort signal
311
327
  * @returns the terminal location and its resolve task
312
328
  */
313
- async function resolveEntry(router, location) {
329
+ async function resolveEntry(router, location, opts) {
314
330
  const {
315
331
  resolveView,
316
332
  errorHandler
317
333
  } = router;
334
+ // The chain owner(navigate/refresh) passes its controller's signal;
335
+ // standalone resolutions get one more controller whose signal never
336
+ // aborts, so downstream consumers always observe a real signal.
337
+ const {
338
+ signal = new AbortController().signal
339
+ } = opts ?? {};
318
340
  for (let redirects = 0;; redirects++) {
319
341
  if (redirects > MAX_REDIRECTS) {
320
342
  throw new RedirectLoopError(location.pathname);
@@ -338,7 +360,8 @@ async function resolveEntry(router, location) {
338
360
  await route.beforeLoad?.({
339
361
  router,
340
362
  location,
341
- params: mergeMatchedParams(matched, i)
363
+ params: mergeMatchedParams(matched, i),
364
+ signal
342
365
  }));
343
366
  if (target) {
344
367
  location = toLocation(router, target, location.state);
@@ -352,7 +375,8 @@ async function resolveEntry(router, location) {
352
375
  location,
353
376
  task: resolveView(matched, {
354
377
  router,
355
- location
378
+ location,
379
+ signal
356
380
  }).catch(errorHandler)
357
381
  };
358
382
  }
@@ -458,12 +482,12 @@ function commit(router, resolvePromise, location) {
458
482
  task: resolvePromise
459
483
  }), location);
460
484
  }
461
- function pushEntry(router, entryPromise, fromLocation) {
485
+ function pushEntry(router, entryPromise, fromLocation, ac) {
462
486
  const {
463
487
  history
464
488
  } = router;
465
489
  const nextIndex = getHistoryState(router).index + 1;
466
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
490
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
467
491
  const {
468
492
  location
469
493
  } = entry;
@@ -510,14 +534,14 @@ function commitReplace(router, resolvePromise, location) {
510
534
  task: resolvePromise
511
535
  }), location);
512
536
  }
513
- function replaceEntry(router, entryPromise, fromLocation) {
537
+ function replaceEntry(router, entryPromise, fromLocation, ac) {
514
538
  const {
515
539
  history
516
540
  } = router;
517
541
  const {
518
542
  index
519
543
  } = getHistoryState(router);
520
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
544
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
521
545
  const {
522
546
  location
523
547
  } = entry;
@@ -543,7 +567,8 @@ function replaceEntry(router, entryPromise, fromLocation) {
543
567
  });
544
568
  });
545
569
  }
546
- function commitBase(router, entryPromise, location, onResolved) {
570
+ function commitBase(router, entryPromise, location, ac, onResolved) {
571
+ const core = router;
547
572
  const {
548
573
  currentGuard,
549
574
  onLoadingChange = noop
@@ -551,8 +576,16 @@ function commitBase(router, entryPromise, location, onResolved) {
551
576
  if (router.resolving) {
552
577
  // Cancel current resolve
553
578
  onLoadingChange();
579
+ // ...and stop its requests: the guard below discards the superseded
580
+ // chain's result, so its in-flight guards/loaders must not keep
581
+ // consuming the network until they settle on their own.
582
+ core.resolvingController?.abort();
554
583
  }
555
584
  router.resolving = location;
585
+ // External commits(plain tasks from resolveTo/preload) carry no
586
+ // controller; clearing the slot keeps a stale one from being aborted
587
+ // by a later supersede.
588
+ core.resolvingController = ac;
556
589
  onLoadingChange('pending');
557
590
  return (
558
591
  // The whole chain — route guards AND the view task — is guarded from
@@ -566,15 +599,19 @@ function commitBase(router, entryPromise, location, onResolved) {
566
599
  entry,
567
600
  resolvedView
568
601
  }) => {
602
+ // This chain settled: it is no longer in flight. Clearing the
603
+ // mark BEFORE onResolved matters because onResolved commits
604
+ // history, which synchronously re-enters cancel() through the
605
+ // router's own listen() handler — an already-settled chain must
606
+ // not be aborted(or fire a cancel signal) as if it were still
607
+ // running. Superseded chains park forever, so the mark is always
608
+ // ours to clear.
609
+ router.resolving = undefined;
569
610
  onResolved(resolvedView, entry);
570
611
  // The navigation consumed this resolution: drop its preload
571
612
  // cache slots so a later preload re-resolves fresh state.
572
613
  evictPreloadCache(router, entry.location);
573
614
  }).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
615
  onLoadingChange('resolved');
579
616
  }).catch(e => {
580
617
  router.resolving = undefined;
@@ -589,7 +626,10 @@ function commitBase(router, entryPromise, location, onResolved) {
589
626
  * the view resolves; the history entry is committed on the terminal
590
627
  * location when guards redirected. The guard phase is part of the
591
628
  * cancelable navigation: a superseding navigate or a `cancel()` while
592
- * guards are still running discards this navigation.
629
+ * guards are still running discards this navigation — and aborts the
630
+ * chain's `signal`, so guards and loaders observing it({@link
631
+ * GuardContext.signal}, {@link ResolveViewContext.signal}) stop their
632
+ * requests instead of only having their results dropped.
593
633
  * @group Methods
594
634
  * @category Router
595
635
  * @param router router instance
@@ -598,19 +638,29 @@ function commitBase(router, entryPromise, location, onResolved) {
598
638
  */
599
639
  function navigate(router, to, state) {
600
640
  const location = toLocation(router, to, state);
601
- return pushEntry(router, resolveEntry(router, location), location);
641
+ // One controller per navigation round: guards and view loaders of the
642
+ // whole chain(including redirect hops) share its signal.
643
+ const ac = new AbortController();
644
+ return pushEntry(router, resolveEntry(router, location, {
645
+ signal: ac.signal
646
+ }), location, ac);
602
647
  }
603
648
 
604
649
  /**
605
650
  * Refresh the page. Route guards run before the view resolves; a redirect
606
- * replaces the current entry with the terminal location.
651
+ * replaces the current entry with the terminal location. The refresh is a
652
+ * cancelable navigation chain like {@link navigate}: superseding it or
653
+ * `cancel()` aborts its signal.
607
654
  * @group Methods
608
655
  * @category Router
609
656
  * @param router router instance
610
657
  */
611
658
  function refresh(router) {
612
659
  const location = getLocation(router);
613
- return replaceEntry(router, resolveEntry(router, location), location);
660
+ const ac = new AbortController();
661
+ return replaceEntry(router, resolveEntry(router, location, {
662
+ signal: ac.signal
663
+ }), location, ac);
614
664
  }
615
665
 
616
666
  /**
@@ -660,12 +710,22 @@ function createHref({
660
710
  }
661
711
 
662
712
  /**
663
- * Cancel the current navigate.
713
+ * Cancel the current navigate. The in-flight chain's guards/loaders are
714
+ * aborted through their signal, not merely discarded.
664
715
  * @group Methods
665
716
  * @category Router
666
717
  * @param router router instance
667
718
  */
668
719
  function cancel(router) {
720
+ const core = router;
721
+ // Aborting is reserved for chains that are still running: a chain that
722
+ // just committed re-enters cancel() synchronously through listen()'s
723
+ // history handler and must not have its(possibly still-rendered) view
724
+ // contexts aborted after the fact.
725
+ if (router.resolving) {
726
+ core.resolvingController?.abort();
727
+ core.resolvingController = undefined;
728
+ }
669
729
  // The cancelled chain parks forever, so nothing else will clear the
670
730
  // in-flight mark: drop it here, or a later navigation would fire a
671
731
  // spurious cancel signal(`onLoadingChange()`) for a dead resolve.
@@ -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
@@ -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.5.0",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/types/index.d.ts",