@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 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/guard-time flavor and rejects async validators with a clear error
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
- const target = route.redirect ?? (
368
- // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
369
- await route.beforeLoad?.({
370
- router,
371
- location,
372
- params: mergeMatchedParams(matched, i),
373
- signal
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. The guard phase is part of the
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, noop } from './util.mjs';
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
- const target = route.redirect ?? (
366
- // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
367
- await route.beforeLoad?.({
368
- router,
369
- location,
370
- params: mergeMatchedParams(matched, i),
371
- signal
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. The guard phase is part of the
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 };
@@ -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. The guard phase is part of the
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
@@ -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'>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-router/core",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/types/index.d.ts",