@native-router/core 1.4.0 → 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
@@ -26,9 +26,10 @@ class RedirectLoopError extends NativeRouterError {
26
26
  */
27
27
  class SearchError extends NativeRouterError {
28
28
  /** The raw search string that failed validation. */
29
+ search;
29
30
 
30
31
  /** The issues reported by the schema. */
31
-
32
+ issues;
32
33
  constructor(search, issues) {
33
34
  super(`Invalid search params "${search}": ${issues.map(({
34
35
  message,
@@ -74,6 +75,10 @@ const DEFAULT_PRELOAD_TTL = 30_000;
74
75
  * `history index - baseIndex`; entries whose slot falls outside the
75
76
  * memory window re-resolve lazily when landed on.
76
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.
77
82
  */
78
83
 
79
84
  /**
@@ -210,7 +215,7 @@ function match(router, pathname) {
210
215
  const route = routes[i];
211
216
  const end = !route.children;
212
217
  const matched = route.path ? pathToRegexp.match(route.path, {
213
- strict: true,
218
+ trailing: false,
214
219
  sensitive: true,
215
220
  decode: typeof decodeURIComponent === 'function' ? decodeURIComponent : undefined,
216
221
  end
@@ -273,7 +278,10 @@ function resolve(router, location) {
273
278
  } = router;
274
279
  return (matched ? resolveView(matched, {
275
280
  router,
276
- 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
277
285
  }) : Promise.reject(new NotFoundError(location.pathname))).catch(errorHandler);
278
286
  }
279
287
 
@@ -300,22 +308,37 @@ function resolveTo(router, to, state) {
300
308
  * levels re-run on every hop(keep side-effectful guards idempotent) —
301
309
  * carrying the original user state; at most
302
310
  * {@link MAX_REDIRECTS 10} redirects are followed before a
303
- * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
311
+ * An unmatched pathname keeps the
304
312
  * {@link resolve resolve} behavior: the task rejects with a
305
313
  * {@link NotFoundError} and is routed through `router.errorHandler`.
306
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
+ *
307
323
  * @group Methods
308
324
  * @category Router
309
325
  * @param router router instance
310
326
  * @param location the location to resolve; the object itself is never
311
327
  * mutated — a redirect rebinds the resolution to a new location
328
+ * @param opts options; `signal` is the chain's abort signal
312
329
  * @returns the terminal location and its resolve task
313
330
  */
314
- async function resolveEntry(router, location) {
331
+ async function resolveEntry(router, location, opts) {
315
332
  const {
316
333
  resolveView,
317
334
  errorHandler
318
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 ?? {};
319
342
  for (let redirects = 0;; redirects++) {
320
343
  if (redirects > MAX_REDIRECTS) {
321
344
  throw new RedirectLoopError(location.pathname);
@@ -339,7 +362,8 @@ async function resolveEntry(router, location) {
339
362
  await route.beforeLoad?.({
340
363
  router,
341
364
  location,
342
- params: mergeMatchedParams(matched, i)
365
+ params: mergeMatchedParams(matched, i),
366
+ signal
343
367
  }));
344
368
  if (target) {
345
369
  location = toLocation(router, target, location.state);
@@ -353,7 +377,8 @@ async function resolveEntry(router, location) {
353
377
  location,
354
378
  task: resolveView(matched, {
355
379
  router,
356
- location
380
+ location,
381
+ signal
357
382
  }).catch(errorHandler)
358
383
  };
359
384
  }
@@ -459,12 +484,12 @@ function commit(router, resolvePromise, location) {
459
484
  task: resolvePromise
460
485
  }), location);
461
486
  }
462
- function pushEntry(router, entryPromise, fromLocation) {
487
+ function pushEntry(router, entryPromise, fromLocation, ac) {
463
488
  const {
464
489
  history
465
490
  } = router;
466
491
  const nextIndex = getHistoryState(router).index + 1;
467
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
492
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
468
493
  const {
469
494
  location
470
495
  } = entry;
@@ -511,14 +536,14 @@ function commitReplace(router, resolvePromise, location) {
511
536
  task: resolvePromise
512
537
  }), location);
513
538
  }
514
- function replaceEntry(router, entryPromise, fromLocation) {
539
+ function replaceEntry(router, entryPromise, fromLocation, ac) {
515
540
  const {
516
541
  history
517
542
  } = router;
518
543
  const {
519
544
  index
520
545
  } = getHistoryState(router);
521
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
546
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
522
547
  const {
523
548
  location
524
549
  } = entry;
@@ -544,7 +569,8 @@ function replaceEntry(router, entryPromise, fromLocation) {
544
569
  });
545
570
  });
546
571
  }
547
- function commitBase(router, entryPromise, location, onResolved) {
572
+ function commitBase(router, entryPromise, location, ac, onResolved) {
573
+ const core = router;
548
574
  const {
549
575
  currentGuard,
550
576
  onLoadingChange = util.noop
@@ -552,8 +578,16 @@ function commitBase(router, entryPromise, location, onResolved) {
552
578
  if (router.resolving) {
553
579
  // Cancel current resolve
554
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();
555
585
  }
556
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;
557
591
  onLoadingChange('pending');
558
592
  return (
559
593
  // The whole chain — route guards AND the view task — is guarded from
@@ -567,15 +601,19 @@ function commitBase(router, entryPromise, location, onResolved) {
567
601
  entry,
568
602
  resolvedView
569
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;
570
612
  onResolved(resolvedView, entry);
571
613
  // The navigation consumed this resolution: drop its preload
572
614
  // cache slots so a later preload re-resolves fresh state.
573
615
  evictPreloadCache(router, entry.location);
574
616
  }).then(() => {
575
- // This chain settled, so it is no longer in flight. Superseded
576
- // chains park forever, so only the latest chain can reach here:
577
- // the mark is always ours to clear.
578
- router.resolving = undefined;
579
617
  onLoadingChange('resolved');
580
618
  }).catch(e => {
581
619
  router.resolving = undefined;
@@ -590,7 +628,10 @@ function commitBase(router, entryPromise, location, onResolved) {
590
628
  * the view resolves; the history entry is committed on the terminal
591
629
  * location when guards redirected. The guard phase is part of the
592
630
  * cancelable navigation: a superseding navigate or a `cancel()` while
593
- * 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.
594
635
  * @group Methods
595
636
  * @category Router
596
637
  * @param router router instance
@@ -599,19 +640,29 @@ function commitBase(router, entryPromise, location, onResolved) {
599
640
  */
600
641
  function navigate(router, to, state) {
601
642
  const location = toLocation(router, to, state);
602
- 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);
603
649
  }
604
650
 
605
651
  /**
606
652
  * Refresh the page. Route guards run before the view resolves; a redirect
607
- * 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.
608
656
  * @group Methods
609
657
  * @category Router
610
658
  * @param router router instance
611
659
  */
612
660
  function refresh(router) {
613
661
  const location = getLocation(router);
614
- 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);
615
666
  }
616
667
 
617
668
  /**
@@ -661,12 +712,22 @@ function createHref({
661
712
  }
662
713
 
663
714
  /**
664
- * 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.
665
717
  * @group Methods
666
718
  * @category Router
667
719
  * @param router router instance
668
720
  */
669
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
+ }
670
731
  // The cancelled chain parks forever, so nothing else will clear the
671
732
  // in-flight mark: drop it here, or a later navigation would fire a
672
733
  // spurious cancel signal(`onLoadingChange()`) for a dead resolve.
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { parsePath, createPath } from 'history';
1
+ import { createPath, parsePath } from 'history';
2
2
  import { match as match$1 } from 'path-to-regexp';
3
- import { reject, noop, createCurrentGuard } from './util.mjs';
3
+ import { createCurrentGuard, reject, noop } from './util.mjs';
4
4
 
5
5
  /* eslint-disable max-classes-per-file */
6
6
 
@@ -24,9 +24,10 @@ class RedirectLoopError extends NativeRouterError {
24
24
  */
25
25
  class SearchError extends NativeRouterError {
26
26
  /** The raw search string that failed validation. */
27
+ search;
27
28
 
28
29
  /** The issues reported by the schema. */
29
-
30
+ issues;
30
31
  constructor(search, issues) {
31
32
  super(`Invalid search params "${search}": ${issues.map(({
32
33
  message,
@@ -72,6 +73,10 @@ const DEFAULT_PRELOAD_TTL = 30_000;
72
73
  * `history index - baseIndex`; entries whose slot falls outside the
73
74
  * memory window re-resolve lazily when landed on.
74
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.
75
80
  */
76
81
 
77
82
  /**
@@ -208,7 +213,7 @@ function match(router, pathname) {
208
213
  const route = routes[i];
209
214
  const end = !route.children;
210
215
  const matched = route.path ? match$1(route.path, {
211
- strict: true,
216
+ trailing: false,
212
217
  sensitive: true,
213
218
  decode: typeof decodeURIComponent === 'function' ? decodeURIComponent : undefined,
214
219
  end
@@ -271,7 +276,10 @@ function resolve(router, location) {
271
276
  } = router;
272
277
  return (matched ? resolveView(matched, {
273
278
  router,
274
- 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
275
283
  }) : Promise.reject(new NotFoundError(location.pathname))).catch(errorHandler);
276
284
  }
277
285
 
@@ -298,22 +306,37 @@ function resolveTo(router, to, state) {
298
306
  * levels re-run on every hop(keep side-effectful guards idempotent) —
299
307
  * carrying the original user state; at most
300
308
  * {@link MAX_REDIRECTS 10} redirects are followed before a
301
- * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
309
+ * An unmatched pathname keeps the
302
310
  * {@link resolve resolve} behavior: the task rejects with a
303
311
  * {@link NotFoundError} and is routed through `router.errorHandler`.
304
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
+ *
305
321
  * @group Methods
306
322
  * @category Router
307
323
  * @param router router instance
308
324
  * @param location the location to resolve; the object itself is never
309
325
  * mutated — a redirect rebinds the resolution to a new location
326
+ * @param opts options; `signal` is the chain's abort signal
310
327
  * @returns the terminal location and its resolve task
311
328
  */
312
- async function resolveEntry(router, location) {
329
+ async function resolveEntry(router, location, opts) {
313
330
  const {
314
331
  resolveView,
315
332
  errorHandler
316
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 ?? {};
317
340
  for (let redirects = 0;; redirects++) {
318
341
  if (redirects > MAX_REDIRECTS) {
319
342
  throw new RedirectLoopError(location.pathname);
@@ -337,7 +360,8 @@ async function resolveEntry(router, location) {
337
360
  await route.beforeLoad?.({
338
361
  router,
339
362
  location,
340
- params: mergeMatchedParams(matched, i)
363
+ params: mergeMatchedParams(matched, i),
364
+ signal
341
365
  }));
342
366
  if (target) {
343
367
  location = toLocation(router, target, location.state);
@@ -351,7 +375,8 @@ async function resolveEntry(router, location) {
351
375
  location,
352
376
  task: resolveView(matched, {
353
377
  router,
354
- location
378
+ location,
379
+ signal
355
380
  }).catch(errorHandler)
356
381
  };
357
382
  }
@@ -457,12 +482,12 @@ function commit(router, resolvePromise, location) {
457
482
  task: resolvePromise
458
483
  }), location);
459
484
  }
460
- function pushEntry(router, entryPromise, fromLocation) {
485
+ function pushEntry(router, entryPromise, fromLocation, ac) {
461
486
  const {
462
487
  history
463
488
  } = router;
464
489
  const nextIndex = getHistoryState(router).index + 1;
465
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
490
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
466
491
  const {
467
492
  location
468
493
  } = entry;
@@ -509,14 +534,14 @@ function commitReplace(router, resolvePromise, location) {
509
534
  task: resolvePromise
510
535
  }), location);
511
536
  }
512
- function replaceEntry(router, entryPromise, fromLocation) {
537
+ function replaceEntry(router, entryPromise, fromLocation, ac) {
513
538
  const {
514
539
  history
515
540
  } = router;
516
541
  const {
517
542
  index
518
543
  } = getHistoryState(router);
519
- return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
544
+ return commitBase(router, entryPromise, fromLocation, ac, (resolvedView, entry) => {
520
545
  const {
521
546
  location
522
547
  } = entry;
@@ -542,7 +567,8 @@ function replaceEntry(router, entryPromise, fromLocation) {
542
567
  });
543
568
  });
544
569
  }
545
- function commitBase(router, entryPromise, location, onResolved) {
570
+ function commitBase(router, entryPromise, location, ac, onResolved) {
571
+ const core = router;
546
572
  const {
547
573
  currentGuard,
548
574
  onLoadingChange = noop
@@ -550,8 +576,16 @@ function commitBase(router, entryPromise, location, onResolved) {
550
576
  if (router.resolving) {
551
577
  // Cancel current resolve
552
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();
553
583
  }
554
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;
555
589
  onLoadingChange('pending');
556
590
  return (
557
591
  // The whole chain — route guards AND the view task — is guarded from
@@ -565,15 +599,19 @@ function commitBase(router, entryPromise, location, onResolved) {
565
599
  entry,
566
600
  resolvedView
567
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;
568
610
  onResolved(resolvedView, entry);
569
611
  // The navigation consumed this resolution: drop its preload
570
612
  // cache slots so a later preload re-resolves fresh state.
571
613
  evictPreloadCache(router, entry.location);
572
614
  }).then(() => {
573
- // This chain settled, so it is no longer in flight. Superseded
574
- // chains park forever, so only the latest chain can reach here:
575
- // the mark is always ours to clear.
576
- router.resolving = undefined;
577
615
  onLoadingChange('resolved');
578
616
  }).catch(e => {
579
617
  router.resolving = undefined;
@@ -588,7 +626,10 @@ function commitBase(router, entryPromise, location, onResolved) {
588
626
  * the view resolves; the history entry is committed on the terminal
589
627
  * location when guards redirected. The guard phase is part of the
590
628
  * cancelable navigation: a superseding navigate or a `cancel()` while
591
- * 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.
592
633
  * @group Methods
593
634
  * @category Router
594
635
  * @param router router instance
@@ -597,19 +638,29 @@ function commitBase(router, entryPromise, location, onResolved) {
597
638
  */
598
639
  function navigate(router, to, state) {
599
640
  const location = toLocation(router, to, state);
600
- 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);
601
647
  }
602
648
 
603
649
  /**
604
650
  * Refresh the page. Route guards run before the view resolves; a redirect
605
- * 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.
606
654
  * @group Methods
607
655
  * @category Router
608
656
  * @param router router instance
609
657
  */
610
658
  function refresh(router) {
611
659
  const location = getLocation(router);
612
- 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);
613
664
  }
614
665
 
615
666
  /**
@@ -659,12 +710,22 @@ function createHref({
659
710
  }
660
711
 
661
712
  /**
662
- * 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.
663
715
  * @group Methods
664
716
  * @category Router
665
717
  * @param router router instance
666
718
  */
667
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
+ }
668
729
  // The cancelled chain parks forever, so nothing else will clear the
669
730
  // in-flight mark: drop it here, or a later navigation would fire a
670
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.0",
3
+ "version": "1.5.0",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/types/index.d.ts",
@@ -52,24 +52,24 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "history": "^5.3.0",
55
- "path-to-regexp": "^6.2.1"
55
+ "path-to-regexp": "^8.4.2"
56
56
  },
57
57
  "devDependencies": {
58
- "@babel/core": "^7.22.9",
59
- "@babel/preset-env": "^7.22.9",
60
- "@babel/preset-typescript": "^7.22.5",
61
- "@rollup/plugin-babel": "^6.0.3",
62
- "@rollup/plugin-commonjs": "^25.0.4",
63
- "@rollup/plugin-node-resolve": "^15.1.0",
64
- "@rollup/plugin-replace": "^5.0.2",
65
- "@types/node": "^20.4.5",
66
- "@types/sinon": "^10.0.15",
58
+ "@babel/core": "^8.0.1",
59
+ "@babel/preset-env": "^8.0.2",
60
+ "@babel/preset-typescript": "^8.0.1",
61
+ "@rollup/plugin-babel": "^7.1.0",
62
+ "@rollup/plugin-commonjs": "^29.0.3",
63
+ "@rollup/plugin-node-resolve": "^16.0.3",
64
+ "@rollup/plugin-replace": "^6.0.3",
65
+ "@types/node": "^26.2.0",
66
+ "@types/sinon": "^22.0.0",
67
67
  "@typescript-eslint/eslint-plugin": "^6.2.0",
68
68
  "@typescript-eslint/parser": "^6.2.0",
69
- "@vitest/coverage-v8": "^4.0.18",
70
- "commitizen": "^4.3.0",
71
- "core-js": "^3.31.1",
72
- "cross-env": "^7.0.3",
69
+ "@vitest/coverage-v8": "^4.1.11",
70
+ "commitizen": "^4.3.2",
71
+ "core-js": "^3.50.0",
72
+ "cross-env": "^10.1.0",
73
73
  "eslint": "^8.50.0",
74
74
  "eslint-config-airbnb": "^19.0.4",
75
75
  "eslint-config-airbnb-typescript": "^17.1.0",
@@ -81,21 +81,21 @@
81
81
  "eslint-plugin-prettier": "^5.0.0",
82
82
  "eslint-plugin-react": "^7.33.0",
83
83
  "eslint-plugin-react-hooks": "^4.6.0",
84
- "gh-pages": "^5.0.0",
85
- "husky": "^8.0.3",
86
- "lint-staged": "^13.2.3",
87
- "prettier": "^3.0.0",
88
- "rollup": "^3.28.0",
84
+ "gh-pages": "^6.3.0",
85
+ "husky": "^9.1.7",
86
+ "lint-staged": "^17.3.0",
87
+ "prettier": "^3.9.6",
88
+ "rollup": "^4.62.5",
89
+ "semantic-release": "^25.0.9",
89
90
  "should": "^13.2.3",
90
91
  "should-sinon": "0.0.6",
91
- "sinon": "^15.2.0",
92
- "terser": "^5.19.2",
93
- "tsc-alias": "^1.8.7",
94
- "typedoc": "^0.24.8",
92
+ "sinon": "^22.1.0",
93
+ "terser": "^5.50.0",
94
+ "tsc-alias": "^1.9.2",
95
+ "typedoc": "^0.28.20",
95
96
  "typedoc-plugin-mark-react-functional-components": "^0.2.2",
96
- "typedoc-plugin-missing-exports": "^2.0.0",
97
- "typescript": "^5.9.3",
98
- "vitest": "^4.0.18",
99
- "semantic-release": "^25.0.3"
97
+ "typedoc-plugin-missing-exports": "^4.1.4",
98
+ "typescript": "~5.9.3",
99
+ "vitest": "^4.1.11"
100
100
  }
101
101
  }