@native-router/core 1.6.0 → 1.8.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 +12 -2
- package/dist/index.cjs +321 -88
- package/dist/index.mjs +322 -90
- package/dist/types/router.d.ts +39 -1
- package/dist/types/types.d.ts +15 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,6 +70,7 @@ commit(router, entry.task, entry.location); // commit like a click
|
|
|
70
70
|
- Route matching via path-to-regexp: declaration order, layout routes without `path`, index/fallback children with `path: ''`, strict trailing slashes, case-sensitive, nested params merged deep over shallow
|
|
71
71
|
- Route guards: static `redirect` and async `beforeLoad` on every route level, run shallow → deep; more than 10 chained redirects reject with `RedirectLoopError`
|
|
72
72
|
- Cancelable async navigation: a new resolve supersedes the in-flight one (`currentGuard`); `cancel()` aborts it; a history POP cancels it too. A superseded or cancelled `navigate()` promise **never settles** — don't `await` a navigation that might be superseded. Superseding or cancelling also aborts the chain's `AbortSignal`: guards (`beforeLoad` ctx) and view loaders (`ResolveViewContext`) receive it as `ctx.signal`, so their in-flight requests stop instead of only having results dropped; `preload` resolutions are shared and therefore never aborted
|
|
73
|
+
- Navigation blockers: `setBlocker(router, fn)` registers a synchronous `(to, from) => boolean` veto over path strings, asked at the head of every `navigate`/`commit`/`commitReplace` and before a history POP lands; a vetoed navigation never starts and its promise resolves immediately (a veto is not an error — unlike a cancelled navigation, whose promise never settles), a vetoed POP is rewound with a counter-`go()` that leaves any in-flight navigation running — the classic unsaved-changes guard. `refresh` and guard redirects are never blocked
|
|
73
74
|
- Navigation API: `navigate`, `refresh`, `go`/`forward`/`back`, `commit`/`commitReplace`, `createHref`, `getParams`, `match`, `toLocation`, `resolve`, `resolveTo`
|
|
74
75
|
- `invalidate(router)`: drop the session view snapshots in one call — the current view stays rendered (no re-resolve, no re-render) and the next back/forward re-resolves through the guards; the typical call site is right after a logout/account switch, so a POP cannot render the previous account's data or bypass guards that already ran
|
|
75
76
|
- 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`
|
|
@@ -107,10 +108,19 @@ const router = create(
|
|
|
107
108
|
);
|
|
108
109
|
```
|
|
109
110
|
|
|
110
|
-
- `parseSearchInput(search)` degrades a query string into a plain object — single-valued keys are strings, keys repeated in the query are arrays — which is also the input every schema validates
|
|
111
|
-
- `parseSearch(schema, search)` resolves the schema output (async validators are awaited); `parseSearchSync` is the render
|
|
111
|
+
- `parseSearchInput(search)` degrades a query string into a plain object — single-valued keys are strings, keys repeated in the query string are arrays — which is also the input every schema validates
|
|
112
|
+
- `parseSearch(schema, search)` resolves the schema output (async validators are awaited); `parseSearchSync` is the render-time flavor and rejects async validators with a clear error
|
|
113
|
+
- Guards: `beforeLoad` receives the level's parsed search as `ctx.search` — the schema output (parsed with `parseSearch`, so async validators work), or the degraded input on schema-less levels; an invalid search fails the resolution through the `errorHandler` channel like a data-phase search error
|
|
112
114
|
- A rejected validation throws `SearchError` (a `NativeRouterError`) carrying the raw `search` and the reported `issues` — route it through your `errorHandler` like any other resolve failure
|
|
113
115
|
|
|
116
|
+
## Design principles
|
|
117
|
+
|
|
118
|
+
**Navigation semantics follow the browser** — native-router aligns with browser-native navigation semantics, not with what other SPA routers happen to do. Every navigation API decision is measured against that yardstick; "a popular router has it" is not, by itself, a reason to follow. These are deliberate choices, not bugs to fix.
|
|
119
|
+
|
|
120
|
+
- **An in-flight navigation keeps the old view.** The chain — guards, loaders — settles as a whole, and only then commits and pushes (`history.push`). The browser does the same: the old document stays displayed until the new one commits. A superseded or cancelled navigation is the browser's stop button / ESC — you stay on the old page and the URL never moved.
|
|
121
|
+
- **Failure means an error view.** A failed resolve renders the error semantics (`errorHandler` in core, `errorComponent` in the react bindings) — the counterpart of the browser's error page. There is no "waited too long → switch to a loading view" path: the browser has no UI-layer load timeout; a timeout surfaces as a network-layer failure, i.e. an error page.
|
|
122
|
+
- **Corollary: no pending-timeout escalation.** No TanStack-style `pendingMs` / in-app `pendingComponent` timeout upgrade. A pending view renders only on cold start / refresh, when there is no old view to keep.
|
|
123
|
+
|
|
114
124
|
## Install
|
|
115
125
|
|
|
116
126
|
```bash
|
package/dist/index.cjs
CHANGED
|
@@ -45,6 +45,84 @@ function formatIssuePath(path) {
|
|
|
45
45
|
return `${keys.join('.')}: `;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
|
|
50
|
+
* input object consumed by {@link StandardSchemaV1 search schemas}:
|
|
51
|
+
* single-valued keys are strings, keys repeated in the query string are
|
|
52
|
+
* arrays of their values. An empty search is `{}`.
|
|
53
|
+
*
|
|
54
|
+
* This is also the degraded shape every search API falls back to when no
|
|
55
|
+
* schema is given.
|
|
56
|
+
* @group Methods
|
|
57
|
+
* @category Route
|
|
58
|
+
* @param search the raw `location.search` string, with or without `?`
|
|
59
|
+
* @returns the input object for schema validation
|
|
60
|
+
*/
|
|
61
|
+
function parseSearchInput(search) {
|
|
62
|
+
const input = {};
|
|
63
|
+
// eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
|
|
64
|
+
new URLSearchParams(search).forEach((value, key) => {
|
|
65
|
+
const prev = input[key];
|
|
66
|
+
if (prev === undefined) {
|
|
67
|
+
input[key] = value;
|
|
68
|
+
} else if (Array.isArray(prev)) {
|
|
69
|
+
prev.push(value);
|
|
70
|
+
} else {
|
|
71
|
+
input[key] = [prev, value];
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return input;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate a search string with a {@link StandardSchemaV1} schema — any
|
|
79
|
+
* zod/valibot/arktype schema works, no hard dependency. The string is
|
|
80
|
+
* first degraded via {@link parseSearchInput}, then parsed by the schema,
|
|
81
|
+
* so schemas can coerce(`'2'` → `2`) and normalize along the way.
|
|
82
|
+
*
|
|
83
|
+
* Async schemas(`validate` returning a promise) are awaited.
|
|
84
|
+
*
|
|
85
|
+
* @group Methods
|
|
86
|
+
* @category Route
|
|
87
|
+
* @param schema the search schema
|
|
88
|
+
* @param search the raw `location.search` string
|
|
89
|
+
* @returns the parsed(and possibly coerced) output of the schema
|
|
90
|
+
* @throws {SearchError} when the schema reports issues
|
|
91
|
+
*/
|
|
92
|
+
async function parseSearch(schema, search) {
|
|
93
|
+
const result = await schema['~standard'].validate(parseSearchInput(search));
|
|
94
|
+
if (result.issues) throw new SearchError(search, result.issues);
|
|
95
|
+
// The schema's declared output; the loose `StandardSchemaV1` default
|
|
96
|
+
// degrades to `unknown`.
|
|
97
|
+
return result.value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Synchronous flavor of {@link parseSearch}, for render-time reads(see
|
|
102
|
+
* `useSearch` of `@native-router/react`) and route guards.
|
|
103
|
+
*
|
|
104
|
+
* @group Methods
|
|
105
|
+
* @category Route
|
|
106
|
+
* @param schema the search schema — must validate synchronously
|
|
107
|
+
* @param search the raw `location.search` string
|
|
108
|
+
* @returns the parsed(and possibly coerced) output of the schema
|
|
109
|
+
* @throws {SearchError} when the schema reports issues
|
|
110
|
+
* @throws when the schema validates asynchronously; use {@link parseSearch}
|
|
111
|
+
* for async schemas instead
|
|
112
|
+
*/
|
|
113
|
+
function parseSearchSync(schema, search) {
|
|
114
|
+
const result = schema['~standard'].validate(parseSearchInput(search));
|
|
115
|
+
if (isThenable(result)) {
|
|
116
|
+
throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
|
|
117
|
+
}
|
|
118
|
+
if (result.issues) throw new SearchError(search, result.issues);
|
|
119
|
+
// See parseSearch for the cast rationale.
|
|
120
|
+
return result.value;
|
|
121
|
+
}
|
|
122
|
+
function isThenable(value) {
|
|
123
|
+
return typeof value?.then === 'function';
|
|
124
|
+
}
|
|
125
|
+
|
|
48
126
|
const DEFAULT_MAX_STACK_DEPTH = 100;
|
|
49
127
|
|
|
50
128
|
/** Max redirects followed by {@link resolveEntry} before giving up. */
|
|
@@ -327,6 +405,11 @@ function resolveTo(router, to, state) {
|
|
|
327
405
|
* aborts — their resolution may be shared, so cancelling it on behalf of
|
|
328
406
|
* one consumer is not sound yet.
|
|
329
407
|
*
|
|
408
|
+
* A guard's context also carries the level's parsed
|
|
409
|
+
* {@link GuardContext.search search}: the {@link BaseRoute.search schema}
|
|
410
|
+
* output(its validation failure rejects this resolution with a
|
|
411
|
+
* `SearchError`), or the degraded input without a schema.
|
|
412
|
+
*
|
|
330
413
|
* @group Methods
|
|
331
414
|
* @category Router
|
|
332
415
|
* @param router router instance
|
|
@@ -364,14 +447,37 @@ async function resolveEntry(router, location, opts) {
|
|
|
364
447
|
} = matched[i];
|
|
365
448
|
// `redirect` wins over `beforeLoad`; a non-empty string target
|
|
366
449
|
// restarts the resolution at the redirected location.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
450
|
+
let target = route.redirect;
|
|
451
|
+
if (!target && route.beforeLoad) {
|
|
452
|
+
// The level's search schema runs before its guard, so the guard
|
|
453
|
+
// sees the parsed output(degraded input without a schema). A
|
|
454
|
+
// validation failure fails the resolution through the task's
|
|
455
|
+
// errorHandler channel — the same route a data-phase search
|
|
456
|
+
// error takes — instead of rejecting this entry, which preload
|
|
457
|
+
// consumers share.
|
|
458
|
+
let search;
|
|
459
|
+
if (route.search) {
|
|
460
|
+
try {
|
|
461
|
+
// eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
|
|
462
|
+
search = await parseSearch(route.search, location.search);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
return {
|
|
465
|
+
location,
|
|
466
|
+
task: Promise.reject(e).catch(errorHandler)
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
search = parseSearchInput(location.search);
|
|
471
|
+
}
|
|
472
|
+
// eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
|
|
473
|
+
target = await route.beforeLoad({
|
|
474
|
+
router,
|
|
475
|
+
location,
|
|
476
|
+
params: mergeMatchedParams(matched, i),
|
|
477
|
+
signal,
|
|
478
|
+
search
|
|
479
|
+
});
|
|
480
|
+
}
|
|
375
481
|
if (target) {
|
|
376
482
|
location = toLocation(router, target, location.state);
|
|
377
483
|
redirected = true;
|
|
@@ -484,6 +590,15 @@ function preloadCacheOf(router) {
|
|
|
484
590
|
* @param location the location to resolved
|
|
485
591
|
*/
|
|
486
592
|
function commit(router, resolvePromise, location) {
|
|
593
|
+
// Blockers sit at the chain head: a vetoed external commit is dropped
|
|
594
|
+
// before the given task is ever awaited. The dropped task still gets
|
|
595
|
+
// a rejection handler — an orphaned failure(preload tasks re-throw
|
|
596
|
+
// NotFoundError through the default errorHandler) would otherwise
|
|
597
|
+
// surface as an unhandled rejection.
|
|
598
|
+
if (blockedBy(router, history.createPath(location))) {
|
|
599
|
+
resolvePromise.catch(util.noop);
|
|
600
|
+
return Promise.resolve();
|
|
601
|
+
}
|
|
487
602
|
// Wrap the raw task so external callers share the guarded entry
|
|
488
603
|
// pipeline; the entry location is the given one, as-is.
|
|
489
604
|
return pushEntry(router, Promise.resolve({
|
|
@@ -538,6 +653,12 @@ function pushEntry(router, entryPromise, fromLocation, ac) {
|
|
|
538
653
|
* @param location the location to resolved
|
|
539
654
|
*/
|
|
540
655
|
function commitReplace(router, resolvePromise, location) {
|
|
656
|
+
// Same chain-head veto as commit: a blocked replace never starts, and
|
|
657
|
+
// the dropped task's failure is swallowed the same way.
|
|
658
|
+
if (blockedBy(router, history.createPath(location))) {
|
|
659
|
+
resolvePromise.catch(util.noop);
|
|
660
|
+
return Promise.resolve();
|
|
661
|
+
}
|
|
541
662
|
return replaceEntry(router, Promise.resolve({
|
|
542
663
|
location,
|
|
543
664
|
task: resolvePromise
|
|
@@ -633,7 +754,9 @@ function commitBase(router, entryPromise, location, ac, onResolved) {
|
|
|
633
754
|
/**
|
|
634
755
|
* Navigate to a new path. Route guards(`redirect`/`beforeLoad`) run before
|
|
635
756
|
* the view resolves; the history entry is committed on the terminal
|
|
636
|
-
* location when guards redirected.
|
|
757
|
+
* location when guards redirected. A registered blocker(see {@link
|
|
758
|
+
* setBlocker}) may veto the navigation before anything starts. The guard
|
|
759
|
+
* phase is part of the
|
|
637
760
|
* cancelable navigation: a superseding navigate or a `cancel()` while
|
|
638
761
|
* guards are still running discards this navigation — and aborts the
|
|
639
762
|
* chain's `signal`, so guards and loaders observing it({@link
|
|
@@ -647,6 +770,14 @@ function commitBase(router, entryPromise, location, ac, onResolved) {
|
|
|
647
770
|
*/
|
|
648
771
|
function navigate(router, to, state) {
|
|
649
772
|
const location = toLocation(router, to, state);
|
|
773
|
+
// Blockers sit at the chain head, before the controller exists: a
|
|
774
|
+
// vetoed navigation never resolves a single guard, and its promise
|
|
775
|
+
// resolves immediately — a veto is not an error, and unlike a
|
|
776
|
+
// cancelled navigation(whose promise never settles) it does settle —
|
|
777
|
+
// so the ubiquitous `void navigate(...)` call sites stay untouched.
|
|
778
|
+
// The target is asked in its committed path form(`createPath`), the
|
|
779
|
+
// same string a POP blocker sees, baseUrl included.
|
|
780
|
+
if (blockedBy(router, history.createPath(location))) return Promise.resolve();
|
|
650
781
|
// One controller per navigation round: guards and view loaders of the
|
|
651
782
|
// whole chain(including redirect hops) share its signal.
|
|
652
783
|
const ac = new AbortController();
|
|
@@ -785,6 +916,149 @@ function invalidate(router) {
|
|
|
785
916
|
router.viewStack = new Array(router.locationStack.length).fill(null);
|
|
786
917
|
}
|
|
787
918
|
|
|
919
|
+
/**
|
|
920
|
+
* Navigation blocker predicate: `to` and `from` are path strings
|
|
921
|
+
* (pathname, search and hash included, built with `createPath`). Return
|
|
922
|
+
* `false` to veto the navigation. A blocker that throws counts as a
|
|
923
|
+
* veto too — a crashed gate must not open, and the exception must not
|
|
924
|
+
* escape into a history listener.
|
|
925
|
+
* @group Methods
|
|
926
|
+
* @category Router
|
|
927
|
+
*/
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Registered blockers per router, in registration order. Module-level
|
|
931
|
+
* so the public {@link RouterInstance} type stays untouched; the router
|
|
932
|
+
* key is weakly held, an empty leftover set after the last release
|
|
933
|
+
* leaks nothing.
|
|
934
|
+
*/
|
|
935
|
+
const blockerRegistry = new WeakMap();
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Last settled history position per router, kept in sync by {@link listen}.
|
|
939
|
+
* POP blockers read it as the `from` path and the rewind base: by the
|
|
940
|
+
* time a POP listener runs, `history.location` is already the landed
|
|
941
|
+
* location, so the pre-POP position must be tracked separately.
|
|
942
|
+
*/
|
|
943
|
+
const lastSettled = new WeakMap();
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Pending blocker rewind per router: a rewind `go()` is in flight. The
|
|
947
|
+
* rewind's own POP must not query the blockers again — they would veto
|
|
948
|
+
* it too and ping-pong the history forever.
|
|
949
|
+
*/
|
|
950
|
+
const pendingRewind = new WeakMap();
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Register a navigation blocker. Every {@link navigate}, {@link commit},
|
|
954
|
+
* {@link commitReplace} and every history POP(see {@link listen}) asks
|
|
955
|
+
* the registered blockers(in registration order, first veto wins)
|
|
956
|
+
* before anything else; a vetoed navigation never starts — no guards,
|
|
957
|
+
* no loaders, no history change — and its promise resolves immediately
|
|
958
|
+
* (a veto is not an error; unlike a cancelled navigation, whose promise
|
|
959
|
+
* never settles, a vetoed one does). `refresh` and guard redirects
|
|
960
|
+
* are never blocked: a refresh re-resolves the current location, and a
|
|
961
|
+
* redirect is the guard chain's own target correction, already asked
|
|
962
|
+
* once at the chain head. A vetoed POP is rewound with a
|
|
963
|
+
* counter-`go()`; its landing re-announces the current view without
|
|
964
|
+
* cancelling an in-flight chain.
|
|
965
|
+
* @group Methods
|
|
966
|
+
* @category Router
|
|
967
|
+
* @param router router instance
|
|
968
|
+
* @param fn blocker predicate; `to` is the target path, `from` the
|
|
969
|
+
* current path, both path strings
|
|
970
|
+
* @returns unblock - remove the blocker(idempotent)
|
|
971
|
+
*/
|
|
972
|
+
function setBlocker(router, fn) {
|
|
973
|
+
let set = blockerRegistry.get(router);
|
|
974
|
+
if (!set) {
|
|
975
|
+
set = new Set();
|
|
976
|
+
blockerRegistry.set(router, set);
|
|
977
|
+
}
|
|
978
|
+
set.add(fn);
|
|
979
|
+
let released = false;
|
|
980
|
+
return () => {
|
|
981
|
+
if (released) return;
|
|
982
|
+
released = true;
|
|
983
|
+
set.delete(fn);
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Ask every registered blocker, in registration order. The first veto
|
|
989
|
+
* wins — `some` stops asking at it — and a blocker that throws counts
|
|
990
|
+
* as a veto: a crashed gate must not open, and the exception must not
|
|
991
|
+
* escape into a history listener.
|
|
992
|
+
*/
|
|
993
|
+
function vetoedBy(set, to, from) {
|
|
994
|
+
return Array.from(set).some(block => {
|
|
995
|
+
try {
|
|
996
|
+
return !block(to, from);
|
|
997
|
+
} catch {
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Ask the blockers about a router-driven navigation. Runs before
|
|
1005
|
+
* anything else, so `history.location` is still the pre-navigation
|
|
1006
|
+
* `from`. Returns `true` when any blocker vetoed.
|
|
1007
|
+
*/
|
|
1008
|
+
function blockedBy(router, to) {
|
|
1009
|
+
const set = blockerRegistry.get(router);
|
|
1010
|
+
if (!set) return false;
|
|
1011
|
+
return vetoedBy(set, to, history.createPath(router.history.location));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Ask the blockers about a history POP; rewind it when vetoed.
|
|
1016
|
+
* Returns the POP's disposition for {@link listen}:
|
|
1017
|
+
* - `'vetoed'`: a blocker vetoed. The caller drops the event wholesale
|
|
1018
|
+
* — no `onViewChange`, no window sync, and no `cancel()` either, so
|
|
1019
|
+
* an in-flight chain keeps running as if the POP never happened.
|
|
1020
|
+
* - `'rewind'`: this POP is the landing of an earlier veto's rewind,
|
|
1021
|
+
* back on the entry the router never left. The router state did not
|
|
1022
|
+
* change, so the caller re-announces the current view without
|
|
1023
|
+
* cancelling the in-flight chain or re-syncing the window state.
|
|
1024
|
+
* - `false`: not blocked; the caller handles the POP normally.
|
|
1025
|
+
*/
|
|
1026
|
+
function blockedPop(router, location, index) {
|
|
1027
|
+
const {
|
|
1028
|
+
history: history$1
|
|
1029
|
+
} = router;
|
|
1030
|
+
// A pending rewind's own landing: swallow it without a second query
|
|
1031
|
+
// (a blocker that vetoes leaving a page would veto the rewind too)
|
|
1032
|
+
// and report it for the no-cancel re-announce branch in the caller.
|
|
1033
|
+
// Deliberately not index-matched: a user POP racing the pending
|
|
1034
|
+
// rewind must never re-enter the blockers either.
|
|
1035
|
+
if (pendingRewind.delete(router)) return 'rewind';
|
|
1036
|
+
const set = blockerRegistry.get(router);
|
|
1037
|
+
if (!set) return false;
|
|
1038
|
+
// Without a settled baseline(unreachable while this listener exists:
|
|
1039
|
+
// listen() seeds the tracker before registering) there is neither a
|
|
1040
|
+
// `from` nor a rewind delta to work with — let the POP land rather
|
|
1041
|
+
// than veto blind.
|
|
1042
|
+
const settled = lastSettled.get(router);
|
|
1043
|
+
if (!settled) return false;
|
|
1044
|
+
const to = history.createPath(location);
|
|
1045
|
+
const from = history.createPath(settled.location);
|
|
1046
|
+
if (!vetoedBy(set, to, from)) return false;
|
|
1047
|
+
// Rewind by the distance the POP travelled. Router-driven pushes keep
|
|
1048
|
+
// the state index and the history index in lockstep, so the delta
|
|
1049
|
+
// between the landed and settled state indexes doubles as the history
|
|
1050
|
+
// delta. A zero delta(same-index POP between stateless external
|
|
1051
|
+
// entries) cannot be rewound — `go(0)` goes nowhere — so the URL
|
|
1052
|
+
// stays on the vetoed target while the router stacks and the
|
|
1053
|
+
// rendered view keep the current entry.
|
|
1054
|
+
const delta = index - settled.index;
|
|
1055
|
+
if (delta) {
|
|
1056
|
+
pendingRewind.set(router, true);
|
|
1057
|
+
history$1.go(-delta);
|
|
1058
|
+
}
|
|
1059
|
+
return 'vetoed';
|
|
1060
|
+
}
|
|
1061
|
+
|
|
788
1062
|
/**
|
|
789
1063
|
* Listen the history change.
|
|
790
1064
|
* @group Methods
|
|
@@ -797,13 +1071,45 @@ function listen(router, onViewChange) {
|
|
|
797
1071
|
const {
|
|
798
1072
|
history: history$1
|
|
799
1073
|
} = router;
|
|
1074
|
+
|
|
1075
|
+
// Seed the settled-position tracker so a POP arriving before any other
|
|
1076
|
+
// history change still reads a correct `from` and rewind delta.
|
|
1077
|
+
lastSettled.set(router, {
|
|
1078
|
+
index: getHistoryState(router).index,
|
|
1079
|
+
location: history$1.location
|
|
1080
|
+
});
|
|
800
1081
|
const rmListener = history$1.listen(({
|
|
801
1082
|
action,
|
|
802
1083
|
location
|
|
803
1084
|
}) => {
|
|
804
|
-
cancel(router);
|
|
805
1085
|
const state = location.state;
|
|
806
1086
|
const index = state?.index || 0;
|
|
1087
|
+
if (action === 'POP') {
|
|
1088
|
+
const blocked = blockedPop(router, location, index);
|
|
1089
|
+
// A blocker veto runs before anything else: the POP is rewound
|
|
1090
|
+
// with a counter-`go()`, so neither the view nor the in-flight
|
|
1091
|
+
// chain may observe it.
|
|
1092
|
+
if (blocked === 'vetoed') return;
|
|
1093
|
+
if (blocked === 'rewind') {
|
|
1094
|
+
// The rewind's landing: an entry the router never left. The
|
|
1095
|
+
// stacks and the landed entry's window state are already
|
|
1096
|
+
// correct — no sync replace — and no `cancel()` either: an
|
|
1097
|
+
// in-flight chain must survive the bounced POP. Re-announce
|
|
1098
|
+
// the current view only when a snapshot exists; an
|
|
1099
|
+
// invalidate()d slot emits nothing and the host keeps its
|
|
1100
|
+
// retained view, exactly like invalidate() itself — a lazy
|
|
1101
|
+
// refresh here would supersede the very chain this branch
|
|
1102
|
+
// protects.
|
|
1103
|
+
const view = viewAt(router, index);
|
|
1104
|
+
if (view) onViewChange(view);
|
|
1105
|
+
lastSettled.set(router, {
|
|
1106
|
+
index,
|
|
1107
|
+
location
|
|
1108
|
+
});
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
cancel(router);
|
|
807
1113
|
const view = viewAt(router, index);
|
|
808
1114
|
onViewChange(view);
|
|
809
1115
|
if (!view) {
|
|
@@ -822,6 +1128,10 @@ function listen(router, onViewChange) {
|
|
|
822
1128
|
...serializeStack(router)
|
|
823
1129
|
});
|
|
824
1130
|
}
|
|
1131
|
+
lastSettled.set(router, {
|
|
1132
|
+
index,
|
|
1133
|
+
location
|
|
1134
|
+
});
|
|
825
1135
|
});
|
|
826
1136
|
history$1.replace(history.createPath(history$1.location), history$1.location.state);
|
|
827
1137
|
return () => {
|
|
@@ -872,84 +1182,6 @@ function getParams(router) {
|
|
|
872
1182
|
return mergeMatchedParams(match(router, location.pathname) ?? []);
|
|
873
1183
|
}
|
|
874
1184
|
|
|
875
|
-
/**
|
|
876
|
-
* Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
|
|
877
|
-
* input object consumed by {@link StandardSchemaV1 search schemas}:
|
|
878
|
-
* single-valued keys are strings, keys repeated in the query string are
|
|
879
|
-
* arrays of their values. An empty search is `{}`.
|
|
880
|
-
*
|
|
881
|
-
* This is also the degraded shape every search API falls back to when no
|
|
882
|
-
* schema is given.
|
|
883
|
-
* @group Methods
|
|
884
|
-
* @category Route
|
|
885
|
-
* @param search the raw `location.search` string, with or without `?`
|
|
886
|
-
* @returns the input object for schema validation
|
|
887
|
-
*/
|
|
888
|
-
function parseSearchInput(search) {
|
|
889
|
-
const input = {};
|
|
890
|
-
// eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
|
|
891
|
-
new URLSearchParams(search).forEach((value, key) => {
|
|
892
|
-
const prev = input[key];
|
|
893
|
-
if (prev === undefined) {
|
|
894
|
-
input[key] = value;
|
|
895
|
-
} else if (Array.isArray(prev)) {
|
|
896
|
-
prev.push(value);
|
|
897
|
-
} else {
|
|
898
|
-
input[key] = [prev, value];
|
|
899
|
-
}
|
|
900
|
-
});
|
|
901
|
-
return input;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* Validate a search string with a {@link StandardSchemaV1} schema — any
|
|
906
|
-
* zod/valibot/arktype schema works, no hard dependency. The string is
|
|
907
|
-
* first degraded via {@link parseSearchInput}, then parsed by the schema,
|
|
908
|
-
* so schemas can coerce(`'2'` → `2`) and normalize along the way.
|
|
909
|
-
*
|
|
910
|
-
* Async schemas(`validate` returning a promise) are awaited.
|
|
911
|
-
*
|
|
912
|
-
* @group Methods
|
|
913
|
-
* @category Route
|
|
914
|
-
* @param schema the search schema
|
|
915
|
-
* @param search the raw `location.search` string
|
|
916
|
-
* @returns the parsed(and possibly coerced) output of the schema
|
|
917
|
-
* @throws {SearchError} when the schema reports issues
|
|
918
|
-
*/
|
|
919
|
-
async function parseSearch(schema, search) {
|
|
920
|
-
const result = await schema['~standard'].validate(parseSearchInput(search));
|
|
921
|
-
if (result.issues) throw new SearchError(search, result.issues);
|
|
922
|
-
// The schema's declared output; the loose `StandardSchemaV1` default
|
|
923
|
-
// degrades to `unknown`.
|
|
924
|
-
return result.value;
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
/**
|
|
928
|
-
* Synchronous flavor of {@link parseSearch}, for render-time reads(see
|
|
929
|
-
* `useSearch` of `@native-router/react`) and route guards.
|
|
930
|
-
*
|
|
931
|
-
* @group Methods
|
|
932
|
-
* @category Route
|
|
933
|
-
* @param schema the search schema — must validate synchronously
|
|
934
|
-
* @param search the raw `location.search` string
|
|
935
|
-
* @returns the parsed(and possibly coerced) output of the schema
|
|
936
|
-
* @throws {SearchError} when the schema reports issues
|
|
937
|
-
* @throws when the schema validates asynchronously; use {@link parseSearch}
|
|
938
|
-
* for async schemas instead
|
|
939
|
-
*/
|
|
940
|
-
function parseSearchSync(schema, search) {
|
|
941
|
-
const result = schema['~standard'].validate(parseSearchInput(search));
|
|
942
|
-
if (isThenable(result)) {
|
|
943
|
-
throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
|
|
944
|
-
}
|
|
945
|
-
if (result.issues) throw new SearchError(search, result.issues);
|
|
946
|
-
// See parseSearch for the cast rationale.
|
|
947
|
-
return result.value;
|
|
948
|
-
}
|
|
949
|
-
function isThenable(value) {
|
|
950
|
-
return typeof value?.then === 'function';
|
|
951
|
-
}
|
|
952
|
-
|
|
953
1185
|
exports.NativeRouterError = NativeRouterError;
|
|
954
1186
|
exports.NotFoundError = NotFoundError;
|
|
955
1187
|
exports.RedirectLoopError = RedirectLoopError;
|
|
@@ -979,5 +1211,6 @@ exports.refresh = refresh;
|
|
|
979
1211
|
exports.resolve = resolve;
|
|
980
1212
|
exports.resolveEntry = resolveEntry;
|
|
981
1213
|
exports.resolveTo = resolveTo;
|
|
1214
|
+
exports.setBlocker = setBlocker;
|
|
982
1215
|
exports.setOptions = setOptions;
|
|
983
1216
|
exports.toLocation = toLocation;
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createPath, parsePath } from 'history';
|
|
2
2
|
import { match as match$1 } from 'path-to-regexp';
|
|
3
|
-
import { createCurrentGuard, reject
|
|
3
|
+
import { noop, createCurrentGuard, reject } from './util.mjs';
|
|
4
4
|
|
|
5
5
|
/* eslint-disable max-classes-per-file */
|
|
6
6
|
|
|
@@ -43,6 +43,84 @@ function formatIssuePath(path) {
|
|
|
43
43
|
return `${keys.join('.')}: `;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
|
|
48
|
+
* input object consumed by {@link StandardSchemaV1 search schemas}:
|
|
49
|
+
* single-valued keys are strings, keys repeated in the query string are
|
|
50
|
+
* arrays of their values. An empty search is `{}`.
|
|
51
|
+
*
|
|
52
|
+
* This is also the degraded shape every search API falls back to when no
|
|
53
|
+
* schema is given.
|
|
54
|
+
* @group Methods
|
|
55
|
+
* @category Route
|
|
56
|
+
* @param search the raw `location.search` string, with or without `?`
|
|
57
|
+
* @returns the input object for schema validation
|
|
58
|
+
*/
|
|
59
|
+
function parseSearchInput(search) {
|
|
60
|
+
const input = {};
|
|
61
|
+
// eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
|
|
62
|
+
new URLSearchParams(search).forEach((value, key) => {
|
|
63
|
+
const prev = input[key];
|
|
64
|
+
if (prev === undefined) {
|
|
65
|
+
input[key] = value;
|
|
66
|
+
} else if (Array.isArray(prev)) {
|
|
67
|
+
prev.push(value);
|
|
68
|
+
} else {
|
|
69
|
+
input[key] = [prev, value];
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return input;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate a search string with a {@link StandardSchemaV1} schema — any
|
|
77
|
+
* zod/valibot/arktype schema works, no hard dependency. The string is
|
|
78
|
+
* first degraded via {@link parseSearchInput}, then parsed by the schema,
|
|
79
|
+
* so schemas can coerce(`'2'` → `2`) and normalize along the way.
|
|
80
|
+
*
|
|
81
|
+
* Async schemas(`validate` returning a promise) are awaited.
|
|
82
|
+
*
|
|
83
|
+
* @group Methods
|
|
84
|
+
* @category Route
|
|
85
|
+
* @param schema the search schema
|
|
86
|
+
* @param search the raw `location.search` string
|
|
87
|
+
* @returns the parsed(and possibly coerced) output of the schema
|
|
88
|
+
* @throws {SearchError} when the schema reports issues
|
|
89
|
+
*/
|
|
90
|
+
async function parseSearch(schema, search) {
|
|
91
|
+
const result = await schema['~standard'].validate(parseSearchInput(search));
|
|
92
|
+
if (result.issues) throw new SearchError(search, result.issues);
|
|
93
|
+
// The schema's declared output; the loose `StandardSchemaV1` default
|
|
94
|
+
// degrades to `unknown`.
|
|
95
|
+
return result.value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Synchronous flavor of {@link parseSearch}, for render-time reads(see
|
|
100
|
+
* `useSearch` of `@native-router/react`) and route guards.
|
|
101
|
+
*
|
|
102
|
+
* @group Methods
|
|
103
|
+
* @category Route
|
|
104
|
+
* @param schema the search schema — must validate synchronously
|
|
105
|
+
* @param search the raw `location.search` string
|
|
106
|
+
* @returns the parsed(and possibly coerced) output of the schema
|
|
107
|
+
* @throws {SearchError} when the schema reports issues
|
|
108
|
+
* @throws when the schema validates asynchronously; use {@link parseSearch}
|
|
109
|
+
* for async schemas instead
|
|
110
|
+
*/
|
|
111
|
+
function parseSearchSync(schema, search) {
|
|
112
|
+
const result = schema['~standard'].validate(parseSearchInput(search));
|
|
113
|
+
if (isThenable(result)) {
|
|
114
|
+
throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
|
|
115
|
+
}
|
|
116
|
+
if (result.issues) throw new SearchError(search, result.issues);
|
|
117
|
+
// See parseSearch for the cast rationale.
|
|
118
|
+
return result.value;
|
|
119
|
+
}
|
|
120
|
+
function isThenable(value) {
|
|
121
|
+
return typeof value?.then === 'function';
|
|
122
|
+
}
|
|
123
|
+
|
|
46
124
|
const DEFAULT_MAX_STACK_DEPTH = 100;
|
|
47
125
|
|
|
48
126
|
/** Max redirects followed by {@link resolveEntry} before giving up. */
|
|
@@ -325,6 +403,11 @@ function resolveTo(router, to, state) {
|
|
|
325
403
|
* aborts — their resolution may be shared, so cancelling it on behalf of
|
|
326
404
|
* one consumer is not sound yet.
|
|
327
405
|
*
|
|
406
|
+
* A guard's context also carries the level's parsed
|
|
407
|
+
* {@link GuardContext.search search}: the {@link BaseRoute.search schema}
|
|
408
|
+
* output(its validation failure rejects this resolution with a
|
|
409
|
+
* `SearchError`), or the degraded input without a schema.
|
|
410
|
+
*
|
|
328
411
|
* @group Methods
|
|
329
412
|
* @category Router
|
|
330
413
|
* @param router router instance
|
|
@@ -362,14 +445,37 @@ async function resolveEntry(router, location, opts) {
|
|
|
362
445
|
} = matched[i];
|
|
363
446
|
// `redirect` wins over `beforeLoad`; a non-empty string target
|
|
364
447
|
// restarts the resolution at the redirected location.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
448
|
+
let target = route.redirect;
|
|
449
|
+
if (!target && route.beforeLoad) {
|
|
450
|
+
// The level's search schema runs before its guard, so the guard
|
|
451
|
+
// sees the parsed output(degraded input without a schema). A
|
|
452
|
+
// validation failure fails the resolution through the task's
|
|
453
|
+
// errorHandler channel — the same route a data-phase search
|
|
454
|
+
// error takes — instead of rejecting this entry, which preload
|
|
455
|
+
// consumers share.
|
|
456
|
+
let search;
|
|
457
|
+
if (route.search) {
|
|
458
|
+
try {
|
|
459
|
+
// eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
|
|
460
|
+
search = await parseSearch(route.search, location.search);
|
|
461
|
+
} catch (e) {
|
|
462
|
+
return {
|
|
463
|
+
location,
|
|
464
|
+
task: Promise.reject(e).catch(errorHandler)
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
search = parseSearchInput(location.search);
|
|
469
|
+
}
|
|
470
|
+
// eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
|
|
471
|
+
target = await route.beforeLoad({
|
|
472
|
+
router,
|
|
473
|
+
location,
|
|
474
|
+
params: mergeMatchedParams(matched, i),
|
|
475
|
+
signal,
|
|
476
|
+
search
|
|
477
|
+
});
|
|
478
|
+
}
|
|
373
479
|
if (target) {
|
|
374
480
|
location = toLocation(router, target, location.state);
|
|
375
481
|
redirected = true;
|
|
@@ -482,6 +588,15 @@ function preloadCacheOf(router) {
|
|
|
482
588
|
* @param location the location to resolved
|
|
483
589
|
*/
|
|
484
590
|
function commit(router, resolvePromise, location) {
|
|
591
|
+
// Blockers sit at the chain head: a vetoed external commit is dropped
|
|
592
|
+
// before the given task is ever awaited. The dropped task still gets
|
|
593
|
+
// a rejection handler — an orphaned failure(preload tasks re-throw
|
|
594
|
+
// NotFoundError through the default errorHandler) would otherwise
|
|
595
|
+
// surface as an unhandled rejection.
|
|
596
|
+
if (blockedBy(router, createPath(location))) {
|
|
597
|
+
resolvePromise.catch(noop);
|
|
598
|
+
return Promise.resolve();
|
|
599
|
+
}
|
|
485
600
|
// Wrap the raw task so external callers share the guarded entry
|
|
486
601
|
// pipeline; the entry location is the given one, as-is.
|
|
487
602
|
return pushEntry(router, Promise.resolve({
|
|
@@ -536,6 +651,12 @@ function pushEntry(router, entryPromise, fromLocation, ac) {
|
|
|
536
651
|
* @param location the location to resolved
|
|
537
652
|
*/
|
|
538
653
|
function commitReplace(router, resolvePromise, location) {
|
|
654
|
+
// Same chain-head veto as commit: a blocked replace never starts, and
|
|
655
|
+
// the dropped task's failure is swallowed the same way.
|
|
656
|
+
if (blockedBy(router, createPath(location))) {
|
|
657
|
+
resolvePromise.catch(noop);
|
|
658
|
+
return Promise.resolve();
|
|
659
|
+
}
|
|
539
660
|
return replaceEntry(router, Promise.resolve({
|
|
540
661
|
location,
|
|
541
662
|
task: resolvePromise
|
|
@@ -631,7 +752,9 @@ function commitBase(router, entryPromise, location, ac, onResolved) {
|
|
|
631
752
|
/**
|
|
632
753
|
* Navigate to a new path. Route guards(`redirect`/`beforeLoad`) run before
|
|
633
754
|
* the view resolves; the history entry is committed on the terminal
|
|
634
|
-
* location when guards redirected.
|
|
755
|
+
* location when guards redirected. A registered blocker(see {@link
|
|
756
|
+
* setBlocker}) may veto the navigation before anything starts. The guard
|
|
757
|
+
* phase is part of the
|
|
635
758
|
* cancelable navigation: a superseding navigate or a `cancel()` while
|
|
636
759
|
* guards are still running discards this navigation — and aborts the
|
|
637
760
|
* chain's `signal`, so guards and loaders observing it({@link
|
|
@@ -645,6 +768,14 @@ function commitBase(router, entryPromise, location, ac, onResolved) {
|
|
|
645
768
|
*/
|
|
646
769
|
function navigate(router, to, state) {
|
|
647
770
|
const location = toLocation(router, to, state);
|
|
771
|
+
// Blockers sit at the chain head, before the controller exists: a
|
|
772
|
+
// vetoed navigation never resolves a single guard, and its promise
|
|
773
|
+
// resolves immediately — a veto is not an error, and unlike a
|
|
774
|
+
// cancelled navigation(whose promise never settles) it does settle —
|
|
775
|
+
// so the ubiquitous `void navigate(...)` call sites stay untouched.
|
|
776
|
+
// The target is asked in its committed path form(`createPath`), the
|
|
777
|
+
// same string a POP blocker sees, baseUrl included.
|
|
778
|
+
if (blockedBy(router, createPath(location))) return Promise.resolve();
|
|
648
779
|
// One controller per navigation round: guards and view loaders of the
|
|
649
780
|
// whole chain(including redirect hops) share its signal.
|
|
650
781
|
const ac = new AbortController();
|
|
@@ -783,6 +914,149 @@ function invalidate(router) {
|
|
|
783
914
|
router.viewStack = new Array(router.locationStack.length).fill(null);
|
|
784
915
|
}
|
|
785
916
|
|
|
917
|
+
/**
|
|
918
|
+
* Navigation blocker predicate: `to` and `from` are path strings
|
|
919
|
+
* (pathname, search and hash included, built with `createPath`). Return
|
|
920
|
+
* `false` to veto the navigation. A blocker that throws counts as a
|
|
921
|
+
* veto too — a crashed gate must not open, and the exception must not
|
|
922
|
+
* escape into a history listener.
|
|
923
|
+
* @group Methods
|
|
924
|
+
* @category Router
|
|
925
|
+
*/
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Registered blockers per router, in registration order. Module-level
|
|
929
|
+
* so the public {@link RouterInstance} type stays untouched; the router
|
|
930
|
+
* key is weakly held, an empty leftover set after the last release
|
|
931
|
+
* leaks nothing.
|
|
932
|
+
*/
|
|
933
|
+
const blockerRegistry = new WeakMap();
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Last settled history position per router, kept in sync by {@link listen}.
|
|
937
|
+
* POP blockers read it as the `from` path and the rewind base: by the
|
|
938
|
+
* time a POP listener runs, `history.location` is already the landed
|
|
939
|
+
* location, so the pre-POP position must be tracked separately.
|
|
940
|
+
*/
|
|
941
|
+
const lastSettled = new WeakMap();
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Pending blocker rewind per router: a rewind `go()` is in flight. The
|
|
945
|
+
* rewind's own POP must not query the blockers again — they would veto
|
|
946
|
+
* it too and ping-pong the history forever.
|
|
947
|
+
*/
|
|
948
|
+
const pendingRewind = new WeakMap();
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Register a navigation blocker. Every {@link navigate}, {@link commit},
|
|
952
|
+
* {@link commitReplace} and every history POP(see {@link listen}) asks
|
|
953
|
+
* the registered blockers(in registration order, first veto wins)
|
|
954
|
+
* before anything else; a vetoed navigation never starts — no guards,
|
|
955
|
+
* no loaders, no history change — and its promise resolves immediately
|
|
956
|
+
* (a veto is not an error; unlike a cancelled navigation, whose promise
|
|
957
|
+
* never settles, a vetoed one does). `refresh` and guard redirects
|
|
958
|
+
* are never blocked: a refresh re-resolves the current location, and a
|
|
959
|
+
* redirect is the guard chain's own target correction, already asked
|
|
960
|
+
* once at the chain head. A vetoed POP is rewound with a
|
|
961
|
+
* counter-`go()`; its landing re-announces the current view without
|
|
962
|
+
* cancelling an in-flight chain.
|
|
963
|
+
* @group Methods
|
|
964
|
+
* @category Router
|
|
965
|
+
* @param router router instance
|
|
966
|
+
* @param fn blocker predicate; `to` is the target path, `from` the
|
|
967
|
+
* current path, both path strings
|
|
968
|
+
* @returns unblock - remove the blocker(idempotent)
|
|
969
|
+
*/
|
|
970
|
+
function setBlocker(router, fn) {
|
|
971
|
+
let set = blockerRegistry.get(router);
|
|
972
|
+
if (!set) {
|
|
973
|
+
set = new Set();
|
|
974
|
+
blockerRegistry.set(router, set);
|
|
975
|
+
}
|
|
976
|
+
set.add(fn);
|
|
977
|
+
let released = false;
|
|
978
|
+
return () => {
|
|
979
|
+
if (released) return;
|
|
980
|
+
released = true;
|
|
981
|
+
set.delete(fn);
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Ask every registered blocker, in registration order. The first veto
|
|
987
|
+
* wins — `some` stops asking at it — and a blocker that throws counts
|
|
988
|
+
* as a veto: a crashed gate must not open, and the exception must not
|
|
989
|
+
* escape into a history listener.
|
|
990
|
+
*/
|
|
991
|
+
function vetoedBy(set, to, from) {
|
|
992
|
+
return Array.from(set).some(block => {
|
|
993
|
+
try {
|
|
994
|
+
return !block(to, from);
|
|
995
|
+
} catch {
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Ask the blockers about a router-driven navigation. Runs before
|
|
1003
|
+
* anything else, so `history.location` is still the pre-navigation
|
|
1004
|
+
* `from`. Returns `true` when any blocker vetoed.
|
|
1005
|
+
*/
|
|
1006
|
+
function blockedBy(router, to) {
|
|
1007
|
+
const set = blockerRegistry.get(router);
|
|
1008
|
+
if (!set) return false;
|
|
1009
|
+
return vetoedBy(set, to, createPath(router.history.location));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Ask the blockers about a history POP; rewind it when vetoed.
|
|
1014
|
+
* Returns the POP's disposition for {@link listen}:
|
|
1015
|
+
* - `'vetoed'`: a blocker vetoed. The caller drops the event wholesale
|
|
1016
|
+
* — no `onViewChange`, no window sync, and no `cancel()` either, so
|
|
1017
|
+
* an in-flight chain keeps running as if the POP never happened.
|
|
1018
|
+
* - `'rewind'`: this POP is the landing of an earlier veto's rewind,
|
|
1019
|
+
* back on the entry the router never left. The router state did not
|
|
1020
|
+
* change, so the caller re-announces the current view without
|
|
1021
|
+
* cancelling the in-flight chain or re-syncing the window state.
|
|
1022
|
+
* - `false`: not blocked; the caller handles the POP normally.
|
|
1023
|
+
*/
|
|
1024
|
+
function blockedPop(router, location, index) {
|
|
1025
|
+
const {
|
|
1026
|
+
history
|
|
1027
|
+
} = router;
|
|
1028
|
+
// A pending rewind's own landing: swallow it without a second query
|
|
1029
|
+
// (a blocker that vetoes leaving a page would veto the rewind too)
|
|
1030
|
+
// and report it for the no-cancel re-announce branch in the caller.
|
|
1031
|
+
// Deliberately not index-matched: a user POP racing the pending
|
|
1032
|
+
// rewind must never re-enter the blockers either.
|
|
1033
|
+
if (pendingRewind.delete(router)) return 'rewind';
|
|
1034
|
+
const set = blockerRegistry.get(router);
|
|
1035
|
+
if (!set) return false;
|
|
1036
|
+
// Without a settled baseline(unreachable while this listener exists:
|
|
1037
|
+
// listen() seeds the tracker before registering) there is neither a
|
|
1038
|
+
// `from` nor a rewind delta to work with — let the POP land rather
|
|
1039
|
+
// than veto blind.
|
|
1040
|
+
const settled = lastSettled.get(router);
|
|
1041
|
+
if (!settled) return false;
|
|
1042
|
+
const to = createPath(location);
|
|
1043
|
+
const from = createPath(settled.location);
|
|
1044
|
+
if (!vetoedBy(set, to, from)) return false;
|
|
1045
|
+
// Rewind by the distance the POP travelled. Router-driven pushes keep
|
|
1046
|
+
// the state index and the history index in lockstep, so the delta
|
|
1047
|
+
// between the landed and settled state indexes doubles as the history
|
|
1048
|
+
// delta. A zero delta(same-index POP between stateless external
|
|
1049
|
+
// entries) cannot be rewound — `go(0)` goes nowhere — so the URL
|
|
1050
|
+
// stays on the vetoed target while the router stacks and the
|
|
1051
|
+
// rendered view keep the current entry.
|
|
1052
|
+
const delta = index - settled.index;
|
|
1053
|
+
if (delta) {
|
|
1054
|
+
pendingRewind.set(router, true);
|
|
1055
|
+
history.go(-delta);
|
|
1056
|
+
}
|
|
1057
|
+
return 'vetoed';
|
|
1058
|
+
}
|
|
1059
|
+
|
|
786
1060
|
/**
|
|
787
1061
|
* Listen the history change.
|
|
788
1062
|
* @group Methods
|
|
@@ -795,13 +1069,45 @@ function listen(router, onViewChange) {
|
|
|
795
1069
|
const {
|
|
796
1070
|
history
|
|
797
1071
|
} = router;
|
|
1072
|
+
|
|
1073
|
+
// Seed the settled-position tracker so a POP arriving before any other
|
|
1074
|
+
// history change still reads a correct `from` and rewind delta.
|
|
1075
|
+
lastSettled.set(router, {
|
|
1076
|
+
index: getHistoryState(router).index,
|
|
1077
|
+
location: history.location
|
|
1078
|
+
});
|
|
798
1079
|
const rmListener = history.listen(({
|
|
799
1080
|
action,
|
|
800
1081
|
location
|
|
801
1082
|
}) => {
|
|
802
|
-
cancel(router);
|
|
803
1083
|
const state = location.state;
|
|
804
1084
|
const index = state?.index || 0;
|
|
1085
|
+
if (action === 'POP') {
|
|
1086
|
+
const blocked = blockedPop(router, location, index);
|
|
1087
|
+
// A blocker veto runs before anything else: the POP is rewound
|
|
1088
|
+
// with a counter-`go()`, so neither the view nor the in-flight
|
|
1089
|
+
// chain may observe it.
|
|
1090
|
+
if (blocked === 'vetoed') return;
|
|
1091
|
+
if (blocked === 'rewind') {
|
|
1092
|
+
// The rewind's landing: an entry the router never left. The
|
|
1093
|
+
// stacks and the landed entry's window state are already
|
|
1094
|
+
// correct — no sync replace — and no `cancel()` either: an
|
|
1095
|
+
// in-flight chain must survive the bounced POP. Re-announce
|
|
1096
|
+
// the current view only when a snapshot exists; an
|
|
1097
|
+
// invalidate()d slot emits nothing and the host keeps its
|
|
1098
|
+
// retained view, exactly like invalidate() itself — a lazy
|
|
1099
|
+
// refresh here would supersede the very chain this branch
|
|
1100
|
+
// protects.
|
|
1101
|
+
const view = viewAt(router, index);
|
|
1102
|
+
if (view) onViewChange(view);
|
|
1103
|
+
lastSettled.set(router, {
|
|
1104
|
+
index,
|
|
1105
|
+
location
|
|
1106
|
+
});
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
cancel(router);
|
|
805
1111
|
const view = viewAt(router, index);
|
|
806
1112
|
onViewChange(view);
|
|
807
1113
|
if (!view) {
|
|
@@ -820,6 +1126,10 @@ function listen(router, onViewChange) {
|
|
|
820
1126
|
...serializeStack(router)
|
|
821
1127
|
});
|
|
822
1128
|
}
|
|
1129
|
+
lastSettled.set(router, {
|
|
1130
|
+
index,
|
|
1131
|
+
location
|
|
1132
|
+
});
|
|
823
1133
|
});
|
|
824
1134
|
history.replace(createPath(history.location), history.location.state);
|
|
825
1135
|
return () => {
|
|
@@ -870,82 +1180,4 @@ function getParams(router) {
|
|
|
870
1180
|
return mergeMatchedParams(match(router, location.pathname) ?? []);
|
|
871
1181
|
}
|
|
872
1182
|
|
|
873
|
-
|
|
874
|
-
* Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
|
|
875
|
-
* input object consumed by {@link StandardSchemaV1 search schemas}:
|
|
876
|
-
* single-valued keys are strings, keys repeated in the query string are
|
|
877
|
-
* arrays of their values. An empty search is `{}`.
|
|
878
|
-
*
|
|
879
|
-
* This is also the degraded shape every search API falls back to when no
|
|
880
|
-
* schema is given.
|
|
881
|
-
* @group Methods
|
|
882
|
-
* @category Route
|
|
883
|
-
* @param search the raw `location.search` string, with or without `?`
|
|
884
|
-
* @returns the input object for schema validation
|
|
885
|
-
*/
|
|
886
|
-
function parseSearchInput(search) {
|
|
887
|
-
const input = {};
|
|
888
|
-
// eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
|
|
889
|
-
new URLSearchParams(search).forEach((value, key) => {
|
|
890
|
-
const prev = input[key];
|
|
891
|
-
if (prev === undefined) {
|
|
892
|
-
input[key] = value;
|
|
893
|
-
} else if (Array.isArray(prev)) {
|
|
894
|
-
prev.push(value);
|
|
895
|
-
} else {
|
|
896
|
-
input[key] = [prev, value];
|
|
897
|
-
}
|
|
898
|
-
});
|
|
899
|
-
return input;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
/**
|
|
903
|
-
* Validate a search string with a {@link StandardSchemaV1} schema — any
|
|
904
|
-
* zod/valibot/arktype schema works, no hard dependency. The string is
|
|
905
|
-
* first degraded via {@link parseSearchInput}, then parsed by the schema,
|
|
906
|
-
* so schemas can coerce(`'2'` → `2`) and normalize along the way.
|
|
907
|
-
*
|
|
908
|
-
* Async schemas(`validate` returning a promise) are awaited.
|
|
909
|
-
*
|
|
910
|
-
* @group Methods
|
|
911
|
-
* @category Route
|
|
912
|
-
* @param schema the search schema
|
|
913
|
-
* @param search the raw `location.search` string
|
|
914
|
-
* @returns the parsed(and possibly coerced) output of the schema
|
|
915
|
-
* @throws {SearchError} when the schema reports issues
|
|
916
|
-
*/
|
|
917
|
-
async function parseSearch(schema, search) {
|
|
918
|
-
const result = await schema['~standard'].validate(parseSearchInput(search));
|
|
919
|
-
if (result.issues) throw new SearchError(search, result.issues);
|
|
920
|
-
// The schema's declared output; the loose `StandardSchemaV1` default
|
|
921
|
-
// degrades to `unknown`.
|
|
922
|
-
return result.value;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
/**
|
|
926
|
-
* Synchronous flavor of {@link parseSearch}, for render-time reads(see
|
|
927
|
-
* `useSearch` of `@native-router/react`) and route guards.
|
|
928
|
-
*
|
|
929
|
-
* @group Methods
|
|
930
|
-
* @category Route
|
|
931
|
-
* @param schema the search schema — must validate synchronously
|
|
932
|
-
* @param search the raw `location.search` string
|
|
933
|
-
* @returns the parsed(and possibly coerced) output of the schema
|
|
934
|
-
* @throws {SearchError} when the schema reports issues
|
|
935
|
-
* @throws when the schema validates asynchronously; use {@link parseSearch}
|
|
936
|
-
* for async schemas instead
|
|
937
|
-
*/
|
|
938
|
-
function parseSearchSync(schema, search) {
|
|
939
|
-
const result = schema['~standard'].validate(parseSearchInput(search));
|
|
940
|
-
if (isThenable(result)) {
|
|
941
|
-
throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
|
|
942
|
-
}
|
|
943
|
-
if (result.issues) throw new SearchError(search, result.issues);
|
|
944
|
-
// See parseSearch for the cast rationale.
|
|
945
|
-
return result.value;
|
|
946
|
-
}
|
|
947
|
-
function isThenable(value) {
|
|
948
|
-
return typeof value?.then === 'function';
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
export { NativeRouterError, NotFoundError, RedirectLoopError, SearchError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, invalidate, listen, match, mergeMatchedParams, navigate, parseSearch, parseSearchInput, parseSearchSync, preload, refresh, resolve, resolveEntry, resolveTo, setOptions, toLocation };
|
|
1183
|
+
export { NativeRouterError, NotFoundError, RedirectLoopError, SearchError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, invalidate, listen, match, mergeMatchedParams, navigate, parseSearch, parseSearchInput, parseSearchSync, preload, refresh, resolve, resolveEntry, resolveTo, setBlocker, setOptions, toLocation };
|
package/dist/types/router.d.ts
CHANGED
|
@@ -100,6 +100,11 @@ export declare function resolveTo<R extends BaseRoute = BaseRoute, V = any>(rout
|
|
|
100
100
|
* aborts — their resolution may be shared, so cancelling it on behalf of
|
|
101
101
|
* one consumer is not sound yet.
|
|
102
102
|
*
|
|
103
|
+
* A guard's context also carries the level's parsed
|
|
104
|
+
* {@link GuardContext.search search}: the {@link BaseRoute.search schema}
|
|
105
|
+
* output(its validation failure rejects this resolution with a
|
|
106
|
+
* `SearchError`), or the degraded input without a schema.
|
|
107
|
+
*
|
|
103
108
|
* @group Methods
|
|
104
109
|
* @category Router
|
|
105
110
|
* @param router router instance
|
|
@@ -156,7 +161,9 @@ export declare function commitReplace<R extends BaseRoute = BaseRoute, V = any>(
|
|
|
156
161
|
/**
|
|
157
162
|
* Navigate to a new path. Route guards(`redirect`/`beforeLoad`) run before
|
|
158
163
|
* the view resolves; the history entry is committed on the terminal
|
|
159
|
-
* location when guards redirected.
|
|
164
|
+
* location when guards redirected. A registered blocker(see {@link
|
|
165
|
+
* setBlocker}) may veto the navigation before anything starts. The guard
|
|
166
|
+
* phase is part of the
|
|
160
167
|
* cancelable navigation: a superseding navigate or a `cancel()` while
|
|
161
168
|
* guards are still running discards this navigation — and aborts the
|
|
162
169
|
* chain's `signal`, so guards and loaders observing it({@link
|
|
@@ -250,6 +257,37 @@ export declare function initHistoryStack<R extends BaseRoute = BaseRoute, V = an
|
|
|
250
257
|
* @param router router instance
|
|
251
258
|
*/
|
|
252
259
|
export declare function invalidate<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>): void;
|
|
260
|
+
/**
|
|
261
|
+
* Navigation blocker predicate: `to` and `from` are path strings
|
|
262
|
+
* (pathname, search and hash included, built with `createPath`). Return
|
|
263
|
+
* `false` to veto the navigation. A blocker that throws counts as a
|
|
264
|
+
* veto too — a crashed gate must not open, and the exception must not
|
|
265
|
+
* escape into a history listener.
|
|
266
|
+
* @group Methods
|
|
267
|
+
* @category Router
|
|
268
|
+
*/
|
|
269
|
+
export type BlockerFn = (to: string, from: string) => boolean;
|
|
270
|
+
/**
|
|
271
|
+
* Register a navigation blocker. Every {@link navigate}, {@link commit},
|
|
272
|
+
* {@link commitReplace} and every history POP(see {@link listen}) asks
|
|
273
|
+
* the registered blockers(in registration order, first veto wins)
|
|
274
|
+
* before anything else; a vetoed navigation never starts — no guards,
|
|
275
|
+
* no loaders, no history change — and its promise resolves immediately
|
|
276
|
+
* (a veto is not an error; unlike a cancelled navigation, whose promise
|
|
277
|
+
* never settles, a vetoed one does). `refresh` and guard redirects
|
|
278
|
+
* are never blocked: a refresh re-resolves the current location, and a
|
|
279
|
+
* redirect is the guard chain's own target correction, already asked
|
|
280
|
+
* once at the chain head. A vetoed POP is rewound with a
|
|
281
|
+
* counter-`go()`; its landing re-announces the current view without
|
|
282
|
+
* cancelling an in-flight chain.
|
|
283
|
+
* @group Methods
|
|
284
|
+
* @category Router
|
|
285
|
+
* @param router router instance
|
|
286
|
+
* @param fn blocker predicate; `to` is the target path, `from` the
|
|
287
|
+
* current path, both path strings
|
|
288
|
+
* @returns unblock - remove the blocker(idempotent)
|
|
289
|
+
*/
|
|
290
|
+
export declare function setBlocker<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>, fn: BlockerFn): () => void;
|
|
253
291
|
/**
|
|
254
292
|
* Listen the history change.
|
|
255
293
|
* @group Methods
|
package/dist/types/types.d.ts
CHANGED
|
@@ -143,10 +143,19 @@ export type ExtractPathParams<P extends string> = P extends `${infer Head}/${inf
|
|
|
143
143
|
* `params` are accumulated from the root level down to the level that
|
|
144
144
|
* owns the guard, so a guard only sees params of itself and its parents.
|
|
145
145
|
*/
|
|
146
|
-
export type GuardContext<R extends BaseRoute = BaseRoute> = {
|
|
146
|
+
export type GuardContext<R extends BaseRoute = BaseRoute, S = unknown> = {
|
|
147
147
|
router: RouterInstance<R>;
|
|
148
148
|
location: Location;
|
|
149
149
|
params: Record<string, string>;
|
|
150
|
+
/**
|
|
151
|
+
* The search the guard sees: the route's {@link BaseRoute.search search
|
|
152
|
+
* schema} output(parsed and validated before the guard runs), or the
|
|
153
|
+
* degraded {@link SearchInput} when the route declares no schema. The
|
|
154
|
+
* loose default types it `unknown` — narrow it in the guard, or let a
|
|
155
|
+
* typed route table(see `createRoutes` of `@native-router/react`)
|
|
156
|
+
* derive it from the schema.
|
|
157
|
+
*/
|
|
158
|
+
search: S;
|
|
150
159
|
/**
|
|
151
160
|
* Aborted when this navigation is superseded by a newer one or
|
|
152
161
|
* cancelled(see {@link RouterInstance.cancelAll cancel}); pass it to
|
|
@@ -173,7 +182,11 @@ export type BaseRoute<T = any> = {
|
|
|
173
182
|
search?: StandardSchemaV1;
|
|
174
183
|
/**
|
|
175
184
|
* Route guard invoked before the view resolves. Return a path string
|
|
176
|
-
* to redirect, or nothing(`undefined`) to continue.
|
|
185
|
+
* to redirect, or nothing(`undefined`) to continue. The guard's
|
|
186
|
+
* {@link GuardContext context} carries the level's parsed
|
|
187
|
+
* {@link GuardContext.search search}(schema output, or the degraded
|
|
188
|
+
* input without a schema); an invalid search fails the resolution at
|
|
189
|
+
* this phase like any other navigation error.
|
|
177
190
|
*/
|
|
178
191
|
beforeLoad?(ctx: GuardContext<BaseRoute<T>>): Awaitable<string | void>;
|
|
179
192
|
} & Omit<T, 'path' | 'children'>;
|