@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 +1 -1
- package/dist/index.cjs +79 -19
- package/dist/index.mjs +79 -19
- package/dist/types/router.d.ts +22 -5
- package/dist/types/types.d.ts +60 -17
- package/package.json +1 -1
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/types/router.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
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/
|
|
103
|
-
* `{id: string} & {
|
|
130
|
+
* `ExtractPathParams<'/users/:id/files/*rest'>` is
|
|
131
|
+
* `{id: string} & {rest: string[]}`.
|
|
104
132
|
*
|
|
105
|
-
* Within the modeled path-to-regexp
|
|
106
|
-
* {@link PathParamsOf});
|
|
107
|
-
*
|
|
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 {};
|