@native-router/core 1.6.0 → 1.7.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`
@@ -111,6 +112,14 @@ const router = create(
111
112
  - `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
112
113
  - 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
114
 
115
+ ## Design principles
116
+
117
+ **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.
118
+
119
+ - **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.
120
+ - **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.
121
+ - **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.
122
+
114
123
  ## Install
115
124
 
116
125
  ```bash
package/dist/index.cjs CHANGED
@@ -484,6 +484,15 @@ function preloadCacheOf(router) {
484
484
  * @param location the location to resolved
485
485
  */
486
486
  function commit(router, resolvePromise, location) {
487
+ // Blockers sit at the chain head: a vetoed external commit is dropped
488
+ // before the given task is ever awaited. The dropped task still gets
489
+ // a rejection handler — an orphaned failure(preload tasks re-throw
490
+ // NotFoundError through the default errorHandler) would otherwise
491
+ // surface as an unhandled rejection.
492
+ if (blockedBy(router, history.createPath(location))) {
493
+ resolvePromise.catch(util.noop);
494
+ return Promise.resolve();
495
+ }
487
496
  // Wrap the raw task so external callers share the guarded entry
488
497
  // pipeline; the entry location is the given one, as-is.
489
498
  return pushEntry(router, Promise.resolve({
@@ -538,6 +547,12 @@ function pushEntry(router, entryPromise, fromLocation, ac) {
538
547
  * @param location the location to resolved
539
548
  */
540
549
  function commitReplace(router, resolvePromise, location) {
550
+ // Same chain-head veto as commit: a blocked replace never starts, and
551
+ // the dropped task's failure is swallowed the same way.
552
+ if (blockedBy(router, history.createPath(location))) {
553
+ resolvePromise.catch(util.noop);
554
+ return Promise.resolve();
555
+ }
541
556
  return replaceEntry(router, Promise.resolve({
542
557
  location,
543
558
  task: resolvePromise
@@ -633,7 +648,9 @@ function commitBase(router, entryPromise, location, ac, onResolved) {
633
648
  /**
634
649
  * Navigate to a new path. Route guards(`redirect`/`beforeLoad`) run before
635
650
  * the view resolves; the history entry is committed on the terminal
636
- * location when guards redirected. The guard phase is part of the
651
+ * location when guards redirected. A registered blocker(see {@link
652
+ * setBlocker}) may veto the navigation before anything starts. The guard
653
+ * phase is part of the
637
654
  * cancelable navigation: a superseding navigate or a `cancel()` while
638
655
  * guards are still running discards this navigation — and aborts the
639
656
  * chain's `signal`, so guards and loaders observing it({@link
@@ -647,6 +664,14 @@ function commitBase(router, entryPromise, location, ac, onResolved) {
647
664
  */
648
665
  function navigate(router, to, state) {
649
666
  const location = toLocation(router, to, state);
667
+ // Blockers sit at the chain head, before the controller exists: a
668
+ // vetoed navigation never resolves a single guard, and its promise
669
+ // resolves immediately — a veto is not an error, and unlike a
670
+ // cancelled navigation(whose promise never settles) it does settle —
671
+ // so the ubiquitous `void navigate(...)` call sites stay untouched.
672
+ // The target is asked in its committed path form(`createPath`), the
673
+ // same string a POP blocker sees, baseUrl included.
674
+ if (blockedBy(router, history.createPath(location))) return Promise.resolve();
650
675
  // One controller per navigation round: guards and view loaders of the
651
676
  // whole chain(including redirect hops) share its signal.
652
677
  const ac = new AbortController();
@@ -785,6 +810,149 @@ function invalidate(router) {
785
810
  router.viewStack = new Array(router.locationStack.length).fill(null);
786
811
  }
787
812
 
813
+ /**
814
+ * Navigation blocker predicate: `to` and `from` are path strings
815
+ * (pathname, search and hash included, built with `createPath`). Return
816
+ * `false` to veto the navigation. A blocker that throws counts as a
817
+ * veto too — a crashed gate must not open, and the exception must not
818
+ * escape into a history listener.
819
+ * @group Methods
820
+ * @category Router
821
+ */
822
+
823
+ /**
824
+ * Registered blockers per router, in registration order. Module-level
825
+ * so the public {@link RouterInstance} type stays untouched; the router
826
+ * key is weakly held, an empty leftover set after the last release
827
+ * leaks nothing.
828
+ */
829
+ const blockerRegistry = new WeakMap();
830
+
831
+ /**
832
+ * Last settled history position per router, kept in sync by {@link listen}.
833
+ * POP blockers read it as the `from` path and the rewind base: by the
834
+ * time a POP listener runs, `history.location` is already the landed
835
+ * location, so the pre-POP position must be tracked separately.
836
+ */
837
+ const lastSettled = new WeakMap();
838
+
839
+ /**
840
+ * Pending blocker rewind per router: a rewind `go()` is in flight. The
841
+ * rewind's own POP must not query the blockers again — they would veto
842
+ * it too and ping-pong the history forever.
843
+ */
844
+ const pendingRewind = new WeakMap();
845
+
846
+ /**
847
+ * Register a navigation blocker. Every {@link navigate}, {@link commit},
848
+ * {@link commitReplace} and every history POP(see {@link listen}) asks
849
+ * the registered blockers(in registration order, first veto wins)
850
+ * before anything else; a vetoed navigation never starts — no guards,
851
+ * no loaders, no history change — and its promise resolves immediately
852
+ * (a veto is not an error; unlike a cancelled navigation, whose promise
853
+ * never settles, a vetoed one does). `refresh` and guard redirects
854
+ * are never blocked: a refresh re-resolves the current location, and a
855
+ * redirect is the guard chain's own target correction, already asked
856
+ * once at the chain head. A vetoed POP is rewound with a
857
+ * counter-`go()`; its landing re-announces the current view without
858
+ * cancelling an in-flight chain.
859
+ * @group Methods
860
+ * @category Router
861
+ * @param router router instance
862
+ * @param fn blocker predicate; `to` is the target path, `from` the
863
+ * current path, both path strings
864
+ * @returns unblock - remove the blocker(idempotent)
865
+ */
866
+ function setBlocker(router, fn) {
867
+ let set = blockerRegistry.get(router);
868
+ if (!set) {
869
+ set = new Set();
870
+ blockerRegistry.set(router, set);
871
+ }
872
+ set.add(fn);
873
+ let released = false;
874
+ return () => {
875
+ if (released) return;
876
+ released = true;
877
+ set.delete(fn);
878
+ };
879
+ }
880
+
881
+ /**
882
+ * Ask every registered blocker, in registration order. The first veto
883
+ * wins — `some` stops asking at it — and a blocker that throws counts
884
+ * as a veto: a crashed gate must not open, and the exception must not
885
+ * escape into a history listener.
886
+ */
887
+ function vetoedBy(set, to, from) {
888
+ return Array.from(set).some(block => {
889
+ try {
890
+ return !block(to, from);
891
+ } catch {
892
+ return true;
893
+ }
894
+ });
895
+ }
896
+
897
+ /**
898
+ * Ask the blockers about a router-driven navigation. Runs before
899
+ * anything else, so `history.location` is still the pre-navigation
900
+ * `from`. Returns `true` when any blocker vetoed.
901
+ */
902
+ function blockedBy(router, to) {
903
+ const set = blockerRegistry.get(router);
904
+ if (!set) return false;
905
+ return vetoedBy(set, to, history.createPath(router.history.location));
906
+ }
907
+
908
+ /**
909
+ * Ask the blockers about a history POP; rewind it when vetoed.
910
+ * Returns the POP's disposition for {@link listen}:
911
+ * - `'vetoed'`: a blocker vetoed. The caller drops the event wholesale
912
+ * — no `onViewChange`, no window sync, and no `cancel()` either, so
913
+ * an in-flight chain keeps running as if the POP never happened.
914
+ * - `'rewind'`: this POP is the landing of an earlier veto's rewind,
915
+ * back on the entry the router never left. The router state did not
916
+ * change, so the caller re-announces the current view without
917
+ * cancelling the in-flight chain or re-syncing the window state.
918
+ * - `false`: not blocked; the caller handles the POP normally.
919
+ */
920
+ function blockedPop(router, location, index) {
921
+ const {
922
+ history: history$1
923
+ } = router;
924
+ // A pending rewind's own landing: swallow it without a second query
925
+ // (a blocker that vetoes leaving a page would veto the rewind too)
926
+ // and report it for the no-cancel re-announce branch in the caller.
927
+ // Deliberately not index-matched: a user POP racing the pending
928
+ // rewind must never re-enter the blockers either.
929
+ if (pendingRewind.delete(router)) return 'rewind';
930
+ const set = blockerRegistry.get(router);
931
+ if (!set) return false;
932
+ // Without a settled baseline(unreachable while this listener exists:
933
+ // listen() seeds the tracker before registering) there is neither a
934
+ // `from` nor a rewind delta to work with — let the POP land rather
935
+ // than veto blind.
936
+ const settled = lastSettled.get(router);
937
+ if (!settled) return false;
938
+ const to = history.createPath(location);
939
+ const from = history.createPath(settled.location);
940
+ if (!vetoedBy(set, to, from)) return false;
941
+ // Rewind by the distance the POP travelled. Router-driven pushes keep
942
+ // the state index and the history index in lockstep, so the delta
943
+ // between the landed and settled state indexes doubles as the history
944
+ // delta. A zero delta(same-index POP between stateless external
945
+ // entries) cannot be rewound — `go(0)` goes nowhere — so the URL
946
+ // stays on the vetoed target while the router stacks and the
947
+ // rendered view keep the current entry.
948
+ const delta = index - settled.index;
949
+ if (delta) {
950
+ pendingRewind.set(router, true);
951
+ history$1.go(-delta);
952
+ }
953
+ return 'vetoed';
954
+ }
955
+
788
956
  /**
789
957
  * Listen the history change.
790
958
  * @group Methods
@@ -797,13 +965,45 @@ function listen(router, onViewChange) {
797
965
  const {
798
966
  history: history$1
799
967
  } = router;
968
+
969
+ // Seed the settled-position tracker so a POP arriving before any other
970
+ // history change still reads a correct `from` and rewind delta.
971
+ lastSettled.set(router, {
972
+ index: getHistoryState(router).index,
973
+ location: history$1.location
974
+ });
800
975
  const rmListener = history$1.listen(({
801
976
  action,
802
977
  location
803
978
  }) => {
804
- cancel(router);
805
979
  const state = location.state;
806
980
  const index = state?.index || 0;
981
+ if (action === 'POP') {
982
+ const blocked = blockedPop(router, location, index);
983
+ // A blocker veto runs before anything else: the POP is rewound
984
+ // with a counter-`go()`, so neither the view nor the in-flight
985
+ // chain may observe it.
986
+ if (blocked === 'vetoed') return;
987
+ if (blocked === 'rewind') {
988
+ // The rewind's landing: an entry the router never left. The
989
+ // stacks and the landed entry's window state are already
990
+ // correct — no sync replace — and no `cancel()` either: an
991
+ // in-flight chain must survive the bounced POP. Re-announce
992
+ // the current view only when a snapshot exists; an
993
+ // invalidate()d slot emits nothing and the host keeps its
994
+ // retained view, exactly like invalidate() itself — a lazy
995
+ // refresh here would supersede the very chain this branch
996
+ // protects.
997
+ const view = viewAt(router, index);
998
+ if (view) onViewChange(view);
999
+ lastSettled.set(router, {
1000
+ index,
1001
+ location
1002
+ });
1003
+ return;
1004
+ }
1005
+ }
1006
+ cancel(router);
807
1007
  const view = viewAt(router, index);
808
1008
  onViewChange(view);
809
1009
  if (!view) {
@@ -822,6 +1022,10 @@ function listen(router, onViewChange) {
822
1022
  ...serializeStack(router)
823
1023
  });
824
1024
  }
1025
+ lastSettled.set(router, {
1026
+ index,
1027
+ location
1028
+ });
825
1029
  });
826
1030
  history$1.replace(history.createPath(history$1.location), history$1.location.state);
827
1031
  return () => {
@@ -979,5 +1183,6 @@ exports.refresh = refresh;
979
1183
  exports.resolve = resolve;
980
1184
  exports.resolveEntry = resolveEntry;
981
1185
  exports.resolveTo = resolveTo;
1186
+ exports.setBlocker = setBlocker;
982
1187
  exports.setOptions = setOptions;
983
1188
  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
 
@@ -482,6 +482,15 @@ function preloadCacheOf(router) {
482
482
  * @param location the location to resolved
483
483
  */
484
484
  function commit(router, resolvePromise, location) {
485
+ // Blockers sit at the chain head: a vetoed external commit is dropped
486
+ // before the given task is ever awaited. The dropped task still gets
487
+ // a rejection handler — an orphaned failure(preload tasks re-throw
488
+ // NotFoundError through the default errorHandler) would otherwise
489
+ // surface as an unhandled rejection.
490
+ if (blockedBy(router, createPath(location))) {
491
+ resolvePromise.catch(noop);
492
+ return Promise.resolve();
493
+ }
485
494
  // Wrap the raw task so external callers share the guarded entry
486
495
  // pipeline; the entry location is the given one, as-is.
487
496
  return pushEntry(router, Promise.resolve({
@@ -536,6 +545,12 @@ function pushEntry(router, entryPromise, fromLocation, ac) {
536
545
  * @param location the location to resolved
537
546
  */
538
547
  function commitReplace(router, resolvePromise, location) {
548
+ // Same chain-head veto as commit: a blocked replace never starts, and
549
+ // the dropped task's failure is swallowed the same way.
550
+ if (blockedBy(router, createPath(location))) {
551
+ resolvePromise.catch(noop);
552
+ return Promise.resolve();
553
+ }
539
554
  return replaceEntry(router, Promise.resolve({
540
555
  location,
541
556
  task: resolvePromise
@@ -631,7 +646,9 @@ function commitBase(router, entryPromise, location, ac, onResolved) {
631
646
  /**
632
647
  * Navigate to a new path. Route guards(`redirect`/`beforeLoad`) run before
633
648
  * the view resolves; the history entry is committed on the terminal
634
- * location when guards redirected. The guard phase is part of the
649
+ * location when guards redirected. A registered blocker(see {@link
650
+ * setBlocker}) may veto the navigation before anything starts. The guard
651
+ * phase is part of the
635
652
  * cancelable navigation: a superseding navigate or a `cancel()` while
636
653
  * guards are still running discards this navigation — and aborts the
637
654
  * chain's `signal`, so guards and loaders observing it({@link
@@ -645,6 +662,14 @@ function commitBase(router, entryPromise, location, ac, onResolved) {
645
662
  */
646
663
  function navigate(router, to, state) {
647
664
  const location = toLocation(router, to, state);
665
+ // Blockers sit at the chain head, before the controller exists: a
666
+ // vetoed navigation never resolves a single guard, and its promise
667
+ // resolves immediately — a veto is not an error, and unlike a
668
+ // cancelled navigation(whose promise never settles) it does settle —
669
+ // so the ubiquitous `void navigate(...)` call sites stay untouched.
670
+ // The target is asked in its committed path form(`createPath`), the
671
+ // same string a POP blocker sees, baseUrl included.
672
+ if (blockedBy(router, createPath(location))) return Promise.resolve();
648
673
  // One controller per navigation round: guards and view loaders of the
649
674
  // whole chain(including redirect hops) share its signal.
650
675
  const ac = new AbortController();
@@ -783,6 +808,149 @@ function invalidate(router) {
783
808
  router.viewStack = new Array(router.locationStack.length).fill(null);
784
809
  }
785
810
 
811
+ /**
812
+ * Navigation blocker predicate: `to` and `from` are path strings
813
+ * (pathname, search and hash included, built with `createPath`). Return
814
+ * `false` to veto the navigation. A blocker that throws counts as a
815
+ * veto too — a crashed gate must not open, and the exception must not
816
+ * escape into a history listener.
817
+ * @group Methods
818
+ * @category Router
819
+ */
820
+
821
+ /**
822
+ * Registered blockers per router, in registration order. Module-level
823
+ * so the public {@link RouterInstance} type stays untouched; the router
824
+ * key is weakly held, an empty leftover set after the last release
825
+ * leaks nothing.
826
+ */
827
+ const blockerRegistry = new WeakMap();
828
+
829
+ /**
830
+ * Last settled history position per router, kept in sync by {@link listen}.
831
+ * POP blockers read it as the `from` path and the rewind base: by the
832
+ * time a POP listener runs, `history.location` is already the landed
833
+ * location, so the pre-POP position must be tracked separately.
834
+ */
835
+ const lastSettled = new WeakMap();
836
+
837
+ /**
838
+ * Pending blocker rewind per router: a rewind `go()` is in flight. The
839
+ * rewind's own POP must not query the blockers again — they would veto
840
+ * it too and ping-pong the history forever.
841
+ */
842
+ const pendingRewind = new WeakMap();
843
+
844
+ /**
845
+ * Register a navigation blocker. Every {@link navigate}, {@link commit},
846
+ * {@link commitReplace} and every history POP(see {@link listen}) asks
847
+ * the registered blockers(in registration order, first veto wins)
848
+ * before anything else; a vetoed navigation never starts — no guards,
849
+ * no loaders, no history change — and its promise resolves immediately
850
+ * (a veto is not an error; unlike a cancelled navigation, whose promise
851
+ * never settles, a vetoed one does). `refresh` and guard redirects
852
+ * are never blocked: a refresh re-resolves the current location, and a
853
+ * redirect is the guard chain's own target correction, already asked
854
+ * once at the chain head. A vetoed POP is rewound with a
855
+ * counter-`go()`; its landing re-announces the current view without
856
+ * cancelling an in-flight chain.
857
+ * @group Methods
858
+ * @category Router
859
+ * @param router router instance
860
+ * @param fn blocker predicate; `to` is the target path, `from` the
861
+ * current path, both path strings
862
+ * @returns unblock - remove the blocker(idempotent)
863
+ */
864
+ function setBlocker(router, fn) {
865
+ let set = blockerRegistry.get(router);
866
+ if (!set) {
867
+ set = new Set();
868
+ blockerRegistry.set(router, set);
869
+ }
870
+ set.add(fn);
871
+ let released = false;
872
+ return () => {
873
+ if (released) return;
874
+ released = true;
875
+ set.delete(fn);
876
+ };
877
+ }
878
+
879
+ /**
880
+ * Ask every registered blocker, in registration order. The first veto
881
+ * wins — `some` stops asking at it — and a blocker that throws counts
882
+ * as a veto: a crashed gate must not open, and the exception must not
883
+ * escape into a history listener.
884
+ */
885
+ function vetoedBy(set, to, from) {
886
+ return Array.from(set).some(block => {
887
+ try {
888
+ return !block(to, from);
889
+ } catch {
890
+ return true;
891
+ }
892
+ });
893
+ }
894
+
895
+ /**
896
+ * Ask the blockers about a router-driven navigation. Runs before
897
+ * anything else, so `history.location` is still the pre-navigation
898
+ * `from`. Returns `true` when any blocker vetoed.
899
+ */
900
+ function blockedBy(router, to) {
901
+ const set = blockerRegistry.get(router);
902
+ if (!set) return false;
903
+ return vetoedBy(set, to, createPath(router.history.location));
904
+ }
905
+
906
+ /**
907
+ * Ask the blockers about a history POP; rewind it when vetoed.
908
+ * Returns the POP's disposition for {@link listen}:
909
+ * - `'vetoed'`: a blocker vetoed. The caller drops the event wholesale
910
+ * — no `onViewChange`, no window sync, and no `cancel()` either, so
911
+ * an in-flight chain keeps running as if the POP never happened.
912
+ * - `'rewind'`: this POP is the landing of an earlier veto's rewind,
913
+ * back on the entry the router never left. The router state did not
914
+ * change, so the caller re-announces the current view without
915
+ * cancelling the in-flight chain or re-syncing the window state.
916
+ * - `false`: not blocked; the caller handles the POP normally.
917
+ */
918
+ function blockedPop(router, location, index) {
919
+ const {
920
+ history
921
+ } = router;
922
+ // A pending rewind's own landing: swallow it without a second query
923
+ // (a blocker that vetoes leaving a page would veto the rewind too)
924
+ // and report it for the no-cancel re-announce branch in the caller.
925
+ // Deliberately not index-matched: a user POP racing the pending
926
+ // rewind must never re-enter the blockers either.
927
+ if (pendingRewind.delete(router)) return 'rewind';
928
+ const set = blockerRegistry.get(router);
929
+ if (!set) return false;
930
+ // Without a settled baseline(unreachable while this listener exists:
931
+ // listen() seeds the tracker before registering) there is neither a
932
+ // `from` nor a rewind delta to work with — let the POP land rather
933
+ // than veto blind.
934
+ const settled = lastSettled.get(router);
935
+ if (!settled) return false;
936
+ const to = createPath(location);
937
+ const from = createPath(settled.location);
938
+ if (!vetoedBy(set, to, from)) return false;
939
+ // Rewind by the distance the POP travelled. Router-driven pushes keep
940
+ // the state index and the history index in lockstep, so the delta
941
+ // between the landed and settled state indexes doubles as the history
942
+ // delta. A zero delta(same-index POP between stateless external
943
+ // entries) cannot be rewound — `go(0)` goes nowhere — so the URL
944
+ // stays on the vetoed target while the router stacks and the
945
+ // rendered view keep the current entry.
946
+ const delta = index - settled.index;
947
+ if (delta) {
948
+ pendingRewind.set(router, true);
949
+ history.go(-delta);
950
+ }
951
+ return 'vetoed';
952
+ }
953
+
786
954
  /**
787
955
  * Listen the history change.
788
956
  * @group Methods
@@ -795,13 +963,45 @@ function listen(router, onViewChange) {
795
963
  const {
796
964
  history
797
965
  } = router;
966
+
967
+ // Seed the settled-position tracker so a POP arriving before any other
968
+ // history change still reads a correct `from` and rewind delta.
969
+ lastSettled.set(router, {
970
+ index: getHistoryState(router).index,
971
+ location: history.location
972
+ });
798
973
  const rmListener = history.listen(({
799
974
  action,
800
975
  location
801
976
  }) => {
802
- cancel(router);
803
977
  const state = location.state;
804
978
  const index = state?.index || 0;
979
+ if (action === 'POP') {
980
+ const blocked = blockedPop(router, location, index);
981
+ // A blocker veto runs before anything else: the POP is rewound
982
+ // with a counter-`go()`, so neither the view nor the in-flight
983
+ // chain may observe it.
984
+ if (blocked === 'vetoed') return;
985
+ if (blocked === 'rewind') {
986
+ // The rewind's landing: an entry the router never left. The
987
+ // stacks and the landed entry's window state are already
988
+ // correct — no sync replace — and no `cancel()` either: an
989
+ // in-flight chain must survive the bounced POP. Re-announce
990
+ // the current view only when a snapshot exists; an
991
+ // invalidate()d slot emits nothing and the host keeps its
992
+ // retained view, exactly like invalidate() itself — a lazy
993
+ // refresh here would supersede the very chain this branch
994
+ // protects.
995
+ const view = viewAt(router, index);
996
+ if (view) onViewChange(view);
997
+ lastSettled.set(router, {
998
+ index,
999
+ location
1000
+ });
1001
+ return;
1002
+ }
1003
+ }
1004
+ cancel(router);
805
1005
  const view = viewAt(router, index);
806
1006
  onViewChange(view);
807
1007
  if (!view) {
@@ -820,6 +1020,10 @@ function listen(router, onViewChange) {
820
1020
  ...serializeStack(router)
821
1021
  });
822
1022
  }
1023
+ lastSettled.set(router, {
1024
+ index,
1025
+ location
1026
+ });
823
1027
  });
824
1028
  history.replace(createPath(history.location), history.location.state);
825
1029
  return () => {
@@ -948,4 +1152,4 @@ function isThenable(value) {
948
1152
  return typeof value?.then === 'function';
949
1153
  }
950
1154
 
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 };
1155
+ 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 };
@@ -156,7 +156,9 @@ export declare function commitReplace<R extends BaseRoute = BaseRoute, V = any>(
156
156
  /**
157
157
  * Navigate to a new path. Route guards(`redirect`/`beforeLoad`) run before
158
158
  * the view resolves; the history entry is committed on the terminal
159
- * location when guards redirected. The guard phase is part of the
159
+ * location when guards redirected. A registered blocker(see {@link
160
+ * setBlocker}) may veto the navigation before anything starts. The guard
161
+ * phase is part of the
160
162
  * cancelable navigation: a superseding navigate or a `cancel()` while
161
163
  * guards are still running discards this navigation — and aborts the
162
164
  * chain's `signal`, so guards and loaders observing it({@link
@@ -250,6 +252,37 @@ export declare function initHistoryStack<R extends BaseRoute = BaseRoute, V = an
250
252
  * @param router router instance
251
253
  */
252
254
  export declare function invalidate<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>): void;
255
+ /**
256
+ * Navigation blocker predicate: `to` and `from` are path strings
257
+ * (pathname, search and hash included, built with `createPath`). Return
258
+ * `false` to veto the navigation. A blocker that throws counts as a
259
+ * veto too — a crashed gate must not open, and the exception must not
260
+ * escape into a history listener.
261
+ * @group Methods
262
+ * @category Router
263
+ */
264
+ export type BlockerFn = (to: string, from: string) => boolean;
265
+ /**
266
+ * Register a navigation blocker. Every {@link navigate}, {@link commit},
267
+ * {@link commitReplace} and every history POP(see {@link listen}) asks
268
+ * the registered blockers(in registration order, first veto wins)
269
+ * before anything else; a vetoed navigation never starts — no guards,
270
+ * no loaders, no history change — and its promise resolves immediately
271
+ * (a veto is not an error; unlike a cancelled navigation, whose promise
272
+ * never settles, a vetoed one does). `refresh` and guard redirects
273
+ * are never blocked: a refresh re-resolves the current location, and a
274
+ * redirect is the guard chain's own target correction, already asked
275
+ * once at the chain head. A vetoed POP is rewound with a
276
+ * counter-`go()`; its landing re-announces the current view without
277
+ * cancelling an in-flight chain.
278
+ * @group Methods
279
+ * @category Router
280
+ * @param router router instance
281
+ * @param fn blocker predicate; `to` is the target path, `from` the
282
+ * current path, both path strings
283
+ * @returns unblock - remove the blocker(idempotent)
284
+ */
285
+ export declare function setBlocker<R extends BaseRoute = BaseRoute, V = any>(router: RouterInstance<R, V>, fn: BlockerFn): () => void;
253
286
  /**
254
287
  * Listen the history change.
255
288
  * @group Methods
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-router/core",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/types/index.d.ts",