@real-router/navigation-plugin 0.6.3 → 0.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
@@ -62,11 +62,11 @@ router.usePlugin(
62
62
 
63
63
  ### Compatible extensions (same as browser-plugin)
64
64
 
65
- | Method | Returns | Description |
66
- | -------------------------------------------- | -------------------- | ------------------------------------------------ |
67
- | `buildUrl(name, params?)` | `string` | Build full URL with base path |
68
- | `matchUrl(url)` | `State \| undefined` | Parse URL to router state |
69
- | `replaceHistoryState(name, params?)` | `void` | Update browser URL without triggering navigation |
65
+ | Method | Returns | Description |
66
+ | ----------------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------- |
67
+ | `buildUrl(name, params?, options?: { hash? })` | `string` | Build full URL with base path. `options.hash` (decoded) is encoded and appended. |
68
+ | `matchUrl(url)` | `State \| undefined` | Parse URL to router state |
69
+ | `replaceHistoryState(name, params?, options?: { hash? })` | `void` | Update browser URL without triggering navigation. Tri-state `hash`: `undefined` preserves, `""` clears, value sets. |
70
70
 
71
71
  ```typescript
72
72
  router.buildUrl("users", { id: "123" });
@@ -145,6 +145,27 @@ if (router.canGoBackTo("users.list")) {
145
145
  }
146
146
  ```
147
147
 
148
+ ## URL Fragment ("hash") Support
149
+
150
+ `navigation-plugin` ships first-class URL-fragment support: `<Link hash>` from any framework adapter, programmatic `router.navigate(name, params, { hash })`, and a `state.context.url = { hash, hashChanged }` namespace populated on every transition.
151
+
152
+ ```typescript
153
+ // Programmatic — tri-state opts.hash
154
+ router.navigate("settings", {}, { hash: "profile" }); // set
155
+ router.navigate("settings", {}, { hash: "" }); // clear
156
+ router.navigate("settings"); // preserve current
157
+
158
+ // In subscribers
159
+ router.subscribe(({ route }) => {
160
+ console.log(route.context.url?.hash); // "profile"
161
+ console.log(route.context.url?.hashChanged); // true on hash-only nav
162
+ });
163
+ ```
164
+
165
+ Browser-driven hash-only clicks (`event.hashChange === true` from the Navigation API) bypass core's `SAME_STATES` rejection via `force: true, hashChange: true` — subscribers fire normally on tab-style URLs.
166
+
167
+ See the [Hash Fragment Support](https://github.com/greydragon888/real-router/wiki/Hash) wiki page for the full surface (encoding, F5 priming, hash-aware active state).
168
+
148
169
  ## Navigation Metadata
149
170
 
150
171
  Navigation metadata is available on `state.context.navigation` after each transition. The plugin writes it via the claim-based State Context API, and it is frozen (`Object.freeze`) for mutation protection.
@@ -1,5 +1,4 @@
1
1
  import { Params, PluginFactory, State } from "@real-router/core";
2
-
3
2
  //#region src/types.d.ts
4
3
  /**
5
4
  * Navigation plugin configuration.
@@ -63,6 +62,24 @@ interface NavigationMeta {
63
62
  sourceElement: Element | null;
64
63
  }
65
64
  //#endregion
65
+ //#region ../../shared/browser-env/url-context.d.ts
66
+ /**
67
+ * URL fragment ("hash") shared layer (#532).
68
+ *
69
+ * Both URL plugins (navigation-plugin, browser-plugin) claim the `"url"`
70
+ * `state.context` namespace and write `UrlContext` on every transition.
71
+ * Mutually exclusive at runtime — only one URL plugin is installed per router.
72
+ *
73
+ * Hash form: decoded, no leading "#" — symmetric to `params` (no leading "?").
74
+ * Encoding to/from URL form happens at the boundary (URL build / URL parse).
75
+ */
76
+ interface UrlContext {
77
+ /** Decoded fragment, no leading "#". Empty string when URL has no fragment. */
78
+ hash: string;
79
+ /** Whether `hash` differs from the previous transition's `state.context.url.hash`. */
80
+ hashChanged: boolean;
81
+ }
82
+ //#endregion
66
83
  //#region src/factory.d.ts
67
84
  declare function navigationPluginFactory(opts?: Partial<NavigationPluginOptions>, browser?: NavigationBrowser): PluginFactory;
68
85
  //#endregion
@@ -70,22 +87,44 @@ declare function navigationPluginFactory(opts?: Partial<NavigationPluginOptions>
70
87
  declare module "@real-router/types" {
71
88
  interface StateContext {
72
89
  navigation?: NavigationMeta;
90
+ /**
91
+ * URL fragment ("hash") layer state (#532). Populated by both URL plugins
92
+ * (navigation-plugin, browser-plugin) — they are mutually exclusive at
93
+ * runtime, so only one writes to this namespace.
94
+ */
95
+ url?: UrlContext;
96
+ }
97
+ interface NavigationOptions {
98
+ /**
99
+ * URL fragment override (decoded, no leading "#") (#532).
100
+ * Tri-state: `undefined` → preserve current; `""` → clear; non-empty → set.
101
+ */
102
+ hash?: string;
103
+ /**
104
+ * @internal — set by URL plugins on hash-only browser-driven navigation.
105
+ * Subscribers should branch on `state.context.url.hashChanged` instead.
106
+ */
107
+ hashChange?: boolean;
73
108
  }
74
109
  }
75
110
  declare module "@real-router/core" {
76
111
  interface Router {
77
- buildUrl: (name: string, params?: Params) => string;
78
- matchUrl: (url: string) => State | undefined;
79
- replaceHistoryState: (name: string, params?: Params) => void;
80
- peekBack: () => State | undefined;
81
- peekForward: () => State | undefined;
82
- hasVisited: (routeName: string) => boolean;
83
- getVisitedRoutes: () => string[];
84
- getRouteVisitCount: (routeName: string) => number;
85
- traverseToLast: (routeName: string) => Promise<State>;
86
- canGoBack: () => boolean;
87
- canGoForward: () => boolean;
88
- canGoBackTo: (routeName: string) => boolean;
112
+ buildUrl(name: string, params?: Params, options?: {
113
+ hash?: string;
114
+ }): string;
115
+ matchUrl(url: string): State | undefined;
116
+ replaceHistoryState(name: string, params?: Params, options?: {
117
+ hash?: string;
118
+ }): void;
119
+ peekBack(): State | undefined;
120
+ peekForward(): State | undefined;
121
+ hasVisited(routeName: string): boolean;
122
+ getVisitedRoutes(): string[];
123
+ getRouteVisitCount(routeName: string): number;
124
+ traverseToLast(routeName: string): Promise<State>;
125
+ canGoBack(): boolean;
126
+ canGoForward(): boolean;
127
+ canGoBackTo(routeName: string): boolean;
89
128
  start(path?: string): Promise<State>;
90
129
  }
91
130
  } //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/types.ts","../../src/factory.ts","../../src/index.ts"],"mappings":";;;;;;AAIA;UAAiB,uBAAA;;;;AAoBjB;;EAdE,eAAA;EAwBgC;;;;;EAjBhC,IAAA;AAAA;;;;;UAOe,iBAAA;EACf,WAAA;EACA,OAAA;EACA,QAAA,GACE,GAAA,UACA,OAAA;IAAW,KAAA;IAAgB,OAAA;EAAA;EAE7B,YAAA,GAAe,KAAA,WAAgB,GAAA;EAC/B,kBAAA,GAAqB,OAAA;IAAW,KAAA;EAAA;EAChC,UAAA,GAAa,GAAA;EACb,mBAAA,GAAsB,EAAA,GAAK,GAAA,EAAK,aAAA;EAChC,OAAA,QAAe,sBAAA;EACf,YAAA,EAAc,sBAAA;EADC;;;;;EAOf,iBAAA,QAAyB,cAAA;AAAA;AAAA,KAWf,mBAAA;;;;;UAMK,cAAA;EAQJ;EANX,cAAA;EAQe;EANf,aAAA;EAMsB;EAJtB,IAAA;;EAEA,SAAA,EAAW,mBAAA;ECnDG;EDqDd,aAAA,EAAe,OAAA;AAAA;;;iBCrDD,uBAAA,CACd,IAAA,GAAO,OAAA,CAAQ,uBAAA,GACf,OAAA,GAAU,iBAAA,GACT,aAAA;;;;YCLS,YAAA;IACR,UAAA,GAJa,cAAA;EAAA;AAAA;AAAA;EAAA,UASL,MAAA;IACR,QAAA,GAAW,IAAA,UAAc,MAAA,GAAS,MAAA;IAClC,QAAA,GAAW,GAAA,aAAgB,KAAA;IAC3B,mBAAA,GAAsB,IAAA,UAAc,MAAA,GAAS,MAAA;IAC7C,QAAA,QAAgB,KAAA;IAChB,WAAA,QAAmB,KAAA;IACnB,UAAA,GAAa,SAAA;IACb,gBAAA;IACA,kBAAA,GAAqB,SAAA;IACrB,cAAA,GAAiB,SAAA,aAAsB,OAAA,CAAQ,KAAA;IAC/C,SAAA;IACA,YAAA;IACA,WAAA,GAAc,SAAA;IACd,KAAA,CAAM,IAAA,YAAgB,OAAA,CAAQ,KAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/types.ts","../../../../shared/browser-env/url-context.ts","../../src/factory.ts","../../src/index.ts"],"mappings":";;;;;;UAIiB,uBAAA;EAAuB;;;;AAoBxC;EAdE,eAAA;;;;;;EAOA,IAAA;AAAA;;;;;UAOe,iBAAA;EACf,WAAA;EACA,OAAA;EACA,QAAA,GACE,GAAA,UACA,OAAA;IAAW,KAAA;IAAgB,OAAA;EAAA;EAE7B,YAAA,GAAe,KAAA,WAAgB,GAAA;EAC/B,kBAAA,GAAqB,OAAA;IAAW,KAAA;EAAA;EAChC,UAAA,GAAa,GAAA;EACb,mBAAA,GAAsB,EAAA,GAAK,GAAA,EAAK,aAAA;EAChC,OAAA,QAAe,sBAAA;EACf,YAAA,EAAc,sBAAA;EADd;;;;;EAOA,iBAAA,QAAyB,cAAA;AAAA;AAAA,KAWf,mBAAA;;;;;UAMK,cAAA;EAQf;EANA,cAAA;EAQA;EANA,aAAA;EAMsB;EAJtB,IAAA;;EAEA,SAAA,EAAW,mBAAA;;EAEX,aAAA,EAAe,OAAA;AAAA;;;;;;;AAjEjB;;;;;AAoBA;UCbiB,UAAA;;EAEf,IAAA;EDsBe;ECpBf,WAAA;AAAA;;;iBCCc,uBAAA,CACd,IAAA,GAAO,OAAA,CAAQ,uBAAA,GACf,OAAA,GAAU,iBAAA,GACT,aAAA;;;;YCLS,YAAA;IACR,UAAA,GAJa,cAAA;IHPuB;;;AAoBxC;;IGHI,GAAA,GAN6C,UAAA;EAAA;EAAA,UASrC,iBAAA;IHYI;;;;IGPZ,IAAA;IHHF;;;;IGQE,UAAA;EAAA;AAAA;AAAA;EAAA,UAKQ,MAAA;IACR,QAAA,CACE,IAAA,UACA,MAAA,GAAS,MAAA,EACT,OAAA;MAAY,IAAA;IAAA;IAEd,QAAA,CAAS,GAAA,WAAc,KAAA;IACvB,mBAAA,CACE,IAAA,UACA,MAAA,GAAS,MAAA,EACT,OAAA;MAAY,IAAA;IAAA;IAEd,QAAA,IAAY,KAAA;IACZ,WAAA,IAAe,KAAA;IACf,UAAA,CAAW,SAAA;IACX,gBAAA;IACA,kBAAA,CAAmB,SAAA;IACnB,cAAA,CAAe,SAAA,WAAoB,OAAA,CAAQ,KAAA;IAC3C,SAAA;IACA,YAAA;IACA,WAAA,CAAY,SAAA;IACZ,KAAA,CAAM,IAAA,YAAgB,OAAA,CAAQ,KAAA;EAAA;AAAA"}
package/dist/cjs/index.js CHANGED
@@ -1,2 +1,2 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@real-router/core/api`),t=require(`@real-router/core`);const n=()=>globalThis.window!==void 0&&!!globalThis.history;function r(e){if(!e)return e;let t=e.replaceAll(/\/+/g,`/`);return t.startsWith(`/`)||(t=`/${t}`),t.length>1&&t.endsWith(`/`)&&(t=t.slice(0,-1)),t===`/`?``:t}const i=e=>{try{return encodeURI(decodeURI(e))}catch(t){return console.warn(`[browser-env] Could not encode path "${e}"`,t),e}},a=e=>{let t=!1;return n=>{t||=(console.warn(`[browser-env] Browser API is running in a non-browser environment (context: "${e}"). Method "${n}" is a no-op. This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`),!0)}};function o(e,t,n){return r=>{if(r)for(let i of Object.keys(r)){if(!(i in e))continue;let a=r[i];if(a===void 0)continue;let o=typeof e[i],s=typeof a;if(s!==o)throw Error(`[${t}] Invalid type for '${i}': expected ${o}, got ${s}`);let c=n?.[i];if(c){let e=c.validate(a);if(e!==null)throw Error(`[${t}] Invalid '${i}': ${e}`)}}}}const s=/[\u0000-\u001F\u007F]/,c={validate:e=>s.test(e)?`must not contain control characters`:e.split(`/`).includes(`..`)?`must not contain '..' segments`:null};function l(e,t){return e.addInterceptor(`start`,(e,n)=>e(n??t.getLocation()))}function u(e,t,n,r,i=!0){let a={name:``,params:{},path:``};return(o,s={})=>{let c=e.buildState(o,s);if(!c)throw Error(`[real-router] Cannot replace state: route "${o}" is not found`);let l=e.makeState(c.name,c.params,t.buildPath(c.name,c.params),{params:c.meta}),u=i?n.getHash():``,d=r(o,s)+u;a.name=l.name,a.params=l.params,a.path=l.path,n.replaceState(a,d)}}function d(e,t,n){return e.replace===!0?!0:n?!!e.reload&&t.path===n.path:e.replace!==!1}function f(e){let t=e,n=t.indexOf(`://`);if(n!==-1){let e=n+3,r=t.length;for(let n=e;n<t.length;n++){let e=t[n];if(e===`/`||e===`?`||e===`#`){r=n;break}}t=r===t.length?`/`:t.slice(r),(t.startsWith(`?`)||t.startsWith(`#`))&&(t=`/${t}`)}let r=t.indexOf(`#`),i=r===-1?``:t.slice(r),a=r===-1?t:t.slice(0,r),o=a.indexOf(`?`),s=o===-1?``:a.slice(o);return{pathname:o===-1?a:a.slice(0,o),search:s,hash:i}}function p(e,t){if(!e)return`/`;if(t&&(e===t||e.startsWith(`${t}/`))){let n=e.slice(t.length);return n.startsWith(`/`)?n:`/${n}`}return e.startsWith(`/`)?e:`/${e}`}function m(e,t){return e?t?e===`/`?t:e.startsWith(`/`)?`${t}${e}`:`${t}/${e}`:e.startsWith(`/`)?e:`/${e}`:t}function h(e,t){let n=f(e);return p(n.pathname,t)+n.search}function g(e,t){return h(e,t)}const _={forceDeactivate:!1,base:``};function v(e){let t=globalThis.navigation;return{getLocation:()=>i(p(globalThis.location.pathname,e))+globalThis.location.search,getHash:()=>globalThis.location.hash,navigate:(e,n)=>{t.navigate(e,n)},replaceState:(e,n)=>{t.navigate(n,{state:e,history:`replace`})},updateCurrentEntry:e=>{t.updateCurrentEntry(e)},traverseTo:e=>{t.traverseTo(e)},addNavigateListener:e=>(t.addEventListener(`navigate`,e),()=>{t.removeEventListener(`navigate`,e)}),entries:()=>t.entries(),get currentEntry(){return t.currentEntry},getActivationType:()=>t.activation?.navigationType}}function y(e,t){let n=e=>{t.current=!0;try{return e()}finally{t.current=!1}};return{getLocation:()=>e.getLocation(),getHash:()=>e.getHash(),navigate:(t,r)=>{n(()=>{e.navigate(t,r)})},replaceState:(t,r)=>{n(()=>{e.replaceState(t,r)})},updateCurrentEntry:t=>{n(()=>{e.updateCurrentEntry(t)})},traverseTo:t=>{n(()=>{e.traverseTo(t)})},addNavigateListener:t=>e.addNavigateListener(t),entries:()=>e.entries(),get currentEntry(){return e.currentEntry},getActivationType:()=>e.getActivationType()}}function b(e,t,n,r){if(!e)throw Error(`No history entry for route "${t}"`);if(!e.url)throw Error(`No matching route for entry URL "${e.url}"`);let i=g(e.url,r),a=n.matchPath(i);if(!a)throw Error(`No matching route for entry URL "${e.url}"`);return{entry:e,matchedState:a}}function x(e,t,n){if(e?.url)return t.matchPath(g(e.url,n))??void 0}function S(e,t,n,r){let i=e.currentEntry?.index;if(i!=null)return x(e.entries()[i+r],t,n)}function C(e,t,n){return S(e,t,n,-1)}function w(e,t,n){return S(e,t,n,1)}function T(e,t,n,r){return e.entries().some(e=>x(e,t,n)?.name===r)}function E(e,t,n){let r=new Set;for(let i of e.entries()){let e=x(i,t,n);e&&r.add(e.name)}return[...r]}function D(e,t,n,r){let i=0;for(let a of e.entries())x(a,t,n)?.name===r&&i++;return i}function O(e,t,n,r,i){for(let a=e.length-1;a>=0;a--){let o=e[a];if(o.key!==i&&x(o,n,r)?.name===t)return o}}function k(e){let t=e.currentEntry?.index;return t!=null&&t>0}function A(e){let t=e.currentEntry?.index;return t==null?!1:t<e.entries().length-1}function j(e,t,n,r){let i=e.currentEntry?.index;if(i==null)return!1;let a=e.entries();for(let e=i-1;e>=0;e--)if(x(a[e],t,n)?.name===r)return!0;return!1}function M(e,t,n){return e===`traverse`?t===n?`unknown`:t>n?`forward`:`back`:e===`push`?`forward`:`unknown`}function N(e){let{router:n,api:r,browser:i,isSyncingFromRouter:a,base:o,transitionOptions:s}=e,{allowNotFound:c}=r.getOptions();return function(l){if(!l.canIntercept||!n.isActive())return;if(a()){l.intercept({handler:async()=>{}});return}let u=h(l.destination.url,o),d=r.matchPath(u),f=l.navigationType,p=i.currentEntry?.index??-1;e.setCapturedMeta({navigationType:f,userInitiated:l.userInitiated,info:l.info,direction:M(f,l.destination.index,p),sourceElement:l.sourceElement??null});let m=async e=>{try{await e()}catch(e){if(!(e instanceof t.RouterError)){P(e,n,i);return}if(e.code===t.errorCodes.TRANSITION_CANCELLED||e.code===t.errorCodes.SAME_STATES)return;F(n,i)}};d?l.intercept({handler:()=>m(()=>r.navigateToState(d,{...s,signal:l.signal}))}):c?l.intercept({handler:()=>{n.navigateToNotFound(u)}}):l.intercept({handler:async()=>{let e=new t.RouterError(t.errorCodes.ROUTE_NOT_FOUND,{path:u});throw r.emitTransitionError(e),e}})}}function P(e,t,n){console.error(`[navigation-plugin] Critical error in navigate handler`,e),F(t,n)}function F(e,t){try{let n=e.getState();if(n){let r=e.buildUrl(n.name,n.params);t.navigate(r,{state:{name:n.name,params:n.params,path:n.path},history:`replace`})}}catch(e){console.error(`[navigation-plugin] Failed to sync URL to router state`,e)}}function I(e,t,n){return e.reload&&t.path===n?.path?`reload`:d(e,t,n)?`replace`:`push`}var L=class{#e;#t;#n;#r;#i;#a;#o;#s;#c={current:!1};#l;#u;constructor(e,t,n,r,i,a){this.#e=e,this.#t=t,this.#n=n,this.#r=y(r,this.#c),this.#o=t.claimContextNamespace(`navigation`),this.#i=l(t,this.#r);let o=this.#r.getActivationType();o&&(this.#l={navigationType:o,userInitiated:!1,direction:o===`push`?`forward`:`unknown`,sourceElement:null});let s=(t,r)=>m(e.buildPath(t,r),n.base);this.#a=t.extendRouter({buildUrl:s,matchUrl:e=>t.matchPath(h(e,n.base))??void 0,replaceHistoryState:u(t,e,this.#r,s),peekBack:()=>C(this.#r,t,n.base),peekForward:()=>w(this.#r,t,n.base),hasVisited:e=>T(this.#r,t,n.base,e),getVisitedRoutes:()=>E(this.#r,t,n.base),getRouteVisitCount:e=>D(this.#r,t,n.base,e),traverseToLast:e=>this.traverseToLast(e),canGoBack:()=>k(this.#r),canGoForward:()=>A(this.#r),canGoBackTo:e=>j(this.#r,t,n.base,e)});let c=N({router:e,api:t,browser:this.#r,isSyncingFromRouter:()=>this.#c.current,setCapturedMeta:e=>{this.#l=e},base:n.base,transitionOptions:i});this.#s=R({browser:this.#r,shared:a,handler:c,removeStartInterceptor:this.#i,removeExtensions:this.#a,releaseClaim:()=>{this.#o.release()}})}async traverseToLast(e){let t=this.#r.entries(),n=this.#r.currentEntry?.key,{entry:r,matchedState:i}=b(O(t,e,this.#t,this.#n.base,n),e,this.#t,this.#n.base),a=this.#r.currentEntry;if(!a)throw Error(`[navigation-plugin] Cannot determine direction for traverseToLast("${e}"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`);return this.#l={navigationType:`traverse`,userInitiated:!1,direction:r.index>a.index?`forward`:`back`,sourceElement:null},this.#u=r.key,this.#e.navigate(i.name,i.params)}getPlugin(){return{...this.#s,onTransitionStart:e=>{this.#l&&this.#o.write(e,this.#l)},onTransitionSuccess:(e,n,r)=>{if(!this.#l){let t=I(r,e,n);this.#l={navigationType:t,userInitiated:!1,direction:t===`push`?`forward`:`unknown`,sourceElement:null}}let i=Object.freeze(this.#l);this.#o.write(e,i),this.#l=void 0;let a=this.#u;if(this.#u=void 0,a)this.#r.traverseTo(a);else{let r=m(e.path,this.#n.base),a=!n||n.path===e.path?this.#r.getHash():``,o=a?r+a:r,s={name:e.name,params:e.params,path:e.path};if(e.name===t.UNKNOWN_ROUTE)this.#r.updateCurrentEntry({state:s});else{let e=i.navigationType!==`push`;this.#r.navigate(o,{state:s,history:e?`replace`:`push`})}}},onTransitionCancel:()=>{this.#l=void 0,this.#u=void 0},onTransitionError:()=>{this.#l=void 0,this.#u=void 0}}}};function R(e){return{onStart(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=e.browser.addNavigateListener(e.handler)},onStop(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=void 0},teardown(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=void 0,e.removeStartInterceptor(),e.removeExtensions(),e.releaseClaim()}}}const z=()=>{},B=e=>{let t=a(e);return{getLocation:()=>(t(`getLocation`),`/`),getHash:()=>(t(`getHash`),``),navigate:()=>{t(`navigate`)},replaceState:()=>{t(`replaceState`)},updateCurrentEntry:()=>{t(`updateCurrentEntry`)},traverseTo:()=>{t(`traverseTo`)},addNavigateListener:()=>(t(`addNavigateListener`),z),entries:()=>(t(`entries`),[]),currentEntry:null,getActivationType:()=>void 0}},V=o(_,`navigation-plugin`,{base:c});function H(t,i){if(!i&&n()&&!(`navigation`in globalThis))throw Error(`[navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.`);V(t);let a={..._,...t};a.base=r(a.base);let o=i??U(a.base),s={forceDeactivate:a.forceDeactivate,source:`navigate`,replace:!0},c={removeNavigateListener:void 0};return t=>new L(t,(0,e.getPluginApi)(t),a,o,s,c).getPlugin()}function U(e){return`navigation`in globalThis?v(e):B(`navigation-plugin`)}exports.navigationPluginFactory=H;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@real-router/core/api`),t=require(`@real-router/core`);const n=()=>globalThis.window!==void 0&&!!globalThis.history;function r(e){if(!e)return e;let t=e.replaceAll(/\/+/g,`/`);return t.startsWith(`/`)||(t=`/${t}`),t.length>1&&t.endsWith(`/`)&&(t=t.slice(0,-1)),t===`/`?``:t}const i=e=>{try{return encodeURI(decodeURI(e))}catch(t){return console.warn(`[browser-env] Could not encode path "${e}"`,t),e}},a=e=>{let t=!1;return n=>{t||=(console.warn(`[browser-env] Browser API is running in a non-browser environment (context: "${e}"). Method "${n}" is a no-op. This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`),!0)}};function o(e,t,n){return r=>{if(r)for(let i of Object.keys(r)){if(!(i in e))continue;let a=r[i];if(a===void 0)continue;let o=typeof e[i],s=typeof a;if(s!==o)throw Error(`[${t}] Invalid type for '${i}': expected ${o}, got ${s}`);let c=n?.[i];if(c){let e=c.validate(a);if(e!==null)throw Error(`[${t}] Invalid '${i}': ${e}`)}}}}const s=/[\u0000-\u001F\u007F]/,c={validate:e=>s.test(e)?`must not contain control characters`:e.split(`/`).includes(`..`)?`must not contain '..' segments`:null};function l(e){return encodeURI(e).replaceAll(`#`,`%23`)}function u(e){try{return decodeURIComponent(e)}catch{return e}}function d(e){return u(e.startsWith(`#`)?e.slice(1):e)}function f(e){let t=e.getHash();return t?u(t.startsWith(`#`)?t.slice(1):t):``}function p(e,t){return e.addInterceptor(`start`,(e,n)=>e(n??t.getLocation()))}function m(e,t,n,r,i=!0){let a={name:``,params:{},path:``};return(o,s={},c)=>{let u=e.buildState(o,s);if(!u)throw Error(`[real-router] Cannot replace state: route "${o}" is not found`);let f=e.makeState(u.name,u.params,t.buildPath(u.name,u.params),{params:u.meta}),p;if(c?.hash!==void 0){let e=d(c.hash);p=e?`#${l(e)}`:``}else p=i?n.getHash():``;let m=r(o,s)+p;a.name=f.name,a.params=f.params,a.path=f.path,n.replaceState(a,m)}}function h(e,t,n){return e.replace===!0?!0:n?!!e.reload&&t.path===n.path:e.replace!==!1}function g(e){let t=e,n=t.indexOf(`://`);if(n!==-1){let e=n+3,r=t.length;for(let n=e;n<t.length;n++){let e=t[n];if(e===`/`||e===`?`||e===`#`){r=n;break}}t=r===t.length?`/`:t.slice(r),(t.startsWith(`?`)||t.startsWith(`#`))&&(t=`/${t}`)}let r=t.indexOf(`#`),i=r===-1?``:t.slice(r),a=r===-1?t:t.slice(0,r),o=a.indexOf(`?`),s=o===-1?``:a.slice(o);return{pathname:o===-1?a:a.slice(0,o),search:s,hash:i}}function _(e,t){if(!e)return`/`;if(t&&(e===t||e.startsWith(`${t}/`))){let n=e.slice(t.length);return n.startsWith(`/`)?n:`/${n}`}return e.startsWith(`/`)?e:`/${e}`}function v(e,t){return e?t?e===`/`?t:e.startsWith(`/`)?`${t}${e}`:`${t}/${e}`:e.startsWith(`/`)?e:`/${e}`:t}function y(e,t){let n=g(e);return _(n.pathname,t)+n.search}function b(e,t){let n=g(e);return{path:_(n.pathname,t)+n.search,hash:n.hash?u(n.hash.slice(1)):``}}function x(e,t){return y(e,t)}const S={forceDeactivate:!1,base:``};function C(e){let t=globalThis.navigation;return{getLocation:()=>i(_(globalThis.location.pathname,e))+globalThis.location.search,getHash:()=>globalThis.location.hash,navigate:(e,n)=>{t.navigate(e,n)},replaceState:(e,n)=>{t.navigate(n,{state:e,history:`replace`})},updateCurrentEntry:e=>{t.updateCurrentEntry(e)},traverseTo:e=>{t.traverseTo(e)},addNavigateListener:e=>(t.addEventListener(`navigate`,e),()=>{t.removeEventListener(`navigate`,e)}),entries:()=>t.entries(),get currentEntry(){return t.currentEntry},getActivationType:()=>t.activation?.navigationType}}function w(e,t){let n=e=>{t.current=!0;try{return e()}finally{t.current=!1}};return{getLocation:()=>e.getLocation(),getHash:()=>e.getHash(),navigate:(t,r)=>{n(()=>{e.navigate(t,r)})},replaceState:(t,r)=>{n(()=>{e.replaceState(t,r)})},updateCurrentEntry:t=>{n(()=>{e.updateCurrentEntry(t)})},traverseTo:t=>{n(()=>{e.traverseTo(t)})},addNavigateListener:t=>e.addNavigateListener(t),entries:()=>e.entries(),get currentEntry(){return e.currentEntry},getActivationType:()=>e.getActivationType()}}function T(e,t,n,r){if(!e)throw Error(`No history entry for route "${t}"`);if(!e.url)throw Error(`No matching route for entry URL "${e.url}"`);let i=x(e.url,r),a=n.matchPath(i);if(!a)throw Error(`No matching route for entry URL "${e.url}"`);return{entry:e,matchedState:a}}function E(e,t,n){if(e?.url)return t.matchPath(x(e.url,n))??void 0}function D(e,t,n,r){let i=e.currentEntry?.index;if(i!=null)return E(e.entries()[i+r],t,n)}function O(e,t,n){return D(e,t,n,-1)}function k(e,t,n){return D(e,t,n,1)}function A(e,t,n,r){return e.entries().some(e=>E(e,t,n)?.name===r)}function j(e,t,n){let r=new Set;for(let i of e.entries()){let e=E(i,t,n);e&&r.add(e.name)}return[...r]}function M(e,t,n,r){let i=0;for(let a of e.entries())E(a,t,n)?.name===r&&i++;return i}function N(e,t,n,r,i){for(let a=e.length-1;a>=0;a--){let o=e[a];if(o.key!==i&&E(o,n,r)?.name===t)return o}}function P(e){let t=e.currentEntry?.index;return t!=null&&t>0}function F(e){let t=e.currentEntry?.index;return t==null?!1:t<e.entries().length-1}function I(e,t,n,r){let i=e.currentEntry?.index;if(i==null)return!1;let a=e.entries();for(let e=i-1;e>=0;e--)if(E(a[e],t,n)?.name===r)return!0;return!1}function L(e,t,n){return e===`traverse`?t===n?`unknown`:t>n?`forward`:`back`:e===`push`?`forward`:`unknown`}function R(e){let{router:n,api:r,browser:i,isSyncingFromRouter:a,base:o,transitionOptions:s}=e,{allowNotFound:c}=r.getOptions();return function(l){if(!l.canIntercept||!n.isActive())return;if(a()){l.intercept({handler:async()=>{}});return}let{path:u,hash:d}=b(l.destination.url,o),f=r.matchPath(u),p=l.navigationType,m=i.currentEntry?.index??-1;e.setCapturedMeta({navigationType:p,userInitiated:l.userInitiated,info:l.info,direction:L(p,l.destination.index,m),sourceElement:l.sourceElement??null});let h=async e=>{try{await e()}catch(e){if(!(e instanceof t.RouterError)){z(e,n,i);return}if(e.code===t.errorCodes.TRANSITION_CANCELLED||e.code===t.errorCodes.SAME_STATES)return;B(n,i)}};f?l.intercept({handler:()=>h(()=>r.navigateToState(f,{...s,hash:d,...l.hashChange?{force:!0,hashChange:!0}:{},signal:l.signal}))}):c?l.intercept({handler:()=>{n.navigateToNotFound(u)}}):l.intercept({handler:async()=>{let e=new t.RouterError(t.errorCodes.ROUTE_NOT_FOUND,{path:u});throw r.emitTransitionError(e),e}})}}function z(e,t,n){console.error(`[navigation-plugin] Critical error in navigate handler`,e),B(t,n)}function B(e,t){try{let n=e.getState();if(n){let r=n.context?.url?.hash,i=e.buildUrl(n.name,n.params,r?{hash:r}:void 0);t.navigate(i,{state:{name:n.name,params:n.params,path:n.path},history:`replace`})}}catch(e){console.error(`[navigation-plugin] Failed to sync URL to router state`,e)}}function V(e,t,n){return e.reload&&t.path===n?.path?`reload`:h(e,t,n)?`replace`:`push`}var H=class{#e;#t;#n;#r;#i;#a;#o;#s;#c;#l={current:!1};#u;#d;constructor(e,t,n,r,i,a){this.#e=e,this.#t=t,this.#n=n,this.#r=w(r,this.#l),this.#o=t.claimContextNamespace(`navigation`),this.#s=t.claimContextNamespace(`url`),this.#i=p(t,this.#r);let o=this.#r.getActivationType();o&&(this.#u={navigationType:o,userInitiated:!1,direction:o===`push`?`forward`:`unknown`,sourceElement:null});let s=(t,r,i)=>{let a=v(e.buildPath(t,r),n.base);if(i?.hash===void 0)return a;let o=d(i.hash);return o?`${a}#${l(o)}`:a};this.#a=t.extendRouter({buildUrl:s,matchUrl:e=>t.matchPath(y(e,n.base))??void 0,replaceHistoryState:m(t,e,this.#r,s),peekBack:()=>O(this.#r,t,n.base),peekForward:()=>k(this.#r,t,n.base),hasVisited:e=>A(this.#r,t,n.base,e),getVisitedRoutes:()=>j(this.#r,t,n.base),getRouteVisitCount:e=>M(this.#r,t,n.base,e),traverseToLast:e=>this.traverseToLast(e),canGoBack:()=>P(this.#r),canGoForward:()=>F(this.#r),canGoBackTo:e=>I(this.#r,t,n.base,e)});let c=R({router:e,api:t,browser:this.#r,isSyncingFromRouter:()=>this.#l.current,setCapturedMeta:e=>{this.#u=e},base:n.base,transitionOptions:i});this.#c=U({browser:this.#r,shared:a,handler:c,removeStartInterceptor:this.#i,removeExtensions:this.#a,releaseClaim:()=>{this.#o.release(),this.#s.release()}})}async traverseToLast(e){let t=this.#r.entries(),n=this.#r.currentEntry?.key,{entry:r,matchedState:i}=T(N(t,e,this.#t,this.#n.base,n),e,this.#t,this.#n.base),a=this.#r.currentEntry;if(!a)throw Error(`[navigation-plugin] Cannot determine direction for traverseToLast("${e}"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`);return this.#u={navigationType:`traverse`,userInitiated:!1,direction:r.index>a.index?`forward`:`back`,sourceElement:null},this.#d=r.key,this.#e.navigate(i.name,i.params)}getPlugin(){return{...this.#c,onTransitionStart:e=>{this.#u&&this.#o.write(e,this.#u)},onTransitionSuccess:(e,n,r)=>{if(!this.#u){let t=V(r,e,n);this.#u={navigationType:t,userInitiated:!1,direction:t===`push`?`forward`:`unknown`,sourceElement:null}}let i=Object.freeze(this.#u);this.#o.write(e,i),this.#u=void 0;let a=this.#d;if(this.#d=void 0,a)this.#r.traverseTo(a);else{let a=f(this.#r),o=(n?.context)?.url?.hash??``,s=r.hash===void 0?a:d(r.hash);this.#s.write(e,Object.freeze({hash:s,hashChanged:r.hashChange??s!==o}));let c=v(e.path,this.#n.base),u=s?`${c}#${l(s)}`:c,p={name:e.name,params:e.params,path:e.path};if(e.name===t.UNKNOWN_ROUTE)this.#r.updateCurrentEntry({state:p});else{let e=i.navigationType!==`push`;this.#r.navigate(u,{state:p,history:e?`replace`:`push`})}}},onTransitionCancel:()=>{this.#u=void 0,this.#d=void 0},onTransitionError:()=>{this.#u=void 0,this.#d=void 0}}}};function U(e){return{onStart(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=e.browser.addNavigateListener(e.handler)},onStop(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=void 0},teardown(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=void 0,e.removeStartInterceptor(),e.removeExtensions(),e.releaseClaim()}}}const W=()=>{},G=e=>{let t=a(e);return{getLocation:()=>(t(`getLocation`),`/`),getHash:()=>(t(`getHash`),``),navigate:()=>{t(`navigate`)},replaceState:()=>{t(`replaceState`)},updateCurrentEntry:()=>{t(`updateCurrentEntry`)},traverseTo:()=>{t(`traverseTo`)},addNavigateListener:()=>(t(`addNavigateListener`),W),entries:()=>(t(`entries`),[]),currentEntry:null,getActivationType:()=>void 0}},K=o(S,`navigation-plugin`,{base:c});function q(t,i){if(!i&&n()&&!(`navigation`in globalThis))throw Error(`[navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.`);K(t);let a={...S,...t};a.base=r(a.base);let o=i??J(a.base),s={forceDeactivate:a.forceDeactivate,source:`navigate`,replace:!0},c={removeNavigateListener:void 0};return t=>new H(t,(0,e.getPluginApi)(t),a,o,s,c).getPlugin()}function J(e){return`navigation`in globalThis?C(e):G(`navigation-plugin`)}exports.navigationPluginFactory=q;
2
2
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["RouterError","errorCodes","#router","#api","#options","#browser","#removeStartInterceptor","#removeExtensions","#claim","#lifecycle","#syncing","#capturedMeta","#pendingTraverseKey","UNKNOWN_ROUTE"],"sources":["../../../../shared/browser-env/detect.ts","../../../../shared/browser-env/utils.ts","../../../../shared/browser-env/ssr-fallback.ts","../../../../shared/browser-env/validation.ts","../../../../shared/browser-env/plugin-utils.ts","../../../../shared/browser-env/url-parsing.ts","../../../../shared/browser-env/url-utils.ts","../../src/constants.ts","../../src/navigation-browser.ts","../../src/history-extensions.ts","../../src/navigate-handler.ts","../../src/plugin.ts","../../src/ssr-fallback.ts","../../src/validation.ts","../../src/factory.ts"],"sourcesContent":["export const isBrowserEnvironment = (): boolean =>\n typeof globalThis.window !== \"undefined\" && !!globalThis.history;\n","/**\n * Normalizes base path to canonical form: leading slash, no trailing slash,\n * no repeated slashes. Isolated \"/\" collapses to \"\".\n *\n * @example\n * normalizeBase(\"app\") // \"/app\"\n * normalizeBase(\"/app/\") // \"/app\"\n * normalizeBase(\"//app//\") // \"/app\"\n * normalizeBase(\"\") // \"\"\n * normalizeBase(\"/\") // \"\"\n */\nexport function normalizeBase(base: string): string {\n if (!base) {\n return base;\n }\n\n let result = base.replaceAll(/\\/+/g, \"/\");\n\n if (!result.startsWith(\"/\")) {\n result = `/${result}`;\n }\n\n if (result.length > 1 && result.endsWith(\"/\")) {\n result = result.slice(0, -1);\n }\n\n return result === \"/\" ? \"\" : result;\n}\n\nexport const safelyEncodePath = (path: string): string => {\n try {\n return encodeURI(decodeURI(path));\n } catch (error) {\n console.warn(`[browser-env] Could not encode path \"${path}\"`, error);\n\n return path;\n }\n};\n","import type { HistoryBrowser } from \"./types.js\";\n\nconst NOOP = (): void => {};\n\nexport const createWarnOnce = (context: string) => {\n let hasWarned = false;\n\n return (method: string): void => {\n if (!hasWarned) {\n console.warn(\n `[browser-env] Browser API is running in a non-browser environment (context: \"${context}\"). ` +\n `Method \"${method}\" is a no-op. ` +\n `This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`,\n );\n hasWarned = true;\n }\n };\n};\n\nexport const createHistoryFallbackBrowser = (\n context: string,\n): HistoryBrowser => {\n const warnOnce = createWarnOnce(context);\n\n return {\n pushState: () => {\n warnOnce(\"pushState\");\n },\n replaceState: () => {\n warnOnce(\"replaceState\");\n },\n addPopstateListener: () => {\n warnOnce(\"addPopstateListener\");\n\n return NOOP;\n },\n getHash: () => {\n warnOnce(\"getHash\");\n\n return \"\";\n },\n };\n};\n","export interface OptionRule<T> {\n validate: (value: T) => string | null;\n}\n\nexport type OptionRules<T extends object> = {\n [K in keyof T]?: OptionRule<NonNullable<T[K]>>;\n};\n\nexport function createOptionsValidator<T extends object>(\n defaults: Required<T>,\n loggerContext: string,\n rules?: OptionRules<T>,\n): (opts: Partial<T> | undefined) => void {\n return (opts) => {\n if (!opts) {\n return;\n }\n\n for (const key of Object.keys(opts)) {\n if (!(key in defaults)) {\n continue;\n }\n\n const value = opts[key as keyof typeof opts];\n\n if (value === undefined) {\n continue;\n }\n\n const expected = typeof defaults[key as keyof typeof defaults];\n const actual = typeof value;\n\n if (actual !== expected) {\n throw new Error(\n `[${loggerContext}] Invalid type for '${key}': expected ${expected}, got ${actual}`,\n );\n }\n\n const rule = rules?.[key as keyof T];\n\n if (rule) {\n const msg = (rule.validate as (input: unknown) => string | null)(value);\n\n if (msg !== null) {\n throw new Error(`[${loggerContext}] Invalid '${key}': ${msg}`);\n }\n }\n }\n };\n}\n\n// eslint-disable-next-line no-control-regex -- control characters are exactly what this rule rejects\nconst CONTROL_CHARS = /[\\u0000-\\u001F\\u007F]/;\n\nexport const safeBaseRule: OptionRule<string> = {\n validate: (value) => {\n if (CONTROL_CHARS.test(value)) {\n return \"must not contain control characters\";\n }\n\n if (value.split(\"/\").includes(\"..\")) {\n return \"must not contain '..' segments\";\n }\n\n return null;\n },\n};\n\nexport const safeHashPrefixRule: OptionRule<string> = {\n validate: (value) => {\n if (CONTROL_CHARS.test(value)) {\n return \"must not contain control characters\";\n }\n\n if (value.includes(\"/\")) {\n return \"must not contain '/' (slash is added before the path automatically)\";\n }\n\n if (value.includes(\"#\")) {\n return \"must not contain '#' (it is added as the hash delimiter)\";\n }\n\n if (value.includes(\"?\")) {\n return \"must not contain '?' (it conflicts with the query delimiter)\";\n }\n\n return null;\n },\n};\n\nexport const nonNegativeIntegerRule: OptionRule<number> = {\n validate: (value) => {\n if (!Number.isFinite(value)) {\n return `expected finite number, got ${String(value)}`;\n }\n\n if (!Number.isInteger(value)) {\n return `expected integer, got ${String(value)}`;\n }\n\n if (value < 0) {\n return `expected non-negative integer, got ${value}`;\n }\n\n return null;\n },\n};\n","import type {\n NavigationOptions,\n Params,\n Router,\n State,\n} from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\nexport interface LocationSource {\n getLocation: () => string;\n}\n\n/**\n * Minimal browser surface needed by `createReplaceHistoryState`.\n *\n * Both `Browser` (History API) and navigation-plugin's `NavigationBrowser`\n * (Navigation API) satisfy this structurally — the function never needs\n * `pushState`/`addPopstateListener`, only the replace path.\n */\nexport interface ReplaceStateBrowser {\n replaceState: (state: unknown, url: string) => void;\n getHash: () => string;\n}\n\nexport function createStartInterceptor(\n api: PluginApi,\n browser: LocationSource,\n): () => void {\n return api.addInterceptor(\"start\", (next, path) =>\n next(path ?? browser.getLocation()),\n );\n}\n\nexport function createReplaceHistoryState(\n api: PluginApi,\n router: Router,\n browser: ReplaceStateBrowser,\n buildUrl: (name: string, params?: Params) => string,\n preserveHash = true,\n): (name: string, params?: Params) => void {\n // Reusable buffer — browsers structured-clone state synchronously inside\n // replaceState, so the buffer never escapes. Eliminates one allocation per\n // navigation on the hot path. (Mirrors createUpdateBrowserState.)\n const buffer = {\n name: \"\",\n params: {} as Params,\n path: \"\",\n };\n\n return (name: string, params: Params = {}) => {\n const state = api.buildState(name, params);\n\n if (!state) {\n throw new Error(\n `[real-router] Cannot replace state: route \"${name}\" is not found`,\n );\n }\n\n const builtState = api.makeState(\n state.name,\n state.params,\n router.buildPath(state.name, state.params),\n {\n params: state.meta,\n },\n );\n\n const hash = preserveHash ? browser.getHash() : \"\";\n const url = buildUrl(name, params) + hash;\n\n buffer.name = builtState.name;\n buffer.params = builtState.params;\n buffer.path = builtState.path;\n\n browser.replaceState(buffer, url);\n };\n}\n\nexport function shouldReplaceHistory(\n navOptions: NavigationOptions,\n toState: State,\n fromState: State | undefined,\n): boolean {\n if (navOptions.replace === true) {\n return true;\n }\n\n if (!fromState) {\n return navOptions.replace !== false;\n }\n\n return !!navOptions.reload && toState.path === fromState.path;\n}\n","export interface ParsedUrl {\n pathname: string;\n search: string;\n hash: string;\n}\n\n/**\n * Scheme-agnostic URL parser.\n *\n * Extracts `pathname`, `search`, and `hash` from any string — absolute\n * (`scheme://authority/path?q#h`), path-relative (`/path?q#h`), or opaque\n * (`data:...`, `javascript:...`). Never throws, never returns null.\n *\n * Routing does not care about scheme or authority, only about the path part.\n * This keeps `browser-plugin`, `navigation-plugin`, and `hash-plugin` working\n * in Electron (`file://`, `app://`), Tauri (`tauri://`, `https://`), and any\n * other webview that may ship with non-HTTP origins. See issue #496.\n */\nexport function safeParseUrl(url: string): ParsedUrl {\n let rest = url;\n\n const schemeIdx = rest.indexOf(\"://\");\n\n if (schemeIdx !== -1) {\n const authorityStart = schemeIdx + 3;\n let pathStart = rest.length;\n\n for (let i = authorityStart; i < rest.length; i++) {\n const ch = rest[i];\n\n if (ch === \"/\" || ch === \"?\" || ch === \"#\") {\n pathStart = i;\n\n break;\n }\n }\n\n rest = pathStart === rest.length ? \"/\" : rest.slice(pathStart);\n\n if (rest.startsWith(\"?\") || rest.startsWith(\"#\")) {\n rest = `/${rest}`;\n }\n }\n\n const hashIdx = rest.indexOf(\"#\");\n const hash = hashIdx === -1 ? \"\" : rest.slice(hashIdx);\n const beforeHash = hashIdx === -1 ? rest : rest.slice(0, hashIdx);\n\n const queryIdx = beforeHash.indexOf(\"?\");\n const search = queryIdx === -1 ? \"\" : beforeHash.slice(queryIdx);\n const pathname = queryIdx === -1 ? beforeHash : beforeHash.slice(0, queryIdx);\n\n return { pathname, search, hash };\n}\n","import { safeParseUrl } from \"./url-parsing.js\";\n\nexport function extractPath(pathname: string, base: string): string {\n if (!pathname) {\n return \"/\";\n }\n\n if (base && (pathname === base || pathname.startsWith(`${base}/`))) {\n const stripped = pathname.slice(base.length);\n\n return stripped.startsWith(\"/\") ? stripped : `/${stripped}`;\n }\n\n return pathname.startsWith(\"/\") ? pathname : `/${pathname}`;\n}\n\nexport function buildUrl(path: string, base: string): string {\n if (!path) {\n return base;\n }\n\n if (!base) {\n return path.startsWith(\"/\") ? path : `/${path}`;\n }\n\n // Path \"/\" with a non-empty base would otherwise produce `\"${base}/\"` —\n // a trailing-slash URL (e.g. `/app/`). The canonical form of the base\n // (normalizeBase strips trailing slash) is `/app`, and the router's\n // `extractPath(\"/app\", \"/app\")` round-trips to `\"/\"` regardless. Collapse\n // the index case to the canonical base to keep URLs symmetric.\n if (path === \"/\") {\n return base;\n }\n\n return path.startsWith(\"/\") ? `${base}${path}` : `${base}/${path}`;\n}\n\nexport function urlToPath(url: string, base: string): string {\n const parsedUrl = safeParseUrl(url);\n\n return extractPath(parsedUrl.pathname, base) + parsedUrl.search;\n}\n\n/**\n * Parses an absolute URL and returns its path + search, stripped of `base`.\n * Alias of {@link urlToPath} kept for call-site readability — history-query\n * paths (Navigation API entries, etc.) are absolute URLs by contract.\n */\nexport function extractPathFromAbsoluteUrl(url: string, base: string): string {\n return urlToPath(url, base);\n}\n","import type { NavigationPluginOptions } from \"./types\";\n\nexport const defaultOptions: Required<NavigationPluginOptions> = {\n // Default `false` respects `canDeactivate` guards on browser back/forward,\n // matching the documented contract of `browser-plugin` and the core router.\n // Apps that want the browser's native history buttons to bypass guards\n // (e.g. to avoid dead-end UX) can opt in via `forceDeactivate: true`.\n forceDeactivate: false,\n base: \"\",\n};\n\n/**\n * Source identifier for transitions triggered by navigate events.\n * Distinguishes browser-initiated navigation (back/forward, link clicks)\n * from programmatic navigation (router.navigate()).\n */\nexport const source = \"navigate\";\n\nexport const LOGGER_CONTEXT = \"navigation-plugin\";\n","import { safelyEncodePath, extractPath } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\n\n/**\n * Mutable cell carrying the \"syncing-from-router\" flag shared between\n * `wrapNavigationBrowserWithSyncing` (which raises it around every router-driven\n * mutation) and the plugin's navigate handler (which reads it to short-circuit\n * the event fired by the plugin's own write).\n *\n * Internal to navigation-plugin — not part of the public type surface.\n */\nexport interface SyncingFlag {\n current: boolean;\n}\n\n/**\n * Creates a NavigationBrowser wrapping the real Navigation API.\n * Only call this when `\"navigation\" in globalThis` is true.\n */\nexport function createNavigationBrowser(base: string): NavigationBrowser {\n const nav = globalThis.navigation;\n\n return {\n getLocation: () =>\n safelyEncodePath(extractPath(globalThis.location.pathname, base)) +\n globalThis.location.search,\n\n getHash: () => globalThis.location.hash,\n\n navigate: (url, options) => {\n nav.navigate(url, options);\n },\n\n replaceState: (state, url) => {\n nav.navigate(url, {\n state,\n history: \"replace\",\n });\n },\n\n updateCurrentEntry: (options) => {\n nav.updateCurrentEntry(options);\n },\n\n traverseTo: (key) => {\n nav.traverseTo(key);\n },\n\n addNavigateListener: (fn) => {\n nav.addEventListener(\"navigate\", fn);\n\n return () => {\n nav.removeEventListener(\"navigate\", fn);\n };\n },\n\n entries: () => nav.entries(),\n\n get currentEntry() {\n return nav.currentEntry;\n },\n\n getActivationType: () => nav.activation?.navigationType,\n };\n}\n\n/**\n * Wraps every router-driven mutation of a NavigationBrowser with the syncing\n * flag — raised before the underlying call, lowered after, including the\n * throw path. The plugin's navigate handler reads `syncing.current` to\n * short-circuit the navigate event fired by the plugin's own write\n * (`nav.navigate(...)` and `nav.navigate({history:\"replace\"})` both fire\n * navigate events synchronously).\n *\n * Applied at the factory level to both the built-in `createNavigationBrowser`\n * and any user-supplied browser, so consumers don't need to manage the flag.\n */\nexport function wrapNavigationBrowserWithSyncing(\n browser: NavigationBrowser,\n syncing: SyncingFlag,\n): NavigationBrowser {\n const wrap = <T>(fn: () => T): T => {\n syncing.current = true;\n try {\n return fn();\n } finally {\n syncing.current = false;\n }\n };\n\n return {\n getLocation: () => browser.getLocation(),\n getHash: () => browser.getHash(),\n\n navigate: (url, options) => {\n wrap(() => {\n browser.navigate(url, options);\n });\n },\n replaceState: (state, url) => {\n wrap(() => {\n browser.replaceState(state, url);\n });\n },\n updateCurrentEntry: (options) => {\n wrap(() => {\n browser.updateCurrentEntry(options);\n });\n },\n traverseTo: (key) => {\n wrap(() => {\n browser.traverseTo(key);\n });\n },\n\n addNavigateListener: (fn) => browser.addNavigateListener(fn),\n entries: () => browser.entries(),\n\n get currentEntry() {\n return browser.currentEntry;\n },\n\n getActivationType: () => browser.getActivationType(),\n };\n}\n","import { extractPathFromAbsoluteUrl } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\nimport type { State } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\n/**\n * Validates a candidate history entry for `traverseToLast(routeName)` and\n * returns both the entry (now known non-null) and the matched router state.\n * Extracted from `NavigationPlugin` so the three error branches (missing\n * entry, null url, unmatched url) can be tested directly without vi.spyOn\n * on module namespaces — the star-import spy pattern is fragile under ESM\n * and was working by accident in history-extensions.test.ts.\n *\n * Throws a descriptive Error on any failure; the caller (NavigationPlugin)\n * propagates it as the rejection of `traverseToLast`.\n */\nexport function resolveEntryToMatchedState(\n entry: NavigationHistoryEntry | undefined,\n routeName: string,\n api: PluginApi,\n base: string,\n): { entry: NavigationHistoryEntry; matchedState: State } {\n if (!entry) {\n throw new Error(`No history entry for route \"${routeName}\"`);\n }\n\n if (!entry.url) {\n throw new Error(`No matching route for entry URL \"${entry.url}\"`);\n }\n\n const path = extractPathFromAbsoluteUrl(entry.url, base);\n const matchedState = api.matchPath(path);\n\n if (!matchedState) {\n throw new Error(`No matching route for entry URL \"${entry.url}\"`);\n }\n\n return { entry, matchedState };\n}\n\n/**\n * Converts a NavigationHistoryEntry to a State via URL matching.\n * Uses URL matching (not entry.getState()) because:\n * - Entries before plugin init have no state\n * - Entries after router.replace(routes) may have stale state\n * - Entries from other SPAs on the same origin have foreign state\n */\nexport function entryToState(\n entry: NavigationHistoryEntry | undefined,\n api: PluginApi,\n base: string,\n): State | undefined {\n if (!entry?.url) {\n return undefined;\n }\n\n return (\n api.matchPath(extractPathFromAbsoluteUrl(entry.url, base)) ?? undefined\n );\n}\n\nfunction peekAt(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n offset: number,\n): State | undefined {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return undefined;\n }\n\n return entryToState(browser.entries()[idx + offset], api, base);\n}\n\nexport function peekBack(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): State | undefined {\n return peekAt(browser, api, base, -1);\n}\n\nexport function peekForward(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): State | undefined {\n return peekAt(browser, api, base, 1);\n}\n\nexport function hasVisited(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): boolean {\n return browser.entries().some((entry) => {\n const state = entryToState(entry, api, base);\n\n return state?.name === routeName;\n });\n}\n\nexport function getVisitedRoutes(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): string[] {\n const names = new Set<string>();\n\n for (const entry of browser.entries()) {\n const state = entryToState(entry, api, base);\n\n if (state) {\n names.add(state.name);\n }\n }\n\n return [...names];\n}\n\nexport function getRouteVisitCount(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): number {\n let count = 0;\n\n for (const entry of browser.entries()) {\n if (entryToState(entry, api, base)?.name === routeName) {\n count++;\n }\n }\n\n return count;\n}\n\n/**\n * Finds the last NavigationHistoryEntry matching the given route name,\n * excluding the current entry (to avoid SAME_STATES on traverseToLast(\"current-route\")).\n */\nexport function findLastEntryForRoute(\n entries: NavigationHistoryEntry[],\n routeName: string,\n api: PluginApi,\n base: string,\n currentKey: string | undefined,\n): NavigationHistoryEntry | undefined {\n for (let i = entries.length - 1; i >= 0; i--) {\n const entry = entries[i];\n\n if (entry.key === currentKey) {\n continue;\n }\n\n const state = entryToState(entry, api, base);\n\n if (state?.name === routeName) {\n return entry;\n }\n }\n\n return undefined;\n}\n\nexport function canGoBack(browser: NavigationBrowser): boolean {\n const idx = browser.currentEntry?.index;\n\n return idx != null && idx > 0;\n}\n\nexport function canGoForward(browser: NavigationBrowser): boolean {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return false;\n }\n\n return idx < browser.entries().length - 1;\n}\n\nexport function canGoBackTo(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): boolean {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return false;\n }\n\n const entries = browser.entries();\n\n for (let i = idx - 1; i >= 0; i--) {\n const state = entryToState(entries[i], api, base);\n\n if (state?.name === routeName) {\n return true;\n }\n }\n\n return false;\n}\n","import { errorCodes, RouterError } from \"@real-router/core\";\n\nimport { urlToPath } from \"./browser-env\";\n\nimport type {\n NavigationBrowser,\n NavigationDirection,\n NavigationMeta,\n} from \"./types\";\nimport type { Router } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\ninterface NavigateHandlerDeps {\n router: Router;\n api: PluginApi;\n browser: NavigationBrowser;\n isSyncingFromRouter: () => boolean;\n setCapturedMeta: (meta: NavigationMeta) => void;\n base: string;\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n };\n}\n\nexport function computeDirection(\n navigationType: NavigationMeta[\"navigationType\"],\n destinationIndex: number,\n currentIndex: number,\n): NavigationDirection {\n if (navigationType === \"traverse\") {\n if (destinationIndex === currentIndex) {\n return \"unknown\";\n }\n\n return destinationIndex > currentIndex ? \"forward\" : \"back\";\n }\n\n return navigationType === \"push\" ? \"forward\" : \"unknown\";\n}\n\nexport function createNavigateHandler(deps: NavigateHandlerDeps) {\n const { router, api, browser, isSyncingFromRouter, base, transitionOptions } =\n deps;\n const { allowNotFound } = api.getOptions();\n\n return function handleNavigateEvent(event: NavigateEvent): void {\n if (!event.canIntercept || !router.isActive()) {\n return;\n }\n\n if (isSyncingFromRouter()) {\n // Plugin-originated navigate event after its own successful transition\n // (onTransitionSuccess calls browser.navigate to sync URL). We must still\n // intercept — a bare `return` leaves the event un-intercepted, and\n // Chromium falls back to a cross-document navigation (full page reload).\n // The noop handler cancels the fallback without running router logic;\n // state is already committed.\n event.intercept({\n handler: async () => {},\n });\n\n return;\n }\n\n const path = urlToPath(event.destination.url, base);\n const matchedState = api.matchPath(path);\n\n const navType = event.navigationType as NavigationMeta[\"navigationType\"];\n const currentIndex = browser.currentEntry?.index ?? -1;\n\n deps.setCapturedMeta({\n navigationType: navType,\n userInitiated: event.userInitiated,\n info: event.info,\n direction: computeDirection(\n navType,\n event.destination.index,\n currentIndex,\n ),\n sourceElement: event.sourceElement ?? null,\n });\n\n const withRecovery = async (run: () => Promise<unknown>): Promise<void> => {\n try {\n await run();\n } catch (error) {\n if (!(error instanceof RouterError)) {\n recoverFromNavigateError(error, router, browser);\n\n return;\n }\n\n // TRANSITION_CANCELLED: a newer navigation aborted this one — the\n // newer navigate event is (or will be) handled by this same plugin,\n // and THAT event is responsible for syncing URL/state. Firing our\n // own sync here races against it: browser.navigate(replace, same-url)\n // would cancel the in-flight newer transition, which is exactly the\n // rapid-fire-events storm failure mode.\n //\n // SAME_STATES: router refused because router.getState() already equals\n // the target. URL and router state are already consistent — no sync\n // needed.\n if (\n error.code === errorCodes.TRANSITION_CANCELLED ||\n error.code === errorCodes.SAME_STATES\n ) {\n return;\n }\n\n // Other RouterError codes (CANNOT_DEACTIVATE, CANNOT_ACTIVATE,\n // ROUTE_NOT_FOUND, …) — router rejected the transition, state is\n // unchanged, but URL may have already committed to a different\n // value by the Navigation API. Sync the URL back to the current\n // router state in a single visible transition (headless Chromium\n // and some cross-origin setups leave \"committed-then-reverted\"\n // windows if we relied on the native rollback via intercept reject).\n // Observers that care about the error see it through the router's\n // TRANSITION_ERROR event.\n syncUrlToRouterState(router, browser);\n }\n };\n\n if (matchedState) {\n event.intercept({\n handler: () =>\n withRecovery(() =>\n // api.navigateToState: matchPath already applied forwardState +\n // matchSourceTrailingSlash; reusing the State avoids the redundant\n // round-trip and preserves trailing slashes (#525). Plugin-only\n // entry point — not on the public Router/Navigator surface.\n api.navigateToState(matchedState, {\n ...transitionOptions,\n signal: event.signal,\n }),\n ),\n });\n } else if (allowNotFound) {\n event.intercept({\n handler: () => {\n router.navigateToNotFound(path);\n },\n });\n } else {\n // Strict mode — unmatched URL is an error. Emit $$error and reject the\n // intercept so the Navigation API auto-rolls back the URL. No silent\n // fallback to defaultRoute.\n event.intercept({\n // eslint-disable-next-line @typescript-eslint/require-await -- Navigation API requires async handler; synchronous throw is the rollback signal\n handler: async () => {\n const err = new RouterError(errorCodes.ROUTE_NOT_FOUND, { path });\n\n api.emitTransitionError(err);\n\n throw err;\n },\n });\n }\n };\n}\n\nfunction recoverFromNavigateError(\n error: unknown,\n router: Router,\n browser: NavigationBrowser,\n): void {\n console.error(\n \"[navigation-plugin] Critical error in navigate handler\",\n error,\n );\n\n syncUrlToRouterState(router, browser);\n}\n\nfunction syncUrlToRouterState(\n router: Router,\n browser: NavigationBrowser,\n): void {\n try {\n const currentState = router.getState();\n\n if (currentState) {\n const url = router.buildUrl(currentState.name, currentState.params);\n\n // The syncing flag is raised/lowered inside NavigationBrowser around\n // browser.navigate, including the throw path — no manual try/finally\n // needed here.\n browser.navigate(url, {\n state: {\n name: currentState.name,\n params: currentState.params,\n path: currentState.path,\n },\n history: \"replace\",\n });\n }\n } catch (syncError) {\n console.error(\n \"[navigation-plugin] Failed to sync URL to router state\",\n syncError,\n );\n }\n}\n","import { UNKNOWN_ROUTE } from \"@real-router/core\";\n\nimport {\n shouldReplaceHistory,\n buildUrl,\n urlToPath,\n createStartInterceptor,\n createReplaceHistoryState,\n} from \"./browser-env\";\nimport {\n peekBack,\n peekForward,\n hasVisited,\n getVisitedRoutes,\n getRouteVisitCount,\n findLastEntryForRoute,\n resolveEntryToMatchedState,\n canGoBack,\n canGoForward,\n canGoBackTo,\n} from \"./history-extensions\";\nimport { createNavigateHandler } from \"./navigate-handler\";\nimport { wrapNavigationBrowserWithSyncing } from \"./navigation-browser\";\n\nimport type { SyncingFlag } from \"./navigation-browser\";\nimport type {\n NavigationBrowser,\n NavigationMeta,\n NavigationPluginOptions,\n NavigationSharedState,\n} from \"./types\";\nimport type {\n NavigationOptions,\n Params,\n Router,\n State,\n Plugin,\n} from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\nexport function deriveNavigationType(\n navOptions: NavigationOptions,\n toState: State,\n fromState: State | undefined,\n): NavigationMeta[\"navigationType\"] {\n if (navOptions.reload && toState.path === fromState?.path) {\n return \"reload\";\n }\n\n if (shouldReplaceHistory(navOptions, toState, fromState)) {\n return \"replace\";\n }\n\n return \"push\";\n}\n\nexport class NavigationPlugin {\n readonly #router: Router;\n readonly #api: PluginApi;\n readonly #options: Required<NavigationPluginOptions>;\n readonly #browser: NavigationBrowser;\n readonly #removeStartInterceptor: () => void;\n readonly #removeExtensions: () => void;\n readonly #claim: {\n write: (state: State, value: NavigationMeta) => void;\n release: () => void;\n };\n readonly #lifecycle: Pick<Plugin, \"onStart\" | \"onStop\" | \"teardown\">;\n readonly #syncing: SyncingFlag = { current: false };\n\n #capturedMeta: NavigationMeta | undefined;\n #pendingTraverseKey: string | undefined;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<NavigationPluginOptions>,\n browser: NavigationBrowser,\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n },\n shared: NavigationSharedState,\n ) {\n this.#router = router;\n this.#api = api;\n this.#options = options;\n // Wrap mutations with the syncing flag so the navigate handler can\n // short-circuit re-entrant events fired by the plugin's own writes\n // (`nav.navigate` and `nav.navigate({history:\"replace\"})` fire navigate\n // events synchronously). The flag is per-instance — never shared across\n // plugins — so multiple routers running concurrent transitions don't\n // bleed syncing state into each other.\n this.#browser = wrapNavigationBrowserWithSyncing(browser, this.#syncing);\n\n this.#claim = api.claimContextNamespace(\"navigation\");\n this.#removeStartInterceptor = createStartInterceptor(api, this.#browser);\n\n // Cross-document load priming (#531). On F5, browser back/forward across\n // a page boundary, or a fresh URL bar entry, the prior JS context is\n // discarded — the navigate event handler never sees the activation.\n // Without this, deriveNavigationType in onTransitionSuccess falls through\n // to \"replace\" for every initial transition, breaking scroll restore on\n // reload (#497) and any consumer branching on navigationType.\n // navigation.activation reflects the cross-document navigation that\n // activated this document; it stays constant across same-document\n // navigations, so this only affects the FIRST transition.\n const activationType = this.#browser.getActivationType();\n\n if (activationType) {\n this.#capturedMeta = {\n navigationType: activationType,\n userInitiated: false,\n direction: activationType === \"push\" ? \"forward\" : \"unknown\",\n sourceElement: null,\n };\n }\n\n const pluginBuildUrl = (route: string, params?: Params) => {\n const path = router.buildPath(route, params);\n\n return buildUrl(path, options.base);\n };\n\n this.#removeExtensions = api.extendRouter({\n buildUrl: pluginBuildUrl,\n matchUrl: (url: string) =>\n api.matchPath(urlToPath(url, options.base)) ?? undefined,\n replaceHistoryState: createReplaceHistoryState(\n api,\n router,\n this.#browser,\n pluginBuildUrl,\n ),\n\n peekBack: () => peekBack(this.#browser, api, options.base),\n peekForward: () => peekForward(this.#browser, api, options.base),\n hasVisited: (routeName: string) =>\n hasVisited(this.#browser, api, options.base, routeName),\n getVisitedRoutes: () =>\n getVisitedRoutes(this.#browser, api, options.base),\n getRouteVisitCount: (routeName: string) =>\n getRouteVisitCount(this.#browser, api, options.base, routeName),\n traverseToLast: (routeName: string) => this.traverseToLast(routeName),\n canGoBack: () => canGoBack(this.#browser),\n canGoForward: () => canGoForward(this.#browser),\n canGoBackTo: (routeName: string) =>\n canGoBackTo(this.#browser, api, options.base, routeName),\n });\n\n const handler = createNavigateHandler({\n router,\n api,\n browser: this.#browser,\n isSyncingFromRouter: () => this.#syncing.current,\n setCapturedMeta: (meta) => {\n this.#capturedMeta = meta;\n },\n base: options.base,\n transitionOptions,\n });\n\n this.#lifecycle = createNavigateLifecycle({\n browser: this.#browser,\n shared,\n handler,\n removeStartInterceptor: this.#removeStartInterceptor,\n removeExtensions: this.#removeExtensions,\n releaseClaim: () => {\n this.#claim.release();\n },\n });\n }\n\n async traverseToLast(routeName: string): Promise<State> {\n const entries = this.#browser.entries();\n const currentKey = this.#browser.currentEntry?.key;\n const candidate = findLastEntryForRoute(\n entries,\n routeName,\n this.#api,\n this.#options.base,\n currentKey,\n );\n\n // resolveEntryToMatchedState throws for missing entry, null url, or\n // unmatched url — same three error branches the old inline checks\n // produced. Extracted so the error paths can be unit-tested directly\n // without namespace-level vi.spyOn gymnastics.\n const { entry, matchedState } = resolveEntryToMatchedState(\n candidate,\n routeName,\n this.#api,\n this.#options.base,\n );\n\n const currentEntry = this.#browser.currentEntry;\n\n if (!currentEntry) {\n // Invariant violation: traverseToLast is only callable after\n // router.start(), which guarantees a current entry. A null here means\n // the plugin was stopped mid-call or the browser abstraction is\n // broken — either way, silently picking direction \"forward\" from a\n // fallback `-1` would mask the bug. Fail loudly instead.\n throw new Error(\n `[navigation-plugin] Cannot determine direction for traverseToLast(\"${routeName}\"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`,\n );\n }\n\n this.#capturedMeta = {\n navigationType: \"traverse\",\n userInitiated: false,\n direction: entry.index > currentEntry.index ? \"forward\" : \"back\",\n sourceElement: null,\n };\n this.#pendingTraverseKey = entry.key;\n\n return this.#router.navigate(matchedState.name, matchedState.params);\n }\n\n getPlugin(): Plugin {\n return {\n ...this.#lifecycle,\n\n onTransitionStart: (toState: State) => {\n if (this.#capturedMeta) {\n this.#claim.write(toState, this.#capturedMeta);\n }\n },\n\n onTransitionSuccess: (\n toState: State,\n fromState: State | undefined,\n navOptions: NavigationOptions,\n ) => {\n if (!this.#capturedMeta) {\n const navigationType = deriveNavigationType(\n navOptions,\n toState,\n fromState,\n );\n\n this.#capturedMeta = {\n navigationType,\n userInitiated: false,\n direction: navigationType === \"push\" ? \"forward\" : \"unknown\",\n sourceElement: null,\n };\n }\n\n const frozenMeta = Object.freeze(this.#capturedMeta);\n\n this.#claim.write(toState, frozenMeta);\n this.#capturedMeta = undefined;\n\n // Consume pendingTraverseKey BEFORE calling browser.traverseTo.\n // If traverseTo throws (Navigation API can reject on evicted keys\n // under memory pressure), we must not leave the stale key behind —\n // otherwise the NEXT transition's onTransitionSuccess would see it\n // and replay the traverse against the same already-broken key.\n // The syncing flag is raised/lowered inside NavigationBrowser around\n // each mutation, so we do not need to manage it here.\n const traverseKey = this.#pendingTraverseKey;\n\n this.#pendingTraverseKey = undefined;\n\n if (traverseKey) {\n this.#browser.traverseTo(traverseKey);\n } else {\n const url = buildUrl(toState.path, this.#options.base);\n const shouldPreserveHash =\n !fromState || fromState.path === toState.path;\n const hash = shouldPreserveHash ? this.#browser.getHash() : \"\";\n const finalUrl = hash ? url + hash : url;\n const historyState = {\n name: toState.name,\n params: toState.params,\n path: toState.path,\n };\n\n if (toState.name === UNKNOWN_ROUTE) {\n this.#browser.updateCurrentEntry({ state: historyState });\n } else {\n const replace = frozenMeta.navigationType !== \"push\";\n\n this.#browser.navigate(finalUrl, {\n state: historyState,\n history: replace ? \"replace\" : \"push\",\n });\n }\n }\n },\n\n onTransitionCancel: () => {\n this.#capturedMeta = undefined;\n this.#pendingTraverseKey = undefined;\n },\n\n onTransitionError: () => {\n this.#capturedMeta = undefined;\n this.#pendingTraverseKey = undefined;\n },\n };\n }\n}\n\ninterface NavigateLifecycleDeps {\n browser: NavigationBrowser;\n handler: (event: NavigateEvent) => void;\n removeStartInterceptor: () => void;\n removeExtensions: () => void;\n releaseClaim: () => void;\n shared: NavigationSharedState;\n}\n\nfunction createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {\n return {\n onStart() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = deps.browser.addNavigateListener(\n deps.handler,\n );\n },\n\n onStop() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = undefined;\n },\n\n teardown() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = undefined;\n deps.removeStartInterceptor();\n deps.removeExtensions();\n deps.releaseClaim();\n },\n };\n}\n","import { createWarnOnce } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\n\nconst NOOP = (): void => {};\n\nexport const createNavigationFallbackBrowser = (\n context: string,\n): NavigationBrowser => {\n const warnOnce = createWarnOnce(context);\n\n return {\n getLocation: () => {\n warnOnce(\"getLocation\");\n\n return \"/\";\n },\n getHash: () => {\n warnOnce(\"getHash\");\n\n return \"\";\n },\n navigate: () => {\n warnOnce(\"navigate\");\n },\n replaceState: () => {\n warnOnce(\"replaceState\");\n },\n updateCurrentEntry: () => {\n warnOnce(\"updateCurrentEntry\");\n },\n traverseTo: () => {\n warnOnce(\"traverseTo\");\n },\n addNavigateListener: () => {\n warnOnce(\"addNavigateListener\");\n\n return NOOP;\n },\n entries: () => {\n warnOnce(\"entries\");\n\n return [];\n },\n currentEntry: null,\n getActivationType: () => undefined,\n };\n};\n","import { createOptionsValidator, safeBaseRule } from \"./browser-env\";\nimport { LOGGER_CONTEXT, defaultOptions } from \"./constants\";\n\nimport type { NavigationPluginOptions } from \"./types\";\n\nexport const validateOptions = createOptionsValidator<NavigationPluginOptions>(\n defaultOptions,\n LOGGER_CONTEXT,\n { base: safeBaseRule },\n);\n","import { getPluginApi } from \"@real-router/core/api\";\n\nimport { isBrowserEnvironment, normalizeBase } from \"./browser-env\";\nimport { defaultOptions, source } from \"./constants\";\nimport { createNavigationBrowser } from \"./navigation-browser\";\nimport { NavigationPlugin } from \"./plugin\";\nimport { createNavigationFallbackBrowser } from \"./ssr-fallback\";\nimport { validateOptions } from \"./validation\";\n\nimport type {\n NavigationPluginOptions,\n NavigationBrowser,\n NavigationSharedState,\n} from \"./types\";\nimport type { PluginFactory, Router } from \"@real-router/core\";\n\nexport function navigationPluginFactory(\n opts?: Partial<NavigationPluginOptions>,\n browser?: NavigationBrowser,\n): PluginFactory {\n if (!browser && isBrowserEnvironment() && !(\"navigation\" in globalThis)) {\n throw new Error(\n \"[navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.\",\n );\n }\n\n validateOptions(opts);\n\n const options: Required<NavigationPluginOptions> = {\n ...defaultOptions,\n ...opts,\n };\n\n options.base = normalizeBase(options.base);\n\n const resolvedBrowser = browser ?? createBrowser(options.base);\n\n const forceDeactivate = options.forceDeactivate;\n const transitionOptions = { forceDeactivate, source, replace: true as const };\n const shared: NavigationSharedState = { removeNavigateListener: undefined };\n\n return (routerBase) => {\n const api = getPluginApi(routerBase);\n\n const plugin = new NavigationPlugin(\n routerBase as Router,\n api,\n options,\n resolvedBrowser,\n transitionOptions,\n shared,\n );\n\n return plugin.getPlugin();\n };\n}\n\nfunction createBrowser(base: string): NavigationBrowser {\n if (\"navigation\" in globalThis) {\n return createNavigationBrowser(base);\n }\n\n return createNavigationFallbackBrowser(\"navigation-plugin\");\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["RouterError","errorCodes","#router","#api","#options","#browser","#removeStartInterceptor","#removeExtensions","#claim","#urlClaim","#lifecycle","#syncing","#capturedMeta","#pendingTraverseKey","UNKNOWN_ROUTE"],"sources":["../../../../shared/browser-env/detect.ts","../../../../shared/browser-env/utils.ts","../../../../shared/browser-env/ssr-fallback.ts","../../../../shared/browser-env/validation.ts","../../../../shared/browser-env/url-context.ts","../../../../shared/browser-env/plugin-utils.ts","../../../../shared/browser-env/url-parsing.ts","../../../../shared/browser-env/url-utils.ts","../../src/constants.ts","../../src/navigation-browser.ts","../../src/history-extensions.ts","../../src/navigate-handler.ts","../../src/plugin.ts","../../src/ssr-fallback.ts","../../src/validation.ts","../../src/factory.ts"],"sourcesContent":["export const isBrowserEnvironment = (): boolean =>\n typeof globalThis.window !== \"undefined\" && !!globalThis.history;\n","/**\n * Normalizes base path to canonical form: leading slash, no trailing slash,\n * no repeated slashes. Isolated \"/\" collapses to \"\".\n *\n * @example\n * normalizeBase(\"app\") // \"/app\"\n * normalizeBase(\"/app/\") // \"/app\"\n * normalizeBase(\"//app//\") // \"/app\"\n * normalizeBase(\"\") // \"\"\n * normalizeBase(\"/\") // \"\"\n */\nexport function normalizeBase(base: string): string {\n if (!base) {\n return base;\n }\n\n let result = base.replaceAll(/\\/+/g, \"/\");\n\n if (!result.startsWith(\"/\")) {\n result = `/${result}`;\n }\n\n if (result.length > 1 && result.endsWith(\"/\")) {\n result = result.slice(0, -1);\n }\n\n return result === \"/\" ? \"\" : result;\n}\n\nexport const safelyEncodePath = (path: string): string => {\n try {\n return encodeURI(decodeURI(path));\n } catch (error) {\n console.warn(`[browser-env] Could not encode path \"${path}\"`, error);\n\n return path;\n }\n};\n","import type { HistoryBrowser } from \"./types.js\";\n\nconst NOOP = (): void => {};\n\nexport const createWarnOnce = (context: string) => {\n let hasWarned = false;\n\n return (method: string): void => {\n if (!hasWarned) {\n console.warn(\n `[browser-env] Browser API is running in a non-browser environment (context: \"${context}\"). ` +\n `Method \"${method}\" is a no-op. ` +\n `This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`,\n );\n hasWarned = true;\n }\n };\n};\n\nexport const createHistoryFallbackBrowser = (\n context: string,\n): HistoryBrowser => {\n const warnOnce = createWarnOnce(context);\n\n return {\n pushState: () => {\n warnOnce(\"pushState\");\n },\n replaceState: () => {\n warnOnce(\"replaceState\");\n },\n addPopstateListener: () => {\n warnOnce(\"addPopstateListener\");\n\n return NOOP;\n },\n getHash: () => {\n warnOnce(\"getHash\");\n\n return \"\";\n },\n };\n};\n","export interface OptionRule<T> {\n validate: (value: T) => string | null;\n}\n\nexport type OptionRules<T extends object> = {\n [K in keyof T]?: OptionRule<NonNullable<T[K]>>;\n};\n\nexport function createOptionsValidator<T extends object>(\n defaults: Required<T>,\n loggerContext: string,\n rules?: OptionRules<T>,\n): (opts: Partial<T> | undefined) => void {\n return (opts) => {\n if (!opts) {\n return;\n }\n\n for (const key of Object.keys(opts)) {\n if (!(key in defaults)) {\n continue;\n }\n\n const value = opts[key as keyof typeof opts];\n\n if (value === undefined) {\n continue;\n }\n\n const expected = typeof defaults[key as keyof typeof defaults];\n const actual = typeof value;\n\n if (actual !== expected) {\n throw new Error(\n `[${loggerContext}] Invalid type for '${key}': expected ${expected}, got ${actual}`,\n );\n }\n\n const rule = rules?.[key as keyof T];\n\n if (rule) {\n const msg = (rule.validate as (input: unknown) => string | null)(value);\n\n if (msg !== null) {\n throw new Error(`[${loggerContext}] Invalid '${key}': ${msg}`);\n }\n }\n }\n };\n}\n\n// eslint-disable-next-line no-control-regex -- control characters are exactly what this rule rejects\nconst CONTROL_CHARS = /[\\u0000-\\u001F\\u007F]/;\n\nexport const safeBaseRule: OptionRule<string> = {\n validate: (value) => {\n if (CONTROL_CHARS.test(value)) {\n return \"must not contain control characters\";\n }\n\n if (value.split(\"/\").includes(\"..\")) {\n return \"must not contain '..' segments\";\n }\n\n return null;\n },\n};\n\nexport const safeHashPrefixRule: OptionRule<string> = {\n validate: (value) => {\n if (CONTROL_CHARS.test(value)) {\n return \"must not contain control characters\";\n }\n\n if (value.includes(\"/\")) {\n return \"must not contain '/' (slash is added before the path automatically)\";\n }\n\n if (value.includes(\"#\")) {\n return \"must not contain '#' (it is added as the hash delimiter)\";\n }\n\n if (value.includes(\"?\")) {\n return \"must not contain '?' (it conflicts with the query delimiter)\";\n }\n\n return null;\n },\n};\n\nexport const nonNegativeIntegerRule: OptionRule<number> = {\n validate: (value) => {\n if (!Number.isFinite(value)) {\n return `expected finite number, got ${String(value)}`;\n }\n\n if (!Number.isInteger(value)) {\n return `expected integer, got ${String(value)}`;\n }\n\n if (value < 0) {\n return `expected non-negative integer, got ${value}`;\n }\n\n return null;\n },\n};\n","/**\n * URL fragment (\"hash\") shared layer (#532).\n *\n * Both URL plugins (navigation-plugin, browser-plugin) claim the `\"url\"`\n * `state.context` namespace and write `UrlContext` on every transition.\n * Mutually exclusive at runtime — only one URL plugin is installed per router.\n *\n * Hash form: decoded, no leading \"#\" — symmetric to `params` (no leading \"?\").\n * Encoding to/from URL form happens at the boundary (URL build / URL parse).\n */\n\nexport interface UrlContext {\n /** Decoded fragment, no leading \"#\". Empty string when URL has no fragment. */\n hash: string;\n /** Whether `hash` differs from the previous transition's `state.context.url.hash`. */\n hashChanged: boolean;\n}\n\n/**\n * Encode for URL fragment per RFC 3986: preserves sub-delims (`&`, `=`, `?`,\n * `:`, etc.) and the path/query characters that `encodeURI` already leaves\n * alone. Defensively percent-escapes `#` (a stray `#` in a decoded fragment\n * would otherwise terminate the fragment in the rendered URL).\n *\n * `encodeURIComponent` over-encodes RFC-3986 sub-delims (`&` → `%26`) and is\n * therefore wrong for fragments.\n */\nexport function encodeHashFragment(decoded: string): string {\n return encodeURI(decoded).replaceAll(\"#\", \"%23\");\n}\n\n/**\n * Decode a percent-encoded fragment. Falls back to the raw input on malformed\n * escapes — matches the resilience pattern in scroll-restore.\n */\nexport function decodeHashFragment(encoded: string): string {\n try {\n return decodeURIComponent(encoded);\n } catch {\n return encoded;\n }\n}\n\n/**\n * Normalize user-provided hash input: strip a leading \"#\" if present, then\n * decode. Defensive against `<Link hash=\"#section\">` — the prop is documented\n * to accept the fragment name without \"#\", but we accept both gracefully.\n */\nexport function normalizeHashInput(input: string): string {\n const stripped = input.startsWith(\"#\") ? input.slice(1) : input;\n\n return decodeHashFragment(stripped);\n}\n\n/**\n * Read the current browser hash in decoded form, no leading \"#\".\n * Accepts any object with a `getHash()` method — works for both `Browser`\n * (History API) and `NavigationBrowser` (Navigation API). SSR-safe via the\n * abstractions, which return `\"\"` outside a real browser.\n */\nexport function getDecodedHash(browser: { getHash: () => string }): string {\n const raw = browser.getHash();\n\n if (!raw) {\n return \"\";\n }\n\n const stripped = raw.startsWith(\"#\") ? raw.slice(1) : raw;\n\n return decodeHashFragment(stripped);\n}\n","import { encodeHashFragment, normalizeHashInput } from \"./url-context.js\";\n\nimport type {\n NavigationOptions,\n Params,\n Router,\n State,\n} from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\nexport interface LocationSource {\n getLocation: () => string;\n}\n\n/**\n * Minimal browser surface needed by `createReplaceHistoryState`.\n *\n * Both `Browser` (History API) and navigation-plugin's `NavigationBrowser`\n * (Navigation API) satisfy this structurally — the function never needs\n * `pushState`/`addPopstateListener`, only the replace path.\n */\nexport interface ReplaceStateBrowser {\n replaceState: (state: unknown, url: string) => void;\n getHash: () => string;\n}\n\n/**\n * Hash override option for `replaceHistoryState` (#532). Tri-state semantics:\n * `undefined` — preserve the current browser hash (legacy behavior, default)\n * `\"\"` — explicitly clear the fragment\n * non-empty — explicitly set the fragment (decoded form, no leading \"#\")\n */\nexport interface ReplaceHistoryStateOptions {\n hash?: string;\n}\n\nexport function createStartInterceptor(\n api: PluginApi,\n browser: LocationSource,\n): () => void {\n return api.addInterceptor(\"start\", (next, path) =>\n next(path ?? browser.getLocation()),\n );\n}\n\nexport function createReplaceHistoryState(\n api: PluginApi,\n router: Router,\n browser: ReplaceStateBrowser,\n buildUrl: (\n name: string,\n params?: Params,\n options?: ReplaceHistoryStateOptions,\n ) => string,\n preserveHash = true,\n): (\n name: string,\n params?: Params,\n options?: ReplaceHistoryStateOptions,\n) => void {\n // Reusable buffer — browsers structured-clone state synchronously inside\n // replaceState, so the buffer never escapes. Eliminates one allocation per\n // navigation on the hot path. (Mirrors createUpdateBrowserState.)\n const buffer = {\n name: \"\",\n params: {} as Params,\n path: \"\",\n };\n\n return (\n name: string,\n params: Params = {},\n options?: ReplaceHistoryStateOptions,\n ) => {\n const state = api.buildState(name, params);\n\n if (!state) {\n throw new Error(\n `[real-router] Cannot replace state: route \"${name}\" is not found`,\n );\n }\n\n const builtState = api.makeState(\n state.name,\n state.params,\n router.buildPath(state.name, state.params),\n {\n params: state.meta,\n },\n );\n\n // Tri-state hash semantics (#532):\n // options.hash === undefined → preserve (legacy behavior, controlled by\n // preserveHash flag — true for browser/\n // navigation plugins, false for hash-plugin)\n // options.hash === \"\" → explicitly clear\n // options.hash === \"value\" → explicitly set\n let hashSegment: string;\n\n if (options?.hash !== undefined) {\n const norm = normalizeHashInput(options.hash);\n\n hashSegment = norm ? `#${encodeHashFragment(norm)}` : \"\";\n } else if (preserveHash) {\n hashSegment = browser.getHash();\n } else {\n hashSegment = \"\";\n }\n\n // Pass hash through buildUrl when the plugin understands it (avoids\n // double-append). Hash-plugin's buildUrl ignores the option and warns,\n // so call without options here for semantic clarity — but the result is\n // identical because hashSegment is \"\" in that branch (preserveHash=false).\n const url = buildUrl(name, params) + hashSegment;\n\n buffer.name = builtState.name;\n buffer.params = builtState.params;\n buffer.path = builtState.path;\n\n browser.replaceState(buffer, url);\n };\n}\n\nexport function shouldReplaceHistory(\n navOptions: NavigationOptions,\n toState: State,\n fromState: State | undefined,\n): boolean {\n if (navOptions.replace === true) {\n return true;\n }\n\n if (!fromState) {\n return navOptions.replace !== false;\n }\n\n return !!navOptions.reload && toState.path === fromState.path;\n}\n","export interface ParsedUrl {\n pathname: string;\n search: string;\n hash: string;\n}\n\n/**\n * Scheme-agnostic URL parser.\n *\n * Extracts `pathname`, `search`, and `hash` from any string — absolute\n * (`scheme://authority/path?q#h`), path-relative (`/path?q#h`), or opaque\n * (`data:...`, `javascript:...`). Never throws, never returns null.\n *\n * Routing does not care about scheme or authority, only about the path part.\n * This keeps `browser-plugin`, `navigation-plugin`, and `hash-plugin` working\n * in Electron (`file://`, `app://`), Tauri (`tauri://`, `https://`), and any\n * other webview that may ship with non-HTTP origins. See issue #496.\n */\nexport function safeParseUrl(url: string): ParsedUrl {\n let rest = url;\n\n const schemeIdx = rest.indexOf(\"://\");\n\n if (schemeIdx !== -1) {\n const authorityStart = schemeIdx + 3;\n let pathStart = rest.length;\n\n for (let i = authorityStart; i < rest.length; i++) {\n const ch = rest[i];\n\n if (ch === \"/\" || ch === \"?\" || ch === \"#\") {\n pathStart = i;\n\n break;\n }\n }\n\n rest = pathStart === rest.length ? \"/\" : rest.slice(pathStart);\n\n if (rest.startsWith(\"?\") || rest.startsWith(\"#\")) {\n rest = `/${rest}`;\n }\n }\n\n const hashIdx = rest.indexOf(\"#\");\n const hash = hashIdx === -1 ? \"\" : rest.slice(hashIdx);\n const beforeHash = hashIdx === -1 ? rest : rest.slice(0, hashIdx);\n\n const queryIdx = beforeHash.indexOf(\"?\");\n const search = queryIdx === -1 ? \"\" : beforeHash.slice(queryIdx);\n const pathname = queryIdx === -1 ? beforeHash : beforeHash.slice(0, queryIdx);\n\n return { pathname, search, hash };\n}\n","import { decodeHashFragment } from \"./url-context.js\";\nimport { safeParseUrl } from \"./url-parsing.js\";\n\nexport function extractPath(pathname: string, base: string): string {\n if (!pathname) {\n return \"/\";\n }\n\n if (base && (pathname === base || pathname.startsWith(`${base}/`))) {\n const stripped = pathname.slice(base.length);\n\n return stripped.startsWith(\"/\") ? stripped : `/${stripped}`;\n }\n\n return pathname.startsWith(\"/\") ? pathname : `/${pathname}`;\n}\n\nexport function buildUrl(path: string, base: string): string {\n if (!path) {\n return base;\n }\n\n if (!base) {\n return path.startsWith(\"/\") ? path : `/${path}`;\n }\n\n // Path \"/\" with a non-empty base would otherwise produce `\"${base}/\"` —\n // a trailing-slash URL (e.g. `/app/`). The canonical form of the base\n // (normalizeBase strips trailing slash) is `/app`, and the router's\n // `extractPath(\"/app\", \"/app\")` round-trips to `\"/\"` regardless. Collapse\n // the index case to the canonical base to keep URLs symmetric.\n if (path === \"/\") {\n return base;\n }\n\n return path.startsWith(\"/\") ? `${base}${path}` : `${base}/${path}`;\n}\n\nexport function urlToPath(url: string, base: string): string {\n const parsedUrl = safeParseUrl(url);\n\n return extractPath(parsedUrl.pathname, base) + parsedUrl.search;\n}\n\n/**\n * Like `urlToPath` but also returns the decoded URL fragment (#532).\n *\n * Used by URL plugins to extract `event.destination.url`'s hash without\n * dropping it the way `urlToPath` does. The hash is returned in decoded form\n * with no leading \"#\" — same form as stored in `state.context.url.hash`.\n */\nexport function urlToPathAndHash(\n url: string,\n base: string,\n): { path: string; hash: string } {\n const parsed = safeParseUrl(url);\n const path = extractPath(parsed.pathname, base) + parsed.search;\n const hash = parsed.hash ? decodeHashFragment(parsed.hash.slice(1)) : \"\";\n\n return { path, hash };\n}\n\n/**\n * Parses an absolute URL and returns its path + search, stripped of `base`.\n * Alias of {@link urlToPath} kept for call-site readability — history-query\n * paths (Navigation API entries, etc.) are absolute URLs by contract.\n */\nexport function extractPathFromAbsoluteUrl(url: string, base: string): string {\n return urlToPath(url, base);\n}\n","import type { NavigationPluginOptions } from \"./types\";\n\nexport const defaultOptions: Required<NavigationPluginOptions> = {\n // Default `false` respects `canDeactivate` guards on browser back/forward,\n // matching the documented contract of `browser-plugin` and the core router.\n // Apps that want the browser's native history buttons to bypass guards\n // (e.g. to avoid dead-end UX) can opt in via `forceDeactivate: true`.\n forceDeactivate: false,\n base: \"\",\n};\n\n/**\n * Source identifier for transitions triggered by navigate events.\n * Distinguishes browser-initiated navigation (back/forward, link clicks)\n * from programmatic navigation (router.navigate()).\n */\nexport const source = \"navigate\";\n\nexport const LOGGER_CONTEXT = \"navigation-plugin\";\n","import { safelyEncodePath, extractPath } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\n\n/**\n * Mutable cell carrying the \"syncing-from-router\" flag shared between\n * `wrapNavigationBrowserWithSyncing` (which raises it around every router-driven\n * mutation) and the plugin's navigate handler (which reads it to short-circuit\n * the event fired by the plugin's own write).\n *\n * Internal to navigation-plugin — not part of the public type surface.\n */\nexport interface SyncingFlag {\n current: boolean;\n}\n\n/**\n * Creates a NavigationBrowser wrapping the real Navigation API.\n * Only call this when `\"navigation\" in globalThis` is true.\n */\nexport function createNavigationBrowser(base: string): NavigationBrowser {\n const nav = globalThis.navigation;\n\n return {\n getLocation: () =>\n safelyEncodePath(extractPath(globalThis.location.pathname, base)) +\n globalThis.location.search,\n\n getHash: () => globalThis.location.hash,\n\n navigate: (url, options) => {\n nav.navigate(url, options);\n },\n\n replaceState: (state, url) => {\n nav.navigate(url, {\n state,\n history: \"replace\",\n });\n },\n\n updateCurrentEntry: (options) => {\n nav.updateCurrentEntry(options);\n },\n\n traverseTo: (key) => {\n nav.traverseTo(key);\n },\n\n addNavigateListener: (fn) => {\n nav.addEventListener(\"navigate\", fn);\n\n return () => {\n nav.removeEventListener(\"navigate\", fn);\n };\n },\n\n entries: () => nav.entries(),\n\n get currentEntry() {\n return nav.currentEntry;\n },\n\n getActivationType: () => nav.activation?.navigationType,\n };\n}\n\n/**\n * Wraps every router-driven mutation of a NavigationBrowser with the syncing\n * flag — raised before the underlying call, lowered after, including the\n * throw path. The plugin's navigate handler reads `syncing.current` to\n * short-circuit the navigate event fired by the plugin's own write\n * (`nav.navigate(...)` and `nav.navigate({history:\"replace\"})` both fire\n * navigate events synchronously).\n *\n * Applied at the factory level to both the built-in `createNavigationBrowser`\n * and any user-supplied browser, so consumers don't need to manage the flag.\n */\nexport function wrapNavigationBrowserWithSyncing(\n browser: NavigationBrowser,\n syncing: SyncingFlag,\n): NavigationBrowser {\n const wrap = <T>(fn: () => T): T => {\n syncing.current = true;\n try {\n return fn();\n } finally {\n syncing.current = false;\n }\n };\n\n return {\n getLocation: () => browser.getLocation(),\n getHash: () => browser.getHash(),\n\n navigate: (url, options) => {\n wrap(() => {\n browser.navigate(url, options);\n });\n },\n replaceState: (state, url) => {\n wrap(() => {\n browser.replaceState(state, url);\n });\n },\n updateCurrentEntry: (options) => {\n wrap(() => {\n browser.updateCurrentEntry(options);\n });\n },\n traverseTo: (key) => {\n wrap(() => {\n browser.traverseTo(key);\n });\n },\n\n addNavigateListener: (fn) => browser.addNavigateListener(fn),\n entries: () => browser.entries(),\n\n get currentEntry() {\n return browser.currentEntry;\n },\n\n getActivationType: () => browser.getActivationType(),\n };\n}\n","import { extractPathFromAbsoluteUrl } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\nimport type { State } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\n/**\n * Validates a candidate history entry for `traverseToLast(routeName)` and\n * returns both the entry (now known non-null) and the matched router state.\n * Extracted from `NavigationPlugin` so the three error branches (missing\n * entry, null url, unmatched url) can be tested directly without vi.spyOn\n * on module namespaces — the star-import spy pattern is fragile under ESM\n * and was working by accident in history-extensions.test.ts.\n *\n * Throws a descriptive Error on any failure; the caller (NavigationPlugin)\n * propagates it as the rejection of `traverseToLast`.\n */\nexport function resolveEntryToMatchedState(\n entry: NavigationHistoryEntry | undefined,\n routeName: string,\n api: PluginApi,\n base: string,\n): { entry: NavigationHistoryEntry; matchedState: State } {\n if (!entry) {\n throw new Error(`No history entry for route \"${routeName}\"`);\n }\n\n if (!entry.url) {\n throw new Error(`No matching route for entry URL \"${entry.url}\"`);\n }\n\n const path = extractPathFromAbsoluteUrl(entry.url, base);\n const matchedState = api.matchPath(path);\n\n if (!matchedState) {\n throw new Error(`No matching route for entry URL \"${entry.url}\"`);\n }\n\n return { entry, matchedState };\n}\n\n/**\n * Converts a NavigationHistoryEntry to a State via URL matching.\n * Uses URL matching (not entry.getState()) because:\n * - Entries before plugin init have no state\n * - Entries after router.replace(routes) may have stale state\n * - Entries from other SPAs on the same origin have foreign state\n */\nexport function entryToState(\n entry: NavigationHistoryEntry | undefined,\n api: PluginApi,\n base: string,\n): State | undefined {\n if (!entry?.url) {\n return undefined;\n }\n\n return (\n api.matchPath(extractPathFromAbsoluteUrl(entry.url, base)) ?? undefined\n );\n}\n\nfunction peekAt(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n offset: number,\n): State | undefined {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return undefined;\n }\n\n return entryToState(browser.entries()[idx + offset], api, base);\n}\n\nexport function peekBack(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): State | undefined {\n return peekAt(browser, api, base, -1);\n}\n\nexport function peekForward(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): State | undefined {\n return peekAt(browser, api, base, 1);\n}\n\nexport function hasVisited(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): boolean {\n return browser.entries().some((entry) => {\n const state = entryToState(entry, api, base);\n\n return state?.name === routeName;\n });\n}\n\nexport function getVisitedRoutes(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): string[] {\n const names = new Set<string>();\n\n for (const entry of browser.entries()) {\n const state = entryToState(entry, api, base);\n\n if (state) {\n names.add(state.name);\n }\n }\n\n return [...names];\n}\n\nexport function getRouteVisitCount(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): number {\n let count = 0;\n\n for (const entry of browser.entries()) {\n if (entryToState(entry, api, base)?.name === routeName) {\n count++;\n }\n }\n\n return count;\n}\n\n/**\n * Finds the last NavigationHistoryEntry matching the given route name,\n * excluding the current entry (to avoid SAME_STATES on traverseToLast(\"current-route\")).\n */\nexport function findLastEntryForRoute(\n entries: NavigationHistoryEntry[],\n routeName: string,\n api: PluginApi,\n base: string,\n currentKey: string | undefined,\n): NavigationHistoryEntry | undefined {\n for (let i = entries.length - 1; i >= 0; i--) {\n const entry = entries[i];\n\n if (entry.key === currentKey) {\n continue;\n }\n\n const state = entryToState(entry, api, base);\n\n if (state?.name === routeName) {\n return entry;\n }\n }\n\n return undefined;\n}\n\nexport function canGoBack(browser: NavigationBrowser): boolean {\n const idx = browser.currentEntry?.index;\n\n return idx != null && idx > 0;\n}\n\nexport function canGoForward(browser: NavigationBrowser): boolean {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return false;\n }\n\n return idx < browser.entries().length - 1;\n}\n\nexport function canGoBackTo(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): boolean {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return false;\n }\n\n const entries = browser.entries();\n\n for (let i = idx - 1; i >= 0; i--) {\n const state = entryToState(entries[i], api, base);\n\n if (state?.name === routeName) {\n return true;\n }\n }\n\n return false;\n}\n","import { errorCodes, RouterError } from \"@real-router/core\";\n\nimport { urlToPathAndHash } from \"./browser-env\";\n\nimport type {\n NavigationBrowser,\n NavigationDirection,\n NavigationMeta,\n} from \"./types\";\nimport type { Router } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\ninterface NavigateHandlerDeps {\n router: Router;\n api: PluginApi;\n browser: NavigationBrowser;\n isSyncingFromRouter: () => boolean;\n setCapturedMeta: (meta: NavigationMeta) => void;\n base: string;\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n };\n}\n\nexport function computeDirection(\n navigationType: NavigationMeta[\"navigationType\"],\n destinationIndex: number,\n currentIndex: number,\n): NavigationDirection {\n if (navigationType === \"traverse\") {\n if (destinationIndex === currentIndex) {\n return \"unknown\";\n }\n\n return destinationIndex > currentIndex ? \"forward\" : \"back\";\n }\n\n return navigationType === \"push\" ? \"forward\" : \"unknown\";\n}\n\nexport function createNavigateHandler(deps: NavigateHandlerDeps) {\n const { router, api, browser, isSyncingFromRouter, base, transitionOptions } =\n deps;\n const { allowNotFound } = api.getOptions();\n\n return function handleNavigateEvent(event: NavigateEvent): void {\n if (!event.canIntercept || !router.isActive()) {\n return;\n }\n\n if (isSyncingFromRouter()) {\n // Plugin-originated navigate event after its own successful transition\n // (onTransitionSuccess calls browser.navigate to sync URL). We must still\n // intercept — a bare `return` leaves the event un-intercepted, and\n // Chromium falls back to a cross-document navigation (full page reload).\n // The noop handler cancels the fallback without running router logic;\n // state is already committed.\n event.intercept({\n handler: async () => {},\n });\n\n return;\n }\n\n const { path, hash } = urlToPathAndHash(event.destination.url, base);\n const matchedState = api.matchPath(path);\n\n const navType = event.navigationType as NavigationMeta[\"navigationType\"];\n const currentIndex = browser.currentEntry?.index ?? -1;\n\n deps.setCapturedMeta({\n navigationType: navType,\n userInitiated: event.userInitiated,\n info: event.info,\n direction: computeDirection(\n navType,\n event.destination.index,\n currentIndex,\n ),\n sourceElement: event.sourceElement ?? null,\n });\n\n const withRecovery = async (run: () => Promise<unknown>): Promise<void> => {\n try {\n await run();\n } catch (error) {\n if (!(error instanceof RouterError)) {\n recoverFromNavigateError(error, router, browser);\n\n return;\n }\n\n // TRANSITION_CANCELLED: a newer navigation aborted this one — the\n // newer navigate event is (or will be) handled by this same plugin,\n // and THAT event is responsible for syncing URL/state. Firing our\n // own sync here races against it: browser.navigate(replace, same-url)\n // would cancel the in-flight newer transition, which is exactly the\n // rapid-fire-events storm failure mode.\n //\n // SAME_STATES: router refused because router.getState() already equals\n // the target. URL and router state are already consistent — no sync\n // needed.\n if (\n error.code === errorCodes.TRANSITION_CANCELLED ||\n error.code === errorCodes.SAME_STATES\n ) {\n return;\n }\n\n // Other RouterError codes (CANNOT_DEACTIVATE, CANNOT_ACTIVATE,\n // ROUTE_NOT_FOUND, …) — router rejected the transition, state is\n // unchanged, but URL may have already committed to a different\n // value by the Navigation API. Sync the URL back to the current\n // router state in a single visible transition (headless Chromium\n // and some cross-origin setups leave \"committed-then-reverted\"\n // windows if we relied on the native rollback via intercept reject).\n // Observers that care about the error see it through the router's\n // TRANSITION_ERROR event.\n syncUrlToRouterState(router, browser);\n }\n };\n\n if (matchedState) {\n event.intercept({\n handler: () =>\n withRecovery(() =>\n // api.navigateToState: matchPath already applied forwardState +\n // matchSourceTrailingSlash; reusing the State avoids the redundant\n // round-trip and preserves trailing slashes (#525). Plugin-only\n // entry point — not on the public Router/Navigator surface.\n //\n // Hash extraction (#532): pass through the destination's hash so\n // onTransitionSuccess sets state.context.url.hash. When the\n // browser fires hashChange (same-document fragment-only nav),\n // add force+hashChange to bypass SAME_STATES — subscribers\n // disambiguate via state.context.url.hashChanged, not via the\n // overloaded force flag.\n api.navigateToState(matchedState, {\n ...transitionOptions,\n hash,\n ...(event.hashChange ? { force: true, hashChange: true } : {}),\n signal: event.signal,\n }),\n ),\n });\n } else if (allowNotFound) {\n event.intercept({\n handler: () => {\n router.navigateToNotFound(path);\n },\n });\n } else {\n // Strict mode — unmatched URL is an error. Emit $$error and reject the\n // intercept so the Navigation API auto-rolls back the URL. No silent\n // fallback to defaultRoute.\n event.intercept({\n // eslint-disable-next-line @typescript-eslint/require-await -- Navigation API requires async handler; synchronous throw is the rollback signal\n handler: async () => {\n const err = new RouterError(errorCodes.ROUTE_NOT_FOUND, { path });\n\n api.emitTransitionError(err);\n\n throw err;\n },\n });\n }\n };\n}\n\nfunction recoverFromNavigateError(\n error: unknown,\n router: Router,\n browser: NavigationBrowser,\n): void {\n console.error(\n \"[navigation-plugin] Critical error in navigate handler\",\n error,\n );\n\n syncUrlToRouterState(router, browser);\n}\n\nfunction syncUrlToRouterState(\n router: Router,\n browser: NavigationBrowser,\n): void {\n try {\n const currentState = router.getState();\n\n if (currentState) {\n // Preserve hash on recovery (#532): reading from state.context.url\n // keeps the visible URL fragment intact when a guard rejects a hash-\n // bearing navigation.\n const ctxHash = (\n currentState.context as { url?: { hash?: string } } | undefined\n )?.url?.hash;\n const url = router.buildUrl(\n currentState.name,\n currentState.params,\n ctxHash ? { hash: ctxHash } : undefined,\n );\n\n // The syncing flag is raised/lowered inside NavigationBrowser around\n // browser.navigate, including the throw path — no manual try/finally\n // needed here.\n browser.navigate(url, {\n state: {\n name: currentState.name,\n params: currentState.params,\n path: currentState.path,\n },\n history: \"replace\",\n });\n }\n } catch (syncError) {\n console.error(\n \"[navigation-plugin] Failed to sync URL to router state\",\n syncError,\n );\n }\n}\n","import { UNKNOWN_ROUTE } from \"@real-router/core\";\n\nimport {\n shouldReplaceHistory,\n buildUrl,\n urlToPath,\n createStartInterceptor,\n createReplaceHistoryState,\n encodeHashFragment,\n getDecodedHash,\n normalizeHashInput,\n} from \"./browser-env\";\nimport {\n peekBack,\n peekForward,\n hasVisited,\n getVisitedRoutes,\n getRouteVisitCount,\n findLastEntryForRoute,\n resolveEntryToMatchedState,\n canGoBack,\n canGoForward,\n canGoBackTo,\n} from \"./history-extensions\";\nimport { createNavigateHandler } from \"./navigate-handler\";\nimport { wrapNavigationBrowserWithSyncing } from \"./navigation-browser\";\n\nimport type { UrlContext } from \"./browser-env\";\nimport type { SyncingFlag } from \"./navigation-browser\";\nimport type {\n NavigationBrowser,\n NavigationMeta,\n NavigationPluginOptions,\n NavigationSharedState,\n} from \"./types\";\nimport type {\n NavigationOptions,\n Params,\n Router,\n State,\n Plugin,\n} from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\nexport function deriveNavigationType(\n navOptions: NavigationOptions,\n toState: State,\n fromState: State | undefined,\n): NavigationMeta[\"navigationType\"] {\n if (navOptions.reload && toState.path === fromState?.path) {\n return \"reload\";\n }\n\n if (shouldReplaceHistory(navOptions, toState, fromState)) {\n return \"replace\";\n }\n\n return \"push\";\n}\n\nexport class NavigationPlugin {\n readonly #router: Router;\n readonly #api: PluginApi;\n readonly #options: Required<NavigationPluginOptions>;\n readonly #browser: NavigationBrowser;\n readonly #removeStartInterceptor: () => void;\n readonly #removeExtensions: () => void;\n readonly #claim: {\n write: (state: State, value: NavigationMeta) => void;\n release: () => void;\n };\n readonly #urlClaim: {\n write: (state: State, value: UrlContext) => void;\n release: () => void;\n };\n readonly #lifecycle: Pick<Plugin, \"onStart\" | \"onStop\" | \"teardown\">;\n readonly #syncing: SyncingFlag = { current: false };\n\n #capturedMeta: NavigationMeta | undefined;\n #pendingTraverseKey: string | undefined;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<NavigationPluginOptions>,\n browser: NavigationBrowser,\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n },\n shared: NavigationSharedState,\n ) {\n this.#router = router;\n this.#api = api;\n this.#options = options;\n // Wrap mutations with the syncing flag so the navigate handler can\n // short-circuit re-entrant events fired by the plugin's own writes\n // (`nav.navigate` and `nav.navigate({history:\"replace\"})` fire navigate\n // events synchronously). The flag is per-instance — never shared across\n // plugins — so multiple routers running concurrent transitions don't\n // bleed syncing state into each other.\n this.#browser = wrapNavigationBrowserWithSyncing(browser, this.#syncing);\n\n this.#claim = api.claimContextNamespace(\"navigation\");\n this.#urlClaim = api.claimContextNamespace(\"url\");\n this.#removeStartInterceptor = createStartInterceptor(api, this.#browser);\n\n // Cross-document load priming (#531). On F5, browser back/forward across\n // a page boundary, or a fresh URL bar entry, the prior JS context is\n // discarded — the navigate event handler never sees the activation.\n // Without this, deriveNavigationType in onTransitionSuccess falls through\n // to \"replace\" for every initial transition, breaking scroll restore on\n // reload (#497) and any consumer branching on navigationType.\n // navigation.activation reflects the cross-document navigation that\n // activated this document; it stays constant across same-document\n // navigations, so this only affects the FIRST transition.\n const activationType = this.#browser.getActivationType();\n\n if (activationType) {\n this.#capturedMeta = {\n navigationType: activationType,\n userInitiated: false,\n direction: activationType === \"push\" ? \"forward\" : \"unknown\",\n sourceElement: null,\n };\n }\n\n // Hash for the first transition (#532) is read lazily inside\n // onTransitionSuccess via `getDecodedHash(browser)` — capturing in the\n // constructor is too eager (in tests, the mock URL is set after the\n // plugin is constructed). The lazy read still covers F5 / fresh URL\n // bar entry: by the time onTransitionSuccess fires the browser already\n // reflects the destination URL.\n\n const pluginBuildUrl = (\n route: string,\n params?: Params,\n opts?: { hash?: string },\n ) => {\n const path = router.buildPath(route, params);\n const url = buildUrl(path, options.base);\n\n if (opts?.hash === undefined) {\n return url;\n }\n\n const norm = normalizeHashInput(opts.hash);\n\n return norm ? `${url}#${encodeHashFragment(norm)}` : url;\n };\n\n this.#removeExtensions = api.extendRouter({\n buildUrl: pluginBuildUrl,\n matchUrl: (url: string) =>\n api.matchPath(urlToPath(url, options.base)) ?? undefined,\n replaceHistoryState: createReplaceHistoryState(\n api,\n router,\n this.#browser,\n pluginBuildUrl,\n ),\n\n peekBack: () => peekBack(this.#browser, api, options.base),\n peekForward: () => peekForward(this.#browser, api, options.base),\n hasVisited: (routeName: string) =>\n hasVisited(this.#browser, api, options.base, routeName),\n getVisitedRoutes: () =>\n getVisitedRoutes(this.#browser, api, options.base),\n getRouteVisitCount: (routeName: string) =>\n getRouteVisitCount(this.#browser, api, options.base, routeName),\n traverseToLast: (routeName: string) => this.traverseToLast(routeName),\n canGoBack: () => canGoBack(this.#browser),\n canGoForward: () => canGoForward(this.#browser),\n canGoBackTo: (routeName: string) =>\n canGoBackTo(this.#browser, api, options.base, routeName),\n });\n\n const handler = createNavigateHandler({\n router,\n api,\n browser: this.#browser,\n isSyncingFromRouter: () => this.#syncing.current,\n setCapturedMeta: (meta) => {\n this.#capturedMeta = meta;\n },\n base: options.base,\n transitionOptions,\n });\n\n this.#lifecycle = createNavigateLifecycle({\n browser: this.#browser,\n shared,\n handler,\n removeStartInterceptor: this.#removeStartInterceptor,\n removeExtensions: this.#removeExtensions,\n releaseClaim: () => {\n this.#claim.release();\n this.#urlClaim.release();\n },\n });\n }\n\n async traverseToLast(routeName: string): Promise<State> {\n const entries = this.#browser.entries();\n const currentKey = this.#browser.currentEntry?.key;\n const candidate = findLastEntryForRoute(\n entries,\n routeName,\n this.#api,\n this.#options.base,\n currentKey,\n );\n\n // resolveEntryToMatchedState throws for missing entry, null url, or\n // unmatched url — same three error branches the old inline checks\n // produced. Extracted so the error paths can be unit-tested directly\n // without namespace-level vi.spyOn gymnastics.\n const { entry, matchedState } = resolveEntryToMatchedState(\n candidate,\n routeName,\n this.#api,\n this.#options.base,\n );\n\n const currentEntry = this.#browser.currentEntry;\n\n if (!currentEntry) {\n // Invariant violation: traverseToLast is only callable after\n // router.start(), which guarantees a current entry. A null here means\n // the plugin was stopped mid-call or the browser abstraction is\n // broken — either way, silently picking direction \"forward\" from a\n // fallback `-1` would mask the bug. Fail loudly instead.\n throw new Error(\n `[navigation-plugin] Cannot determine direction for traverseToLast(\"${routeName}\"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`,\n );\n }\n\n this.#capturedMeta = {\n navigationType: \"traverse\",\n userInitiated: false,\n direction: entry.index > currentEntry.index ? \"forward\" : \"back\",\n sourceElement: null,\n };\n this.#pendingTraverseKey = entry.key;\n\n return this.#router.navigate(matchedState.name, matchedState.params);\n }\n\n getPlugin(): Plugin {\n return {\n ...this.#lifecycle,\n\n onTransitionStart: (toState: State) => {\n if (this.#capturedMeta) {\n this.#claim.write(toState, this.#capturedMeta);\n }\n },\n\n onTransitionSuccess: (\n toState: State,\n fromState: State | undefined,\n navOptions: NavigationOptions,\n ) => {\n if (!this.#capturedMeta) {\n const navigationType = deriveNavigationType(\n navOptions,\n toState,\n fromState,\n );\n\n this.#capturedMeta = {\n navigationType,\n userInitiated: false,\n direction: navigationType === \"push\" ? \"forward\" : \"unknown\",\n sourceElement: null,\n };\n }\n\n const frozenMeta = Object.freeze(this.#capturedMeta);\n\n this.#claim.write(toState, frozenMeta);\n this.#capturedMeta = undefined;\n\n // Consume pendingTraverseKey BEFORE calling browser.traverseTo.\n // If traverseTo throws (Navigation API can reject on evicted keys\n // under memory pressure), we must not leave the stale key behind —\n // otherwise the NEXT transition's onTransitionSuccess would see it\n // and replay the traverse against the same already-broken key.\n // The syncing flag is raised/lowered inside NavigationBrowser around\n // each mutation, so we do not need to manage it here.\n const traverseKey = this.#pendingTraverseKey;\n\n this.#pendingTraverseKey = undefined;\n\n if (traverseKey) {\n this.#browser.traverseTo(traverseKey);\n } else {\n // Tri-state hash resolution (#532).\n // navOptions.hash === undefined → preserve current browser hash\n // navOptions.hash === \"\" → explicitly clear\n // navOptions.hash === \"value\" → explicitly set\n //\n // The \"preserve\" branch reads location.hash from the browser, not\n // fromState.context.url.hash — this captures dynamic fragment\n // changes the user makes outside the plugin (anchor clicks,\n // manual location.hash assignment) instead of replaying the\n // last-published value.\n //\n // hashChanged compares the chosen hash against the *published*\n // previous hash (fromState.context.url.hash), so subscribers see\n // a true signal regardless of whether the value came from\n // navOptions or the browser.\n const browserHash = getDecodedHash(this.#browser);\n const publishedPrevHash =\n (fromState?.context as { url?: { hash?: string } } | undefined)?.url\n ?.hash ?? \"\";\n\n const hash =\n navOptions.hash === undefined\n ? browserHash\n : normalizeHashInput(navOptions.hash);\n\n this.#urlClaim.write(\n toState,\n Object.freeze({\n hash,\n hashChanged: navOptions.hashChange ?? hash !== publishedPrevHash,\n }),\n );\n\n const url = buildUrl(toState.path, this.#options.base);\n const finalUrl = hash ? `${url}#${encodeHashFragment(hash)}` : url;\n const historyState = {\n name: toState.name,\n params: toState.params,\n path: toState.path,\n };\n\n if (toState.name === UNKNOWN_ROUTE) {\n this.#browser.updateCurrentEntry({ state: historyState });\n } else {\n const replace = frozenMeta.navigationType !== \"push\";\n\n this.#browser.navigate(finalUrl, {\n state: historyState,\n history: replace ? \"replace\" : \"push\",\n });\n }\n }\n },\n\n onTransitionCancel: () => {\n this.#capturedMeta = undefined;\n this.#pendingTraverseKey = undefined;\n },\n\n onTransitionError: () => {\n this.#capturedMeta = undefined;\n this.#pendingTraverseKey = undefined;\n },\n };\n }\n}\n\ninterface NavigateLifecycleDeps {\n browser: NavigationBrowser;\n handler: (event: NavigateEvent) => void;\n removeStartInterceptor: () => void;\n removeExtensions: () => void;\n releaseClaim: () => void;\n shared: NavigationSharedState;\n}\n\nfunction createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {\n return {\n onStart() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = deps.browser.addNavigateListener(\n deps.handler,\n );\n },\n\n onStop() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = undefined;\n },\n\n teardown() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = undefined;\n deps.removeStartInterceptor();\n deps.removeExtensions();\n deps.releaseClaim();\n },\n };\n}\n","import { createWarnOnce } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\n\nconst NOOP = (): void => {};\n\nexport const createNavigationFallbackBrowser = (\n context: string,\n): NavigationBrowser => {\n const warnOnce = createWarnOnce(context);\n\n return {\n getLocation: () => {\n warnOnce(\"getLocation\");\n\n return \"/\";\n },\n getHash: () => {\n warnOnce(\"getHash\");\n\n return \"\";\n },\n navigate: () => {\n warnOnce(\"navigate\");\n },\n replaceState: () => {\n warnOnce(\"replaceState\");\n },\n updateCurrentEntry: () => {\n warnOnce(\"updateCurrentEntry\");\n },\n traverseTo: () => {\n warnOnce(\"traverseTo\");\n },\n addNavigateListener: () => {\n warnOnce(\"addNavigateListener\");\n\n return NOOP;\n },\n entries: () => {\n warnOnce(\"entries\");\n\n return [];\n },\n currentEntry: null,\n getActivationType: () => undefined,\n };\n};\n","import { createOptionsValidator, safeBaseRule } from \"./browser-env\";\nimport { LOGGER_CONTEXT, defaultOptions } from \"./constants\";\n\nimport type { NavigationPluginOptions } from \"./types\";\n\nexport const validateOptions = createOptionsValidator<NavigationPluginOptions>(\n defaultOptions,\n LOGGER_CONTEXT,\n { base: safeBaseRule },\n);\n","import { getPluginApi } from \"@real-router/core/api\";\n\nimport { isBrowserEnvironment, normalizeBase } from \"./browser-env\";\nimport { defaultOptions, source } from \"./constants\";\nimport { createNavigationBrowser } from \"./navigation-browser\";\nimport { NavigationPlugin } from \"./plugin\";\nimport { createNavigationFallbackBrowser } from \"./ssr-fallback\";\nimport { validateOptions } from \"./validation\";\n\nimport type {\n NavigationPluginOptions,\n NavigationBrowser,\n NavigationSharedState,\n} from \"./types\";\nimport type { PluginFactory, Router } from \"@real-router/core\";\n\nexport function navigationPluginFactory(\n opts?: Partial<NavigationPluginOptions>,\n browser?: NavigationBrowser,\n): PluginFactory {\n if (!browser && isBrowserEnvironment() && !(\"navigation\" in globalThis)) {\n throw new Error(\n \"[navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.\",\n );\n }\n\n validateOptions(opts);\n\n const options: Required<NavigationPluginOptions> = {\n ...defaultOptions,\n ...opts,\n };\n\n options.base = normalizeBase(options.base);\n\n const resolvedBrowser = browser ?? createBrowser(options.base);\n\n const forceDeactivate = options.forceDeactivate;\n const transitionOptions = { forceDeactivate, source, replace: true as const };\n const shared: NavigationSharedState = { removeNavigateListener: undefined };\n\n return (routerBase) => {\n const api = getPluginApi(routerBase);\n\n const plugin = new NavigationPlugin(\n routerBase as Router,\n api,\n options,\n resolvedBrowser,\n transitionOptions,\n shared,\n );\n\n return plugin.getPlugin();\n };\n}\n\nfunction createBrowser(base: string): NavigationBrowser {\n if (\"navigation\" in globalThis) {\n return createNavigationBrowser(base);\n }\n\n return createNavigationFallbackBrowser(\"navigation-plugin\");\n}\n"],"mappings":"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"}
@@ -63,6 +63,24 @@ interface NavigationMeta {
63
63
  sourceElement: Element | null;
64
64
  }
65
65
  //#endregion
66
+ //#region ../../shared/browser-env/url-context.d.ts
67
+ /**
68
+ * URL fragment ("hash") shared layer (#532).
69
+ *
70
+ * Both URL plugins (navigation-plugin, browser-plugin) claim the `"url"`
71
+ * `state.context` namespace and write `UrlContext` on every transition.
72
+ * Mutually exclusive at runtime — only one URL plugin is installed per router.
73
+ *
74
+ * Hash form: decoded, no leading "#" — symmetric to `params` (no leading "?").
75
+ * Encoding to/from URL form happens at the boundary (URL build / URL parse).
76
+ */
77
+ interface UrlContext {
78
+ /** Decoded fragment, no leading "#". Empty string when URL has no fragment. */
79
+ hash: string;
80
+ /** Whether `hash` differs from the previous transition's `state.context.url.hash`. */
81
+ hashChanged: boolean;
82
+ }
83
+ //#endregion
66
84
  //#region src/factory.d.ts
67
85
  declare function navigationPluginFactory(opts?: Partial<NavigationPluginOptions>, browser?: NavigationBrowser): PluginFactory;
68
86
  //#endregion
@@ -70,22 +88,44 @@ declare function navigationPluginFactory(opts?: Partial<NavigationPluginOptions>
70
88
  declare module "@real-router/types" {
71
89
  interface StateContext {
72
90
  navigation?: NavigationMeta;
91
+ /**
92
+ * URL fragment ("hash") layer state (#532). Populated by both URL plugins
93
+ * (navigation-plugin, browser-plugin) — they are mutually exclusive at
94
+ * runtime, so only one writes to this namespace.
95
+ */
96
+ url?: UrlContext;
97
+ }
98
+ interface NavigationOptions {
99
+ /**
100
+ * URL fragment override (decoded, no leading "#") (#532).
101
+ * Tri-state: `undefined` → preserve current; `""` → clear; non-empty → set.
102
+ */
103
+ hash?: string;
104
+ /**
105
+ * @internal — set by URL plugins on hash-only browser-driven navigation.
106
+ * Subscribers should branch on `state.context.url.hashChanged` instead.
107
+ */
108
+ hashChange?: boolean;
73
109
  }
74
110
  }
75
111
  declare module "@real-router/core" {
76
112
  interface Router {
77
- buildUrl: (name: string, params?: Params) => string;
78
- matchUrl: (url: string) => State | undefined;
79
- replaceHistoryState: (name: string, params?: Params) => void;
80
- peekBack: () => State | undefined;
81
- peekForward: () => State | undefined;
82
- hasVisited: (routeName: string) => boolean;
83
- getVisitedRoutes: () => string[];
84
- getRouteVisitCount: (routeName: string) => number;
85
- traverseToLast: (routeName: string) => Promise<State>;
86
- canGoBack: () => boolean;
87
- canGoForward: () => boolean;
88
- canGoBackTo: (routeName: string) => boolean;
113
+ buildUrl(name: string, params?: Params, options?: {
114
+ hash?: string;
115
+ }): string;
116
+ matchUrl(url: string): State | undefined;
117
+ replaceHistoryState(name: string, params?: Params, options?: {
118
+ hash?: string;
119
+ }): void;
120
+ peekBack(): State | undefined;
121
+ peekForward(): State | undefined;
122
+ hasVisited(routeName: string): boolean;
123
+ getVisitedRoutes(): string[];
124
+ getRouteVisitCount(routeName: string): number;
125
+ traverseToLast(routeName: string): Promise<State>;
126
+ canGoBack(): boolean;
127
+ canGoForward(): boolean;
128
+ canGoBackTo(routeName: string): boolean;
89
129
  start(path?: string): Promise<State>;
90
130
  }
91
131
  } //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/types.ts","../../src/factory.ts","../../src/index.ts"],"mappings":";;;;;;AAIA;UAAiB,uBAAA;;;;AAoBjB;;EAdE,eAAA;EAwBgC;;;;;EAjBhC,IAAA;AAAA;;;;;UAOe,iBAAA;EACf,WAAA;EACA,OAAA;EACA,QAAA,GACE,GAAA,UACA,OAAA;IAAW,KAAA;IAAgB,OAAA;EAAA;EAE7B,YAAA,GAAe,KAAA,WAAgB,GAAA;EAC/B,kBAAA,GAAqB,OAAA;IAAW,KAAA;EAAA;EAChC,UAAA,GAAa,GAAA;EACb,mBAAA,GAAsB,EAAA,GAAK,GAAA,EAAK,aAAA;EAChC,OAAA,QAAe,sBAAA;EACf,YAAA,EAAc,sBAAA;EADC;;;;;EAOf,iBAAA,QAAyB,cAAA;AAAA;AAAA,KAWf,mBAAA;;;;;UAMK,cAAA;EAQJ;EANX,cAAA;EAQe;EANf,aAAA;EAMsB;EAJtB,IAAA;;EAEA,SAAA,EAAW,mBAAA;ECnDG;EDqDd,aAAA,EAAe,OAAA;AAAA;;;iBCrDD,uBAAA,CACd,IAAA,GAAO,OAAA,CAAQ,uBAAA,GACf,OAAA,GAAU,iBAAA,GACT,aAAA;;;;YCLS,YAAA;IACR,UAAA,GAJa,cAAA;EAAA;AAAA;AAAA;EAAA,UASL,MAAA;IACR,QAAA,GAAW,IAAA,UAAc,MAAA,GAAS,MAAA;IAClC,QAAA,GAAW,GAAA,aAAgB,KAAA;IAC3B,mBAAA,GAAsB,IAAA,UAAc,MAAA,GAAS,MAAA;IAC7C,QAAA,QAAgB,KAAA;IAChB,WAAA,QAAmB,KAAA;IACnB,UAAA,GAAa,SAAA;IACb,gBAAA;IACA,kBAAA,GAAqB,SAAA;IACrB,cAAA,GAAiB,SAAA,aAAsB,OAAA,CAAQ,KAAA;IAC/C,SAAA;IACA,YAAA;IACA,WAAA,GAAc,SAAA;IACd,KAAA,CAAM,IAAA,YAAgB,OAAA,CAAQ,KAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/types.ts","../../../../shared/browser-env/url-context.ts","../../src/factory.ts","../../src/index.ts"],"mappings":";;;;;;;UAIiB,uBAAA;EAAuB;;;;AAoBxC;EAdE,eAAA;;;;;;EAOA,IAAA;AAAA;;;;;UAOe,iBAAA;EACf,WAAA;EACA,OAAA;EACA,QAAA,GACE,GAAA,UACA,OAAA;IAAW,KAAA;IAAgB,OAAA;EAAA;EAE7B,YAAA,GAAe,KAAA,WAAgB,GAAA;EAC/B,kBAAA,GAAqB,OAAA;IAAW,KAAA;EAAA;EAChC,UAAA,GAAa,GAAA;EACb,mBAAA,GAAsB,EAAA,GAAK,GAAA,EAAK,aAAA;EAChC,OAAA,QAAe,sBAAA;EACf,YAAA,EAAc,sBAAA;EADd;;;;;EAOA,iBAAA,QAAyB,cAAA;AAAA;AAAA,KAWf,mBAAA;;;;;UAMK,cAAA;EAQf;EANA,cAAA;EAQA;EANA,aAAA;EAMsB;EAJtB,IAAA;;EAEA,SAAA,EAAW,mBAAA;;EAEX,aAAA,EAAe,OAAA;AAAA;;;;;;;AAjEjB;;;;;AAoBA;UCbiB,UAAA;;EAEf,IAAA;EDsBe;ECpBf,WAAA;AAAA;;;iBCCc,uBAAA,CACd,IAAA,GAAO,OAAA,CAAQ,uBAAA,GACf,OAAA,GAAU,iBAAA,GACT,aAAA;;;;YCLS,YAAA;IACR,UAAA,GAJa,cAAA;IHPuB;;;AAoBxC;;IGHI,GAAA,GAN6C,UAAA;EAAA;EAAA,UASrC,iBAAA;IHYI;;;;IGPZ,IAAA;IHHF;;;;IGQE,UAAA;EAAA;AAAA;AAAA;EAAA,UAKQ,MAAA;IACR,QAAA,CACE,IAAA,UACA,MAAA,GAAS,MAAA,EACT,OAAA;MAAY,IAAA;IAAA;IAEd,QAAA,CAAS,GAAA,WAAc,KAAA;IACvB,mBAAA,CACE,IAAA,UACA,MAAA,GAAS,MAAA,EACT,OAAA;MAAY,IAAA;IAAA;IAEd,QAAA,IAAY,KAAA;IACZ,WAAA,IAAe,KAAA;IACf,UAAA,CAAW,SAAA;IACX,gBAAA;IACA,kBAAA,CAAmB,SAAA;IACnB,cAAA,CAAe,SAAA,WAAoB,OAAA,CAAQ,KAAA;IAC3C,SAAA;IACA,YAAA;IACA,WAAA,CAAY,SAAA;IACZ,KAAA,CAAM,IAAA,YAAgB,OAAA,CAAQ,KAAA;EAAA;AAAA"}
@@ -1,2 +1,2 @@
1
- import{getPluginApi as e}from"@real-router/core/api";import{RouterError as t,UNKNOWN_ROUTE as n,errorCodes as r}from"@real-router/core";const i=()=>globalThis.window!==void 0&&!!globalThis.history;function a(e){if(!e)return e;let t=e.replaceAll(/\/+/g,`/`);return t.startsWith(`/`)||(t=`/${t}`),t.length>1&&t.endsWith(`/`)&&(t=t.slice(0,-1)),t===`/`?``:t}const o=e=>{try{return encodeURI(decodeURI(e))}catch(t){return console.warn(`[browser-env] Could not encode path "${e}"`,t),e}},s=e=>{let t=!1;return n=>{t||=(console.warn(`[browser-env] Browser API is running in a non-browser environment (context: "${e}"). Method "${n}" is a no-op. This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`),!0)}};function c(e,t,n){return r=>{if(r)for(let i of Object.keys(r)){if(!(i in e))continue;let a=r[i];if(a===void 0)continue;let o=typeof e[i],s=typeof a;if(s!==o)throw Error(`[${t}] Invalid type for '${i}': expected ${o}, got ${s}`);let c=n?.[i];if(c){let e=c.validate(a);if(e!==null)throw Error(`[${t}] Invalid '${i}': ${e}`)}}}}const l=/[\u0000-\u001F\u007F]/,u={validate:e=>l.test(e)?`must not contain control characters`:e.split(`/`).includes(`..`)?`must not contain '..' segments`:null};function d(e,t){return e.addInterceptor(`start`,(e,n)=>e(n??t.getLocation()))}function f(e,t,n,r,i=!0){let a={name:``,params:{},path:``};return(o,s={})=>{let c=e.buildState(o,s);if(!c)throw Error(`[real-router] Cannot replace state: route "${o}" is not found`);let l=e.makeState(c.name,c.params,t.buildPath(c.name,c.params),{params:c.meta}),u=i?n.getHash():``,d=r(o,s)+u;a.name=l.name,a.params=l.params,a.path=l.path,n.replaceState(a,d)}}function p(e,t,n){return e.replace===!0?!0:n?!!e.reload&&t.path===n.path:e.replace!==!1}function m(e){let t=e,n=t.indexOf(`://`);if(n!==-1){let e=n+3,r=t.length;for(let n=e;n<t.length;n++){let e=t[n];if(e===`/`||e===`?`||e===`#`){r=n;break}}t=r===t.length?`/`:t.slice(r),(t.startsWith(`?`)||t.startsWith(`#`))&&(t=`/${t}`)}let r=t.indexOf(`#`),i=r===-1?``:t.slice(r),a=r===-1?t:t.slice(0,r),o=a.indexOf(`?`),s=o===-1?``:a.slice(o);return{pathname:o===-1?a:a.slice(0,o),search:s,hash:i}}function h(e,t){if(!e)return`/`;if(t&&(e===t||e.startsWith(`${t}/`))){let n=e.slice(t.length);return n.startsWith(`/`)?n:`/${n}`}return e.startsWith(`/`)?e:`/${e}`}function g(e,t){return e?t?e===`/`?t:e.startsWith(`/`)?`${t}${e}`:`${t}/${e}`:e.startsWith(`/`)?e:`/${e}`:t}function _(e,t){let n=m(e);return h(n.pathname,t)+n.search}function v(e,t){return _(e,t)}const y={forceDeactivate:!1,base:``};function b(e){let t=globalThis.navigation;return{getLocation:()=>o(h(globalThis.location.pathname,e))+globalThis.location.search,getHash:()=>globalThis.location.hash,navigate:(e,n)=>{t.navigate(e,n)},replaceState:(e,n)=>{t.navigate(n,{state:e,history:`replace`})},updateCurrentEntry:e=>{t.updateCurrentEntry(e)},traverseTo:e=>{t.traverseTo(e)},addNavigateListener:e=>(t.addEventListener(`navigate`,e),()=>{t.removeEventListener(`navigate`,e)}),entries:()=>t.entries(),get currentEntry(){return t.currentEntry},getActivationType:()=>t.activation?.navigationType}}function x(e,t){let n=e=>{t.current=!0;try{return e()}finally{t.current=!1}};return{getLocation:()=>e.getLocation(),getHash:()=>e.getHash(),navigate:(t,r)=>{n(()=>{e.navigate(t,r)})},replaceState:(t,r)=>{n(()=>{e.replaceState(t,r)})},updateCurrentEntry:t=>{n(()=>{e.updateCurrentEntry(t)})},traverseTo:t=>{n(()=>{e.traverseTo(t)})},addNavigateListener:t=>e.addNavigateListener(t),entries:()=>e.entries(),get currentEntry(){return e.currentEntry},getActivationType:()=>e.getActivationType()}}function S(e,t,n,r){if(!e)throw Error(`No history entry for route "${t}"`);if(!e.url)throw Error(`No matching route for entry URL "${e.url}"`);let i=v(e.url,r),a=n.matchPath(i);if(!a)throw Error(`No matching route for entry URL "${e.url}"`);return{entry:e,matchedState:a}}function C(e,t,n){if(e?.url)return t.matchPath(v(e.url,n))??void 0}function w(e,t,n,r){let i=e.currentEntry?.index;if(i!=null)return C(e.entries()[i+r],t,n)}function T(e,t,n){return w(e,t,n,-1)}function E(e,t,n){return w(e,t,n,1)}function D(e,t,n,r){return e.entries().some(e=>C(e,t,n)?.name===r)}function O(e,t,n){let r=new Set;for(let i of e.entries()){let e=C(i,t,n);e&&r.add(e.name)}return[...r]}function k(e,t,n,r){let i=0;for(let a of e.entries())C(a,t,n)?.name===r&&i++;return i}function A(e,t,n,r,i){for(let a=e.length-1;a>=0;a--){let o=e[a];if(o.key!==i&&C(o,n,r)?.name===t)return o}}function j(e){let t=e.currentEntry?.index;return t!=null&&t>0}function M(e){let t=e.currentEntry?.index;return t==null?!1:t<e.entries().length-1}function N(e,t,n,r){let i=e.currentEntry?.index;if(i==null)return!1;let a=e.entries();for(let e=i-1;e>=0;e--)if(C(a[e],t,n)?.name===r)return!0;return!1}function P(e,t,n){return e===`traverse`?t===n?`unknown`:t>n?`forward`:`back`:e===`push`?`forward`:`unknown`}function F(e){let{router:n,api:i,browser:a,isSyncingFromRouter:o,base:s,transitionOptions:c}=e,{allowNotFound:l}=i.getOptions();return function(u){if(!u.canIntercept||!n.isActive())return;if(o()){u.intercept({handler:async()=>{}});return}let d=_(u.destination.url,s),f=i.matchPath(d),p=u.navigationType,m=a.currentEntry?.index??-1;e.setCapturedMeta({navigationType:p,userInitiated:u.userInitiated,info:u.info,direction:P(p,u.destination.index,m),sourceElement:u.sourceElement??null});let h=async e=>{try{await e()}catch(e){if(!(e instanceof t)){I(e,n,a);return}if(e.code===r.TRANSITION_CANCELLED||e.code===r.SAME_STATES)return;L(n,a)}};f?u.intercept({handler:()=>h(()=>i.navigateToState(f,{...c,signal:u.signal}))}):l?u.intercept({handler:()=>{n.navigateToNotFound(d)}}):u.intercept({handler:async()=>{let e=new t(r.ROUTE_NOT_FOUND,{path:d});throw i.emitTransitionError(e),e}})}}function I(e,t,n){console.error(`[navigation-plugin] Critical error in navigate handler`,e),L(t,n)}function L(e,t){try{let n=e.getState();if(n){let r=e.buildUrl(n.name,n.params);t.navigate(r,{state:{name:n.name,params:n.params,path:n.path},history:`replace`})}}catch(e){console.error(`[navigation-plugin] Failed to sync URL to router state`,e)}}function R(e,t,n){return e.reload&&t.path===n?.path?`reload`:p(e,t,n)?`replace`:`push`}var z=class{#e;#t;#n;#r;#i;#a;#o;#s;#c={current:!1};#l;#u;constructor(e,t,n,r,i,a){this.#e=e,this.#t=t,this.#n=n,this.#r=x(r,this.#c),this.#o=t.claimContextNamespace(`navigation`),this.#i=d(t,this.#r);let o=this.#r.getActivationType();o&&(this.#l={navigationType:o,userInitiated:!1,direction:o===`push`?`forward`:`unknown`,sourceElement:null});let s=(t,r)=>g(e.buildPath(t,r),n.base);this.#a=t.extendRouter({buildUrl:s,matchUrl:e=>t.matchPath(_(e,n.base))??void 0,replaceHistoryState:f(t,e,this.#r,s),peekBack:()=>T(this.#r,t,n.base),peekForward:()=>E(this.#r,t,n.base),hasVisited:e=>D(this.#r,t,n.base,e),getVisitedRoutes:()=>O(this.#r,t,n.base),getRouteVisitCount:e=>k(this.#r,t,n.base,e),traverseToLast:e=>this.traverseToLast(e),canGoBack:()=>j(this.#r),canGoForward:()=>M(this.#r),canGoBackTo:e=>N(this.#r,t,n.base,e)});let c=F({router:e,api:t,browser:this.#r,isSyncingFromRouter:()=>this.#c.current,setCapturedMeta:e=>{this.#l=e},base:n.base,transitionOptions:i});this.#s=B({browser:this.#r,shared:a,handler:c,removeStartInterceptor:this.#i,removeExtensions:this.#a,releaseClaim:()=>{this.#o.release()}})}async traverseToLast(e){let t=this.#r.entries(),n=this.#r.currentEntry?.key,{entry:r,matchedState:i}=S(A(t,e,this.#t,this.#n.base,n),e,this.#t,this.#n.base),a=this.#r.currentEntry;if(!a)throw Error(`[navigation-plugin] Cannot determine direction for traverseToLast("${e}"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`);return this.#l={navigationType:`traverse`,userInitiated:!1,direction:r.index>a.index?`forward`:`back`,sourceElement:null},this.#u=r.key,this.#e.navigate(i.name,i.params)}getPlugin(){return{...this.#s,onTransitionStart:e=>{this.#l&&this.#o.write(e,this.#l)},onTransitionSuccess:(e,t,r)=>{if(!this.#l){let n=R(r,e,t);this.#l={navigationType:n,userInitiated:!1,direction:n===`push`?`forward`:`unknown`,sourceElement:null}}let i=Object.freeze(this.#l);this.#o.write(e,i),this.#l=void 0;let a=this.#u;if(this.#u=void 0,a)this.#r.traverseTo(a);else{let r=g(e.path,this.#n.base),a=!t||t.path===e.path?this.#r.getHash():``,o=a?r+a:r,s={name:e.name,params:e.params,path:e.path};if(e.name===n)this.#r.updateCurrentEntry({state:s});else{let e=i.navigationType!==`push`;this.#r.navigate(o,{state:s,history:e?`replace`:`push`})}}},onTransitionCancel:()=>{this.#l=void 0,this.#u=void 0},onTransitionError:()=>{this.#l=void 0,this.#u=void 0}}}};function B(e){return{onStart(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=e.browser.addNavigateListener(e.handler)},onStop(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=void 0},teardown(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=void 0,e.removeStartInterceptor(),e.removeExtensions(),e.releaseClaim()}}}const V=()=>{},H=e=>{let t=s(e);return{getLocation:()=>(t(`getLocation`),`/`),getHash:()=>(t(`getHash`),``),navigate:()=>{t(`navigate`)},replaceState:()=>{t(`replaceState`)},updateCurrentEntry:()=>{t(`updateCurrentEntry`)},traverseTo:()=>{t(`traverseTo`)},addNavigateListener:()=>(t(`addNavigateListener`),V),entries:()=>(t(`entries`),[]),currentEntry:null,getActivationType:()=>void 0}},U=c(y,`navigation-plugin`,{base:u});function W(t,n){if(!n&&i()&&!(`navigation`in globalThis))throw Error(`[navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.`);U(t);let r={...y,...t};r.base=a(r.base);let o=n??G(r.base),s={forceDeactivate:r.forceDeactivate,source:`navigate`,replace:!0},c={removeNavigateListener:void 0};return t=>new z(t,e(t),r,o,s,c).getPlugin()}function G(e){return`navigation`in globalThis?b(e):H(`navigation-plugin`)}export{W as navigationPluginFactory};
1
+ import{getPluginApi as e}from"@real-router/core/api";import{RouterError as t,UNKNOWN_ROUTE as n,errorCodes as r}from"@real-router/core";const i=()=>globalThis.window!==void 0&&!!globalThis.history;function a(e){if(!e)return e;let t=e.replaceAll(/\/+/g,`/`);return t.startsWith(`/`)||(t=`/${t}`),t.length>1&&t.endsWith(`/`)&&(t=t.slice(0,-1)),t===`/`?``:t}const o=e=>{try{return encodeURI(decodeURI(e))}catch(t){return console.warn(`[browser-env] Could not encode path "${e}"`,t),e}},s=e=>{let t=!1;return n=>{t||=(console.warn(`[browser-env] Browser API is running in a non-browser environment (context: "${e}"). Method "${n}" is a no-op. This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`),!0)}};function c(e,t,n){return r=>{if(r)for(let i of Object.keys(r)){if(!(i in e))continue;let a=r[i];if(a===void 0)continue;let o=typeof e[i],s=typeof a;if(s!==o)throw Error(`[${t}] Invalid type for '${i}': expected ${o}, got ${s}`);let c=n?.[i];if(c){let e=c.validate(a);if(e!==null)throw Error(`[${t}] Invalid '${i}': ${e}`)}}}}const l=/[\u0000-\u001F\u007F]/,u={validate:e=>l.test(e)?`must not contain control characters`:e.split(`/`).includes(`..`)?`must not contain '..' segments`:null};function d(e){return encodeURI(e).replaceAll(`#`,`%23`)}function f(e){try{return decodeURIComponent(e)}catch{return e}}function p(e){return f(e.startsWith(`#`)?e.slice(1):e)}function m(e){let t=e.getHash();return t?f(t.startsWith(`#`)?t.slice(1):t):``}function h(e,t){return e.addInterceptor(`start`,(e,n)=>e(n??t.getLocation()))}function g(e,t,n,r,i=!0){let a={name:``,params:{},path:``};return(o,s={},c)=>{let l=e.buildState(o,s);if(!l)throw Error(`[real-router] Cannot replace state: route "${o}" is not found`);let u=e.makeState(l.name,l.params,t.buildPath(l.name,l.params),{params:l.meta}),f;if(c?.hash!==void 0){let e=p(c.hash);f=e?`#${d(e)}`:``}else f=i?n.getHash():``;let m=r(o,s)+f;a.name=u.name,a.params=u.params,a.path=u.path,n.replaceState(a,m)}}function _(e,t,n){return e.replace===!0?!0:n?!!e.reload&&t.path===n.path:e.replace!==!1}function v(e){let t=e,n=t.indexOf(`://`);if(n!==-1){let e=n+3,r=t.length;for(let n=e;n<t.length;n++){let e=t[n];if(e===`/`||e===`?`||e===`#`){r=n;break}}t=r===t.length?`/`:t.slice(r),(t.startsWith(`?`)||t.startsWith(`#`))&&(t=`/${t}`)}let r=t.indexOf(`#`),i=r===-1?``:t.slice(r),a=r===-1?t:t.slice(0,r),o=a.indexOf(`?`),s=o===-1?``:a.slice(o);return{pathname:o===-1?a:a.slice(0,o),search:s,hash:i}}function y(e,t){if(!e)return`/`;if(t&&(e===t||e.startsWith(`${t}/`))){let n=e.slice(t.length);return n.startsWith(`/`)?n:`/${n}`}return e.startsWith(`/`)?e:`/${e}`}function b(e,t){return e?t?e===`/`?t:e.startsWith(`/`)?`${t}${e}`:`${t}/${e}`:e.startsWith(`/`)?e:`/${e}`:t}function x(e,t){let n=v(e);return y(n.pathname,t)+n.search}function S(e,t){let n=v(e);return{path:y(n.pathname,t)+n.search,hash:n.hash?f(n.hash.slice(1)):``}}function C(e,t){return x(e,t)}const w={forceDeactivate:!1,base:``};function T(e){let t=globalThis.navigation;return{getLocation:()=>o(y(globalThis.location.pathname,e))+globalThis.location.search,getHash:()=>globalThis.location.hash,navigate:(e,n)=>{t.navigate(e,n)},replaceState:(e,n)=>{t.navigate(n,{state:e,history:`replace`})},updateCurrentEntry:e=>{t.updateCurrentEntry(e)},traverseTo:e=>{t.traverseTo(e)},addNavigateListener:e=>(t.addEventListener(`navigate`,e),()=>{t.removeEventListener(`navigate`,e)}),entries:()=>t.entries(),get currentEntry(){return t.currentEntry},getActivationType:()=>t.activation?.navigationType}}function E(e,t){let n=e=>{t.current=!0;try{return e()}finally{t.current=!1}};return{getLocation:()=>e.getLocation(),getHash:()=>e.getHash(),navigate:(t,r)=>{n(()=>{e.navigate(t,r)})},replaceState:(t,r)=>{n(()=>{e.replaceState(t,r)})},updateCurrentEntry:t=>{n(()=>{e.updateCurrentEntry(t)})},traverseTo:t=>{n(()=>{e.traverseTo(t)})},addNavigateListener:t=>e.addNavigateListener(t),entries:()=>e.entries(),get currentEntry(){return e.currentEntry},getActivationType:()=>e.getActivationType()}}function D(e,t,n,r){if(!e)throw Error(`No history entry for route "${t}"`);if(!e.url)throw Error(`No matching route for entry URL "${e.url}"`);let i=C(e.url,r),a=n.matchPath(i);if(!a)throw Error(`No matching route for entry URL "${e.url}"`);return{entry:e,matchedState:a}}function O(e,t,n){if(e?.url)return t.matchPath(C(e.url,n))??void 0}function k(e,t,n,r){let i=e.currentEntry?.index;if(i!=null)return O(e.entries()[i+r],t,n)}function A(e,t,n){return k(e,t,n,-1)}function j(e,t,n){return k(e,t,n,1)}function M(e,t,n,r){return e.entries().some(e=>O(e,t,n)?.name===r)}function N(e,t,n){let r=new Set;for(let i of e.entries()){let e=O(i,t,n);e&&r.add(e.name)}return[...r]}function P(e,t,n,r){let i=0;for(let a of e.entries())O(a,t,n)?.name===r&&i++;return i}function F(e,t,n,r,i){for(let a=e.length-1;a>=0;a--){let o=e[a];if(o.key!==i&&O(o,n,r)?.name===t)return o}}function I(e){let t=e.currentEntry?.index;return t!=null&&t>0}function L(e){let t=e.currentEntry?.index;return t==null?!1:t<e.entries().length-1}function R(e,t,n,r){let i=e.currentEntry?.index;if(i==null)return!1;let a=e.entries();for(let e=i-1;e>=0;e--)if(O(a[e],t,n)?.name===r)return!0;return!1}function z(e,t,n){return e===`traverse`?t===n?`unknown`:t>n?`forward`:`back`:e===`push`?`forward`:`unknown`}function B(e){let{router:n,api:i,browser:a,isSyncingFromRouter:o,base:s,transitionOptions:c}=e,{allowNotFound:l}=i.getOptions();return function(u){if(!u.canIntercept||!n.isActive())return;if(o()){u.intercept({handler:async()=>{}});return}let{path:d,hash:f}=S(u.destination.url,s),p=i.matchPath(d),m=u.navigationType,h=a.currentEntry?.index??-1;e.setCapturedMeta({navigationType:m,userInitiated:u.userInitiated,info:u.info,direction:z(m,u.destination.index,h),sourceElement:u.sourceElement??null});let g=async e=>{try{await e()}catch(e){if(!(e instanceof t)){V(e,n,a);return}if(e.code===r.TRANSITION_CANCELLED||e.code===r.SAME_STATES)return;H(n,a)}};p?u.intercept({handler:()=>g(()=>i.navigateToState(p,{...c,hash:f,...u.hashChange?{force:!0,hashChange:!0}:{},signal:u.signal}))}):l?u.intercept({handler:()=>{n.navigateToNotFound(d)}}):u.intercept({handler:async()=>{let e=new t(r.ROUTE_NOT_FOUND,{path:d});throw i.emitTransitionError(e),e}})}}function V(e,t,n){console.error(`[navigation-plugin] Critical error in navigate handler`,e),H(t,n)}function H(e,t){try{let n=e.getState();if(n){let r=n.context?.url?.hash,i=e.buildUrl(n.name,n.params,r?{hash:r}:void 0);t.navigate(i,{state:{name:n.name,params:n.params,path:n.path},history:`replace`})}}catch(e){console.error(`[navigation-plugin] Failed to sync URL to router state`,e)}}function U(e,t,n){return e.reload&&t.path===n?.path?`reload`:_(e,t,n)?`replace`:`push`}var W=class{#e;#t;#n;#r;#i;#a;#o;#s;#c;#l={current:!1};#u;#d;constructor(e,t,n,r,i,a){this.#e=e,this.#t=t,this.#n=n,this.#r=E(r,this.#l),this.#o=t.claimContextNamespace(`navigation`),this.#s=t.claimContextNamespace(`url`),this.#i=h(t,this.#r);let o=this.#r.getActivationType();o&&(this.#u={navigationType:o,userInitiated:!1,direction:o===`push`?`forward`:`unknown`,sourceElement:null});let s=(t,r,i)=>{let a=b(e.buildPath(t,r),n.base);if(i?.hash===void 0)return a;let o=p(i.hash);return o?`${a}#${d(o)}`:a};this.#a=t.extendRouter({buildUrl:s,matchUrl:e=>t.matchPath(x(e,n.base))??void 0,replaceHistoryState:g(t,e,this.#r,s),peekBack:()=>A(this.#r,t,n.base),peekForward:()=>j(this.#r,t,n.base),hasVisited:e=>M(this.#r,t,n.base,e),getVisitedRoutes:()=>N(this.#r,t,n.base),getRouteVisitCount:e=>P(this.#r,t,n.base,e),traverseToLast:e=>this.traverseToLast(e),canGoBack:()=>I(this.#r),canGoForward:()=>L(this.#r),canGoBackTo:e=>R(this.#r,t,n.base,e)});let c=B({router:e,api:t,browser:this.#r,isSyncingFromRouter:()=>this.#l.current,setCapturedMeta:e=>{this.#u=e},base:n.base,transitionOptions:i});this.#c=G({browser:this.#r,shared:a,handler:c,removeStartInterceptor:this.#i,removeExtensions:this.#a,releaseClaim:()=>{this.#o.release(),this.#s.release()}})}async traverseToLast(e){let t=this.#r.entries(),n=this.#r.currentEntry?.key,{entry:r,matchedState:i}=D(F(t,e,this.#t,this.#n.base,n),e,this.#t,this.#n.base),a=this.#r.currentEntry;if(!a)throw Error(`[navigation-plugin] Cannot determine direction for traverseToLast("${e}"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`);return this.#u={navigationType:`traverse`,userInitiated:!1,direction:r.index>a.index?`forward`:`back`,sourceElement:null},this.#d=r.key,this.#e.navigate(i.name,i.params)}getPlugin(){return{...this.#c,onTransitionStart:e=>{this.#u&&this.#o.write(e,this.#u)},onTransitionSuccess:(e,t,r)=>{if(!this.#u){let n=U(r,e,t);this.#u={navigationType:n,userInitiated:!1,direction:n===`push`?`forward`:`unknown`,sourceElement:null}}let i=Object.freeze(this.#u);this.#o.write(e,i),this.#u=void 0;let a=this.#d;if(this.#d=void 0,a)this.#r.traverseTo(a);else{let a=m(this.#r),o=(t?.context)?.url?.hash??``,s=r.hash===void 0?a:p(r.hash);this.#s.write(e,Object.freeze({hash:s,hashChanged:r.hashChange??s!==o}));let c=b(e.path,this.#n.base),l=s?`${c}#${d(s)}`:c,u={name:e.name,params:e.params,path:e.path};if(e.name===n)this.#r.updateCurrentEntry({state:u});else{let e=i.navigationType!==`push`;this.#r.navigate(l,{state:u,history:e?`replace`:`push`})}}},onTransitionCancel:()=>{this.#u=void 0,this.#d=void 0},onTransitionError:()=>{this.#u=void 0,this.#d=void 0}}}};function G(e){return{onStart(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=e.browser.addNavigateListener(e.handler)},onStop(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=void 0},teardown(){e.shared.removeNavigateListener?.(),e.shared.removeNavigateListener=void 0,e.removeStartInterceptor(),e.removeExtensions(),e.releaseClaim()}}}const K=()=>{},q=e=>{let t=s(e);return{getLocation:()=>(t(`getLocation`),`/`),getHash:()=>(t(`getHash`),``),navigate:()=>{t(`navigate`)},replaceState:()=>{t(`replaceState`)},updateCurrentEntry:()=>{t(`updateCurrentEntry`)},traverseTo:()=>{t(`traverseTo`)},addNavigateListener:()=>(t(`addNavigateListener`),K),entries:()=>(t(`entries`),[]),currentEntry:null,getActivationType:()=>void 0}},J=c(w,`navigation-plugin`,{base:u});function Y(t,n){if(!n&&i()&&!(`navigation`in globalThis))throw Error(`[navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.`);J(t);let r={...w,...t};r.base=a(r.base);let o=n??X(r.base),s={forceDeactivate:r.forceDeactivate,source:`navigate`,replace:!0},c={removeNavigateListener:void 0};return t=>new W(t,e(t),r,o,s,c).getPlugin()}function X(e){return`navigation`in globalThis?T(e):q(`navigation-plugin`)}export{Y as navigationPluginFactory};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["#router","#api","#options","#browser","#removeStartInterceptor","#removeExtensions","#claim","#lifecycle","#syncing","#capturedMeta","#pendingTraverseKey"],"sources":["../../../../shared/browser-env/detect.ts","../../../../shared/browser-env/utils.ts","../../../../shared/browser-env/ssr-fallback.ts","../../../../shared/browser-env/validation.ts","../../../../shared/browser-env/plugin-utils.ts","../../../../shared/browser-env/url-parsing.ts","../../../../shared/browser-env/url-utils.ts","../../src/constants.ts","../../src/navigation-browser.ts","../../src/history-extensions.ts","../../src/navigate-handler.ts","../../src/plugin.ts","../../src/ssr-fallback.ts","../../src/validation.ts","../../src/factory.ts"],"sourcesContent":["export const isBrowserEnvironment = (): boolean =>\n typeof globalThis.window !== \"undefined\" && !!globalThis.history;\n","/**\n * Normalizes base path to canonical form: leading slash, no trailing slash,\n * no repeated slashes. Isolated \"/\" collapses to \"\".\n *\n * @example\n * normalizeBase(\"app\") // \"/app\"\n * normalizeBase(\"/app/\") // \"/app\"\n * normalizeBase(\"//app//\") // \"/app\"\n * normalizeBase(\"\") // \"\"\n * normalizeBase(\"/\") // \"\"\n */\nexport function normalizeBase(base: string): string {\n if (!base) {\n return base;\n }\n\n let result = base.replaceAll(/\\/+/g, \"/\");\n\n if (!result.startsWith(\"/\")) {\n result = `/${result}`;\n }\n\n if (result.length > 1 && result.endsWith(\"/\")) {\n result = result.slice(0, -1);\n }\n\n return result === \"/\" ? \"\" : result;\n}\n\nexport const safelyEncodePath = (path: string): string => {\n try {\n return encodeURI(decodeURI(path));\n } catch (error) {\n console.warn(`[browser-env] Could not encode path \"${path}\"`, error);\n\n return path;\n }\n};\n","import type { HistoryBrowser } from \"./types.js\";\n\nconst NOOP = (): void => {};\n\nexport const createWarnOnce = (context: string) => {\n let hasWarned = false;\n\n return (method: string): void => {\n if (!hasWarned) {\n console.warn(\n `[browser-env] Browser API is running in a non-browser environment (context: \"${context}\"). ` +\n `Method \"${method}\" is a no-op. ` +\n `This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`,\n );\n hasWarned = true;\n }\n };\n};\n\nexport const createHistoryFallbackBrowser = (\n context: string,\n): HistoryBrowser => {\n const warnOnce = createWarnOnce(context);\n\n return {\n pushState: () => {\n warnOnce(\"pushState\");\n },\n replaceState: () => {\n warnOnce(\"replaceState\");\n },\n addPopstateListener: () => {\n warnOnce(\"addPopstateListener\");\n\n return NOOP;\n },\n getHash: () => {\n warnOnce(\"getHash\");\n\n return \"\";\n },\n };\n};\n","export interface OptionRule<T> {\n validate: (value: T) => string | null;\n}\n\nexport type OptionRules<T extends object> = {\n [K in keyof T]?: OptionRule<NonNullable<T[K]>>;\n};\n\nexport function createOptionsValidator<T extends object>(\n defaults: Required<T>,\n loggerContext: string,\n rules?: OptionRules<T>,\n): (opts: Partial<T> | undefined) => void {\n return (opts) => {\n if (!opts) {\n return;\n }\n\n for (const key of Object.keys(opts)) {\n if (!(key in defaults)) {\n continue;\n }\n\n const value = opts[key as keyof typeof opts];\n\n if (value === undefined) {\n continue;\n }\n\n const expected = typeof defaults[key as keyof typeof defaults];\n const actual = typeof value;\n\n if (actual !== expected) {\n throw new Error(\n `[${loggerContext}] Invalid type for '${key}': expected ${expected}, got ${actual}`,\n );\n }\n\n const rule = rules?.[key as keyof T];\n\n if (rule) {\n const msg = (rule.validate as (input: unknown) => string | null)(value);\n\n if (msg !== null) {\n throw new Error(`[${loggerContext}] Invalid '${key}': ${msg}`);\n }\n }\n }\n };\n}\n\n// eslint-disable-next-line no-control-regex -- control characters are exactly what this rule rejects\nconst CONTROL_CHARS = /[\\u0000-\\u001F\\u007F]/;\n\nexport const safeBaseRule: OptionRule<string> = {\n validate: (value) => {\n if (CONTROL_CHARS.test(value)) {\n return \"must not contain control characters\";\n }\n\n if (value.split(\"/\").includes(\"..\")) {\n return \"must not contain '..' segments\";\n }\n\n return null;\n },\n};\n\nexport const safeHashPrefixRule: OptionRule<string> = {\n validate: (value) => {\n if (CONTROL_CHARS.test(value)) {\n return \"must not contain control characters\";\n }\n\n if (value.includes(\"/\")) {\n return \"must not contain '/' (slash is added before the path automatically)\";\n }\n\n if (value.includes(\"#\")) {\n return \"must not contain '#' (it is added as the hash delimiter)\";\n }\n\n if (value.includes(\"?\")) {\n return \"must not contain '?' (it conflicts with the query delimiter)\";\n }\n\n return null;\n },\n};\n\nexport const nonNegativeIntegerRule: OptionRule<number> = {\n validate: (value) => {\n if (!Number.isFinite(value)) {\n return `expected finite number, got ${String(value)}`;\n }\n\n if (!Number.isInteger(value)) {\n return `expected integer, got ${String(value)}`;\n }\n\n if (value < 0) {\n return `expected non-negative integer, got ${value}`;\n }\n\n return null;\n },\n};\n","import type {\n NavigationOptions,\n Params,\n Router,\n State,\n} from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\nexport interface LocationSource {\n getLocation: () => string;\n}\n\n/**\n * Minimal browser surface needed by `createReplaceHistoryState`.\n *\n * Both `Browser` (History API) and navigation-plugin's `NavigationBrowser`\n * (Navigation API) satisfy this structurally — the function never needs\n * `pushState`/`addPopstateListener`, only the replace path.\n */\nexport interface ReplaceStateBrowser {\n replaceState: (state: unknown, url: string) => void;\n getHash: () => string;\n}\n\nexport function createStartInterceptor(\n api: PluginApi,\n browser: LocationSource,\n): () => void {\n return api.addInterceptor(\"start\", (next, path) =>\n next(path ?? browser.getLocation()),\n );\n}\n\nexport function createReplaceHistoryState(\n api: PluginApi,\n router: Router,\n browser: ReplaceStateBrowser,\n buildUrl: (name: string, params?: Params) => string,\n preserveHash = true,\n): (name: string, params?: Params) => void {\n // Reusable buffer — browsers structured-clone state synchronously inside\n // replaceState, so the buffer never escapes. Eliminates one allocation per\n // navigation on the hot path. (Mirrors createUpdateBrowserState.)\n const buffer = {\n name: \"\",\n params: {} as Params,\n path: \"\",\n };\n\n return (name: string, params: Params = {}) => {\n const state = api.buildState(name, params);\n\n if (!state) {\n throw new Error(\n `[real-router] Cannot replace state: route \"${name}\" is not found`,\n );\n }\n\n const builtState = api.makeState(\n state.name,\n state.params,\n router.buildPath(state.name, state.params),\n {\n params: state.meta,\n },\n );\n\n const hash = preserveHash ? browser.getHash() : \"\";\n const url = buildUrl(name, params) + hash;\n\n buffer.name = builtState.name;\n buffer.params = builtState.params;\n buffer.path = builtState.path;\n\n browser.replaceState(buffer, url);\n };\n}\n\nexport function shouldReplaceHistory(\n navOptions: NavigationOptions,\n toState: State,\n fromState: State | undefined,\n): boolean {\n if (navOptions.replace === true) {\n return true;\n }\n\n if (!fromState) {\n return navOptions.replace !== false;\n }\n\n return !!navOptions.reload && toState.path === fromState.path;\n}\n","export interface ParsedUrl {\n pathname: string;\n search: string;\n hash: string;\n}\n\n/**\n * Scheme-agnostic URL parser.\n *\n * Extracts `pathname`, `search`, and `hash` from any string — absolute\n * (`scheme://authority/path?q#h`), path-relative (`/path?q#h`), or opaque\n * (`data:...`, `javascript:...`). Never throws, never returns null.\n *\n * Routing does not care about scheme or authority, only about the path part.\n * This keeps `browser-plugin`, `navigation-plugin`, and `hash-plugin` working\n * in Electron (`file://`, `app://`), Tauri (`tauri://`, `https://`), and any\n * other webview that may ship with non-HTTP origins. See issue #496.\n */\nexport function safeParseUrl(url: string): ParsedUrl {\n let rest = url;\n\n const schemeIdx = rest.indexOf(\"://\");\n\n if (schemeIdx !== -1) {\n const authorityStart = schemeIdx + 3;\n let pathStart = rest.length;\n\n for (let i = authorityStart; i < rest.length; i++) {\n const ch = rest[i];\n\n if (ch === \"/\" || ch === \"?\" || ch === \"#\") {\n pathStart = i;\n\n break;\n }\n }\n\n rest = pathStart === rest.length ? \"/\" : rest.slice(pathStart);\n\n if (rest.startsWith(\"?\") || rest.startsWith(\"#\")) {\n rest = `/${rest}`;\n }\n }\n\n const hashIdx = rest.indexOf(\"#\");\n const hash = hashIdx === -1 ? \"\" : rest.slice(hashIdx);\n const beforeHash = hashIdx === -1 ? rest : rest.slice(0, hashIdx);\n\n const queryIdx = beforeHash.indexOf(\"?\");\n const search = queryIdx === -1 ? \"\" : beforeHash.slice(queryIdx);\n const pathname = queryIdx === -1 ? beforeHash : beforeHash.slice(0, queryIdx);\n\n return { pathname, search, hash };\n}\n","import { safeParseUrl } from \"./url-parsing.js\";\n\nexport function extractPath(pathname: string, base: string): string {\n if (!pathname) {\n return \"/\";\n }\n\n if (base && (pathname === base || pathname.startsWith(`${base}/`))) {\n const stripped = pathname.slice(base.length);\n\n return stripped.startsWith(\"/\") ? stripped : `/${stripped}`;\n }\n\n return pathname.startsWith(\"/\") ? pathname : `/${pathname}`;\n}\n\nexport function buildUrl(path: string, base: string): string {\n if (!path) {\n return base;\n }\n\n if (!base) {\n return path.startsWith(\"/\") ? path : `/${path}`;\n }\n\n // Path \"/\" with a non-empty base would otherwise produce `\"${base}/\"` —\n // a trailing-slash URL (e.g. `/app/`). The canonical form of the base\n // (normalizeBase strips trailing slash) is `/app`, and the router's\n // `extractPath(\"/app\", \"/app\")` round-trips to `\"/\"` regardless. Collapse\n // the index case to the canonical base to keep URLs symmetric.\n if (path === \"/\") {\n return base;\n }\n\n return path.startsWith(\"/\") ? `${base}${path}` : `${base}/${path}`;\n}\n\nexport function urlToPath(url: string, base: string): string {\n const parsedUrl = safeParseUrl(url);\n\n return extractPath(parsedUrl.pathname, base) + parsedUrl.search;\n}\n\n/**\n * Parses an absolute URL and returns its path + search, stripped of `base`.\n * Alias of {@link urlToPath} kept for call-site readability — history-query\n * paths (Navigation API entries, etc.) are absolute URLs by contract.\n */\nexport function extractPathFromAbsoluteUrl(url: string, base: string): string {\n return urlToPath(url, base);\n}\n","import type { NavigationPluginOptions } from \"./types\";\n\nexport const defaultOptions: Required<NavigationPluginOptions> = {\n // Default `false` respects `canDeactivate` guards on browser back/forward,\n // matching the documented contract of `browser-plugin` and the core router.\n // Apps that want the browser's native history buttons to bypass guards\n // (e.g. to avoid dead-end UX) can opt in via `forceDeactivate: true`.\n forceDeactivate: false,\n base: \"\",\n};\n\n/**\n * Source identifier for transitions triggered by navigate events.\n * Distinguishes browser-initiated navigation (back/forward, link clicks)\n * from programmatic navigation (router.navigate()).\n */\nexport const source = \"navigate\";\n\nexport const LOGGER_CONTEXT = \"navigation-plugin\";\n","import { safelyEncodePath, extractPath } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\n\n/**\n * Mutable cell carrying the \"syncing-from-router\" flag shared between\n * `wrapNavigationBrowserWithSyncing` (which raises it around every router-driven\n * mutation) and the plugin's navigate handler (which reads it to short-circuit\n * the event fired by the plugin's own write).\n *\n * Internal to navigation-plugin — not part of the public type surface.\n */\nexport interface SyncingFlag {\n current: boolean;\n}\n\n/**\n * Creates a NavigationBrowser wrapping the real Navigation API.\n * Only call this when `\"navigation\" in globalThis` is true.\n */\nexport function createNavigationBrowser(base: string): NavigationBrowser {\n const nav = globalThis.navigation;\n\n return {\n getLocation: () =>\n safelyEncodePath(extractPath(globalThis.location.pathname, base)) +\n globalThis.location.search,\n\n getHash: () => globalThis.location.hash,\n\n navigate: (url, options) => {\n nav.navigate(url, options);\n },\n\n replaceState: (state, url) => {\n nav.navigate(url, {\n state,\n history: \"replace\",\n });\n },\n\n updateCurrentEntry: (options) => {\n nav.updateCurrentEntry(options);\n },\n\n traverseTo: (key) => {\n nav.traverseTo(key);\n },\n\n addNavigateListener: (fn) => {\n nav.addEventListener(\"navigate\", fn);\n\n return () => {\n nav.removeEventListener(\"navigate\", fn);\n };\n },\n\n entries: () => nav.entries(),\n\n get currentEntry() {\n return nav.currentEntry;\n },\n\n getActivationType: () => nav.activation?.navigationType,\n };\n}\n\n/**\n * Wraps every router-driven mutation of a NavigationBrowser with the syncing\n * flag — raised before the underlying call, lowered after, including the\n * throw path. The plugin's navigate handler reads `syncing.current` to\n * short-circuit the navigate event fired by the plugin's own write\n * (`nav.navigate(...)` and `nav.navigate({history:\"replace\"})` both fire\n * navigate events synchronously).\n *\n * Applied at the factory level to both the built-in `createNavigationBrowser`\n * and any user-supplied browser, so consumers don't need to manage the flag.\n */\nexport function wrapNavigationBrowserWithSyncing(\n browser: NavigationBrowser,\n syncing: SyncingFlag,\n): NavigationBrowser {\n const wrap = <T>(fn: () => T): T => {\n syncing.current = true;\n try {\n return fn();\n } finally {\n syncing.current = false;\n }\n };\n\n return {\n getLocation: () => browser.getLocation(),\n getHash: () => browser.getHash(),\n\n navigate: (url, options) => {\n wrap(() => {\n browser.navigate(url, options);\n });\n },\n replaceState: (state, url) => {\n wrap(() => {\n browser.replaceState(state, url);\n });\n },\n updateCurrentEntry: (options) => {\n wrap(() => {\n browser.updateCurrentEntry(options);\n });\n },\n traverseTo: (key) => {\n wrap(() => {\n browser.traverseTo(key);\n });\n },\n\n addNavigateListener: (fn) => browser.addNavigateListener(fn),\n entries: () => browser.entries(),\n\n get currentEntry() {\n return browser.currentEntry;\n },\n\n getActivationType: () => browser.getActivationType(),\n };\n}\n","import { extractPathFromAbsoluteUrl } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\nimport type { State } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\n/**\n * Validates a candidate history entry for `traverseToLast(routeName)` and\n * returns both the entry (now known non-null) and the matched router state.\n * Extracted from `NavigationPlugin` so the three error branches (missing\n * entry, null url, unmatched url) can be tested directly without vi.spyOn\n * on module namespaces — the star-import spy pattern is fragile under ESM\n * and was working by accident in history-extensions.test.ts.\n *\n * Throws a descriptive Error on any failure; the caller (NavigationPlugin)\n * propagates it as the rejection of `traverseToLast`.\n */\nexport function resolveEntryToMatchedState(\n entry: NavigationHistoryEntry | undefined,\n routeName: string,\n api: PluginApi,\n base: string,\n): { entry: NavigationHistoryEntry; matchedState: State } {\n if (!entry) {\n throw new Error(`No history entry for route \"${routeName}\"`);\n }\n\n if (!entry.url) {\n throw new Error(`No matching route for entry URL \"${entry.url}\"`);\n }\n\n const path = extractPathFromAbsoluteUrl(entry.url, base);\n const matchedState = api.matchPath(path);\n\n if (!matchedState) {\n throw new Error(`No matching route for entry URL \"${entry.url}\"`);\n }\n\n return { entry, matchedState };\n}\n\n/**\n * Converts a NavigationHistoryEntry to a State via URL matching.\n * Uses URL matching (not entry.getState()) because:\n * - Entries before plugin init have no state\n * - Entries after router.replace(routes) may have stale state\n * - Entries from other SPAs on the same origin have foreign state\n */\nexport function entryToState(\n entry: NavigationHistoryEntry | undefined,\n api: PluginApi,\n base: string,\n): State | undefined {\n if (!entry?.url) {\n return undefined;\n }\n\n return (\n api.matchPath(extractPathFromAbsoluteUrl(entry.url, base)) ?? undefined\n );\n}\n\nfunction peekAt(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n offset: number,\n): State | undefined {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return undefined;\n }\n\n return entryToState(browser.entries()[idx + offset], api, base);\n}\n\nexport function peekBack(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): State | undefined {\n return peekAt(browser, api, base, -1);\n}\n\nexport function peekForward(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): State | undefined {\n return peekAt(browser, api, base, 1);\n}\n\nexport function hasVisited(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): boolean {\n return browser.entries().some((entry) => {\n const state = entryToState(entry, api, base);\n\n return state?.name === routeName;\n });\n}\n\nexport function getVisitedRoutes(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): string[] {\n const names = new Set<string>();\n\n for (const entry of browser.entries()) {\n const state = entryToState(entry, api, base);\n\n if (state) {\n names.add(state.name);\n }\n }\n\n return [...names];\n}\n\nexport function getRouteVisitCount(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): number {\n let count = 0;\n\n for (const entry of browser.entries()) {\n if (entryToState(entry, api, base)?.name === routeName) {\n count++;\n }\n }\n\n return count;\n}\n\n/**\n * Finds the last NavigationHistoryEntry matching the given route name,\n * excluding the current entry (to avoid SAME_STATES on traverseToLast(\"current-route\")).\n */\nexport function findLastEntryForRoute(\n entries: NavigationHistoryEntry[],\n routeName: string,\n api: PluginApi,\n base: string,\n currentKey: string | undefined,\n): NavigationHistoryEntry | undefined {\n for (let i = entries.length - 1; i >= 0; i--) {\n const entry = entries[i];\n\n if (entry.key === currentKey) {\n continue;\n }\n\n const state = entryToState(entry, api, base);\n\n if (state?.name === routeName) {\n return entry;\n }\n }\n\n return undefined;\n}\n\nexport function canGoBack(browser: NavigationBrowser): boolean {\n const idx = browser.currentEntry?.index;\n\n return idx != null && idx > 0;\n}\n\nexport function canGoForward(browser: NavigationBrowser): boolean {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return false;\n }\n\n return idx < browser.entries().length - 1;\n}\n\nexport function canGoBackTo(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): boolean {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return false;\n }\n\n const entries = browser.entries();\n\n for (let i = idx - 1; i >= 0; i--) {\n const state = entryToState(entries[i], api, base);\n\n if (state?.name === routeName) {\n return true;\n }\n }\n\n return false;\n}\n","import { errorCodes, RouterError } from \"@real-router/core\";\n\nimport { urlToPath } from \"./browser-env\";\n\nimport type {\n NavigationBrowser,\n NavigationDirection,\n NavigationMeta,\n} from \"./types\";\nimport type { Router } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\ninterface NavigateHandlerDeps {\n router: Router;\n api: PluginApi;\n browser: NavigationBrowser;\n isSyncingFromRouter: () => boolean;\n setCapturedMeta: (meta: NavigationMeta) => void;\n base: string;\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n };\n}\n\nexport function computeDirection(\n navigationType: NavigationMeta[\"navigationType\"],\n destinationIndex: number,\n currentIndex: number,\n): NavigationDirection {\n if (navigationType === \"traverse\") {\n if (destinationIndex === currentIndex) {\n return \"unknown\";\n }\n\n return destinationIndex > currentIndex ? \"forward\" : \"back\";\n }\n\n return navigationType === \"push\" ? \"forward\" : \"unknown\";\n}\n\nexport function createNavigateHandler(deps: NavigateHandlerDeps) {\n const { router, api, browser, isSyncingFromRouter, base, transitionOptions } =\n deps;\n const { allowNotFound } = api.getOptions();\n\n return function handleNavigateEvent(event: NavigateEvent): void {\n if (!event.canIntercept || !router.isActive()) {\n return;\n }\n\n if (isSyncingFromRouter()) {\n // Plugin-originated navigate event after its own successful transition\n // (onTransitionSuccess calls browser.navigate to sync URL). We must still\n // intercept — a bare `return` leaves the event un-intercepted, and\n // Chromium falls back to a cross-document navigation (full page reload).\n // The noop handler cancels the fallback without running router logic;\n // state is already committed.\n event.intercept({\n handler: async () => {},\n });\n\n return;\n }\n\n const path = urlToPath(event.destination.url, base);\n const matchedState = api.matchPath(path);\n\n const navType = event.navigationType as NavigationMeta[\"navigationType\"];\n const currentIndex = browser.currentEntry?.index ?? -1;\n\n deps.setCapturedMeta({\n navigationType: navType,\n userInitiated: event.userInitiated,\n info: event.info,\n direction: computeDirection(\n navType,\n event.destination.index,\n currentIndex,\n ),\n sourceElement: event.sourceElement ?? null,\n });\n\n const withRecovery = async (run: () => Promise<unknown>): Promise<void> => {\n try {\n await run();\n } catch (error) {\n if (!(error instanceof RouterError)) {\n recoverFromNavigateError(error, router, browser);\n\n return;\n }\n\n // TRANSITION_CANCELLED: a newer navigation aborted this one — the\n // newer navigate event is (or will be) handled by this same plugin,\n // and THAT event is responsible for syncing URL/state. Firing our\n // own sync here races against it: browser.navigate(replace, same-url)\n // would cancel the in-flight newer transition, which is exactly the\n // rapid-fire-events storm failure mode.\n //\n // SAME_STATES: router refused because router.getState() already equals\n // the target. URL and router state are already consistent — no sync\n // needed.\n if (\n error.code === errorCodes.TRANSITION_CANCELLED ||\n error.code === errorCodes.SAME_STATES\n ) {\n return;\n }\n\n // Other RouterError codes (CANNOT_DEACTIVATE, CANNOT_ACTIVATE,\n // ROUTE_NOT_FOUND, …) — router rejected the transition, state is\n // unchanged, but URL may have already committed to a different\n // value by the Navigation API. Sync the URL back to the current\n // router state in a single visible transition (headless Chromium\n // and some cross-origin setups leave \"committed-then-reverted\"\n // windows if we relied on the native rollback via intercept reject).\n // Observers that care about the error see it through the router's\n // TRANSITION_ERROR event.\n syncUrlToRouterState(router, browser);\n }\n };\n\n if (matchedState) {\n event.intercept({\n handler: () =>\n withRecovery(() =>\n // api.navigateToState: matchPath already applied forwardState +\n // matchSourceTrailingSlash; reusing the State avoids the redundant\n // round-trip and preserves trailing slashes (#525). Plugin-only\n // entry point — not on the public Router/Navigator surface.\n api.navigateToState(matchedState, {\n ...transitionOptions,\n signal: event.signal,\n }),\n ),\n });\n } else if (allowNotFound) {\n event.intercept({\n handler: () => {\n router.navigateToNotFound(path);\n },\n });\n } else {\n // Strict mode — unmatched URL is an error. Emit $$error and reject the\n // intercept so the Navigation API auto-rolls back the URL. No silent\n // fallback to defaultRoute.\n event.intercept({\n // eslint-disable-next-line @typescript-eslint/require-await -- Navigation API requires async handler; synchronous throw is the rollback signal\n handler: async () => {\n const err = new RouterError(errorCodes.ROUTE_NOT_FOUND, { path });\n\n api.emitTransitionError(err);\n\n throw err;\n },\n });\n }\n };\n}\n\nfunction recoverFromNavigateError(\n error: unknown,\n router: Router,\n browser: NavigationBrowser,\n): void {\n console.error(\n \"[navigation-plugin] Critical error in navigate handler\",\n error,\n );\n\n syncUrlToRouterState(router, browser);\n}\n\nfunction syncUrlToRouterState(\n router: Router,\n browser: NavigationBrowser,\n): void {\n try {\n const currentState = router.getState();\n\n if (currentState) {\n const url = router.buildUrl(currentState.name, currentState.params);\n\n // The syncing flag is raised/lowered inside NavigationBrowser around\n // browser.navigate, including the throw path — no manual try/finally\n // needed here.\n browser.navigate(url, {\n state: {\n name: currentState.name,\n params: currentState.params,\n path: currentState.path,\n },\n history: \"replace\",\n });\n }\n } catch (syncError) {\n console.error(\n \"[navigation-plugin] Failed to sync URL to router state\",\n syncError,\n );\n }\n}\n","import { UNKNOWN_ROUTE } from \"@real-router/core\";\n\nimport {\n shouldReplaceHistory,\n buildUrl,\n urlToPath,\n createStartInterceptor,\n createReplaceHistoryState,\n} from \"./browser-env\";\nimport {\n peekBack,\n peekForward,\n hasVisited,\n getVisitedRoutes,\n getRouteVisitCount,\n findLastEntryForRoute,\n resolveEntryToMatchedState,\n canGoBack,\n canGoForward,\n canGoBackTo,\n} from \"./history-extensions\";\nimport { createNavigateHandler } from \"./navigate-handler\";\nimport { wrapNavigationBrowserWithSyncing } from \"./navigation-browser\";\n\nimport type { SyncingFlag } from \"./navigation-browser\";\nimport type {\n NavigationBrowser,\n NavigationMeta,\n NavigationPluginOptions,\n NavigationSharedState,\n} from \"./types\";\nimport type {\n NavigationOptions,\n Params,\n Router,\n State,\n Plugin,\n} from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\nexport function deriveNavigationType(\n navOptions: NavigationOptions,\n toState: State,\n fromState: State | undefined,\n): NavigationMeta[\"navigationType\"] {\n if (navOptions.reload && toState.path === fromState?.path) {\n return \"reload\";\n }\n\n if (shouldReplaceHistory(navOptions, toState, fromState)) {\n return \"replace\";\n }\n\n return \"push\";\n}\n\nexport class NavigationPlugin {\n readonly #router: Router;\n readonly #api: PluginApi;\n readonly #options: Required<NavigationPluginOptions>;\n readonly #browser: NavigationBrowser;\n readonly #removeStartInterceptor: () => void;\n readonly #removeExtensions: () => void;\n readonly #claim: {\n write: (state: State, value: NavigationMeta) => void;\n release: () => void;\n };\n readonly #lifecycle: Pick<Plugin, \"onStart\" | \"onStop\" | \"teardown\">;\n readonly #syncing: SyncingFlag = { current: false };\n\n #capturedMeta: NavigationMeta | undefined;\n #pendingTraverseKey: string | undefined;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<NavigationPluginOptions>,\n browser: NavigationBrowser,\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n },\n shared: NavigationSharedState,\n ) {\n this.#router = router;\n this.#api = api;\n this.#options = options;\n // Wrap mutations with the syncing flag so the navigate handler can\n // short-circuit re-entrant events fired by the plugin's own writes\n // (`nav.navigate` and `nav.navigate({history:\"replace\"})` fire navigate\n // events synchronously). The flag is per-instance — never shared across\n // plugins — so multiple routers running concurrent transitions don't\n // bleed syncing state into each other.\n this.#browser = wrapNavigationBrowserWithSyncing(browser, this.#syncing);\n\n this.#claim = api.claimContextNamespace(\"navigation\");\n this.#removeStartInterceptor = createStartInterceptor(api, this.#browser);\n\n // Cross-document load priming (#531). On F5, browser back/forward across\n // a page boundary, or a fresh URL bar entry, the prior JS context is\n // discarded — the navigate event handler never sees the activation.\n // Without this, deriveNavigationType in onTransitionSuccess falls through\n // to \"replace\" for every initial transition, breaking scroll restore on\n // reload (#497) and any consumer branching on navigationType.\n // navigation.activation reflects the cross-document navigation that\n // activated this document; it stays constant across same-document\n // navigations, so this only affects the FIRST transition.\n const activationType = this.#browser.getActivationType();\n\n if (activationType) {\n this.#capturedMeta = {\n navigationType: activationType,\n userInitiated: false,\n direction: activationType === \"push\" ? \"forward\" : \"unknown\",\n sourceElement: null,\n };\n }\n\n const pluginBuildUrl = (route: string, params?: Params) => {\n const path = router.buildPath(route, params);\n\n return buildUrl(path, options.base);\n };\n\n this.#removeExtensions = api.extendRouter({\n buildUrl: pluginBuildUrl,\n matchUrl: (url: string) =>\n api.matchPath(urlToPath(url, options.base)) ?? undefined,\n replaceHistoryState: createReplaceHistoryState(\n api,\n router,\n this.#browser,\n pluginBuildUrl,\n ),\n\n peekBack: () => peekBack(this.#browser, api, options.base),\n peekForward: () => peekForward(this.#browser, api, options.base),\n hasVisited: (routeName: string) =>\n hasVisited(this.#browser, api, options.base, routeName),\n getVisitedRoutes: () =>\n getVisitedRoutes(this.#browser, api, options.base),\n getRouteVisitCount: (routeName: string) =>\n getRouteVisitCount(this.#browser, api, options.base, routeName),\n traverseToLast: (routeName: string) => this.traverseToLast(routeName),\n canGoBack: () => canGoBack(this.#browser),\n canGoForward: () => canGoForward(this.#browser),\n canGoBackTo: (routeName: string) =>\n canGoBackTo(this.#browser, api, options.base, routeName),\n });\n\n const handler = createNavigateHandler({\n router,\n api,\n browser: this.#browser,\n isSyncingFromRouter: () => this.#syncing.current,\n setCapturedMeta: (meta) => {\n this.#capturedMeta = meta;\n },\n base: options.base,\n transitionOptions,\n });\n\n this.#lifecycle = createNavigateLifecycle({\n browser: this.#browser,\n shared,\n handler,\n removeStartInterceptor: this.#removeStartInterceptor,\n removeExtensions: this.#removeExtensions,\n releaseClaim: () => {\n this.#claim.release();\n },\n });\n }\n\n async traverseToLast(routeName: string): Promise<State> {\n const entries = this.#browser.entries();\n const currentKey = this.#browser.currentEntry?.key;\n const candidate = findLastEntryForRoute(\n entries,\n routeName,\n this.#api,\n this.#options.base,\n currentKey,\n );\n\n // resolveEntryToMatchedState throws for missing entry, null url, or\n // unmatched url — same three error branches the old inline checks\n // produced. Extracted so the error paths can be unit-tested directly\n // without namespace-level vi.spyOn gymnastics.\n const { entry, matchedState } = resolveEntryToMatchedState(\n candidate,\n routeName,\n this.#api,\n this.#options.base,\n );\n\n const currentEntry = this.#browser.currentEntry;\n\n if (!currentEntry) {\n // Invariant violation: traverseToLast is only callable after\n // router.start(), which guarantees a current entry. A null here means\n // the plugin was stopped mid-call or the browser abstraction is\n // broken — either way, silently picking direction \"forward\" from a\n // fallback `-1` would mask the bug. Fail loudly instead.\n throw new Error(\n `[navigation-plugin] Cannot determine direction for traverseToLast(\"${routeName}\"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`,\n );\n }\n\n this.#capturedMeta = {\n navigationType: \"traverse\",\n userInitiated: false,\n direction: entry.index > currentEntry.index ? \"forward\" : \"back\",\n sourceElement: null,\n };\n this.#pendingTraverseKey = entry.key;\n\n return this.#router.navigate(matchedState.name, matchedState.params);\n }\n\n getPlugin(): Plugin {\n return {\n ...this.#lifecycle,\n\n onTransitionStart: (toState: State) => {\n if (this.#capturedMeta) {\n this.#claim.write(toState, this.#capturedMeta);\n }\n },\n\n onTransitionSuccess: (\n toState: State,\n fromState: State | undefined,\n navOptions: NavigationOptions,\n ) => {\n if (!this.#capturedMeta) {\n const navigationType = deriveNavigationType(\n navOptions,\n toState,\n fromState,\n );\n\n this.#capturedMeta = {\n navigationType,\n userInitiated: false,\n direction: navigationType === \"push\" ? \"forward\" : \"unknown\",\n sourceElement: null,\n };\n }\n\n const frozenMeta = Object.freeze(this.#capturedMeta);\n\n this.#claim.write(toState, frozenMeta);\n this.#capturedMeta = undefined;\n\n // Consume pendingTraverseKey BEFORE calling browser.traverseTo.\n // If traverseTo throws (Navigation API can reject on evicted keys\n // under memory pressure), we must not leave the stale key behind —\n // otherwise the NEXT transition's onTransitionSuccess would see it\n // and replay the traverse against the same already-broken key.\n // The syncing flag is raised/lowered inside NavigationBrowser around\n // each mutation, so we do not need to manage it here.\n const traverseKey = this.#pendingTraverseKey;\n\n this.#pendingTraverseKey = undefined;\n\n if (traverseKey) {\n this.#browser.traverseTo(traverseKey);\n } else {\n const url = buildUrl(toState.path, this.#options.base);\n const shouldPreserveHash =\n !fromState || fromState.path === toState.path;\n const hash = shouldPreserveHash ? this.#browser.getHash() : \"\";\n const finalUrl = hash ? url + hash : url;\n const historyState = {\n name: toState.name,\n params: toState.params,\n path: toState.path,\n };\n\n if (toState.name === UNKNOWN_ROUTE) {\n this.#browser.updateCurrentEntry({ state: historyState });\n } else {\n const replace = frozenMeta.navigationType !== \"push\";\n\n this.#browser.navigate(finalUrl, {\n state: historyState,\n history: replace ? \"replace\" : \"push\",\n });\n }\n }\n },\n\n onTransitionCancel: () => {\n this.#capturedMeta = undefined;\n this.#pendingTraverseKey = undefined;\n },\n\n onTransitionError: () => {\n this.#capturedMeta = undefined;\n this.#pendingTraverseKey = undefined;\n },\n };\n }\n}\n\ninterface NavigateLifecycleDeps {\n browser: NavigationBrowser;\n handler: (event: NavigateEvent) => void;\n removeStartInterceptor: () => void;\n removeExtensions: () => void;\n releaseClaim: () => void;\n shared: NavigationSharedState;\n}\n\nfunction createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {\n return {\n onStart() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = deps.browser.addNavigateListener(\n deps.handler,\n );\n },\n\n onStop() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = undefined;\n },\n\n teardown() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = undefined;\n deps.removeStartInterceptor();\n deps.removeExtensions();\n deps.releaseClaim();\n },\n };\n}\n","import { createWarnOnce } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\n\nconst NOOP = (): void => {};\n\nexport const createNavigationFallbackBrowser = (\n context: string,\n): NavigationBrowser => {\n const warnOnce = createWarnOnce(context);\n\n return {\n getLocation: () => {\n warnOnce(\"getLocation\");\n\n return \"/\";\n },\n getHash: () => {\n warnOnce(\"getHash\");\n\n return \"\";\n },\n navigate: () => {\n warnOnce(\"navigate\");\n },\n replaceState: () => {\n warnOnce(\"replaceState\");\n },\n updateCurrentEntry: () => {\n warnOnce(\"updateCurrentEntry\");\n },\n traverseTo: () => {\n warnOnce(\"traverseTo\");\n },\n addNavigateListener: () => {\n warnOnce(\"addNavigateListener\");\n\n return NOOP;\n },\n entries: () => {\n warnOnce(\"entries\");\n\n return [];\n },\n currentEntry: null,\n getActivationType: () => undefined,\n };\n};\n","import { createOptionsValidator, safeBaseRule } from \"./browser-env\";\nimport { LOGGER_CONTEXT, defaultOptions } from \"./constants\";\n\nimport type { NavigationPluginOptions } from \"./types\";\n\nexport const validateOptions = createOptionsValidator<NavigationPluginOptions>(\n defaultOptions,\n LOGGER_CONTEXT,\n { base: safeBaseRule },\n);\n","import { getPluginApi } from \"@real-router/core/api\";\n\nimport { isBrowserEnvironment, normalizeBase } from \"./browser-env\";\nimport { defaultOptions, source } from \"./constants\";\nimport { createNavigationBrowser } from \"./navigation-browser\";\nimport { NavigationPlugin } from \"./plugin\";\nimport { createNavigationFallbackBrowser } from \"./ssr-fallback\";\nimport { validateOptions } from \"./validation\";\n\nimport type {\n NavigationPluginOptions,\n NavigationBrowser,\n NavigationSharedState,\n} from \"./types\";\nimport type { PluginFactory, Router } from \"@real-router/core\";\n\nexport function navigationPluginFactory(\n opts?: Partial<NavigationPluginOptions>,\n browser?: NavigationBrowser,\n): PluginFactory {\n if (!browser && isBrowserEnvironment() && !(\"navigation\" in globalThis)) {\n throw new Error(\n \"[navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.\",\n );\n }\n\n validateOptions(opts);\n\n const options: Required<NavigationPluginOptions> = {\n ...defaultOptions,\n ...opts,\n };\n\n options.base = normalizeBase(options.base);\n\n const resolvedBrowser = browser ?? createBrowser(options.base);\n\n const forceDeactivate = options.forceDeactivate;\n const transitionOptions = { forceDeactivate, source, replace: true as const };\n const shared: NavigationSharedState = { removeNavigateListener: undefined };\n\n return (routerBase) => {\n const api = getPluginApi(routerBase);\n\n const plugin = new NavigationPlugin(\n routerBase as Router,\n api,\n options,\n resolvedBrowser,\n transitionOptions,\n shared,\n );\n\n return plugin.getPlugin();\n };\n}\n\nfunction createBrowser(base: string): NavigationBrowser {\n if (\"navigation\" in globalThis) {\n return createNavigationBrowser(base);\n }\n\n return createNavigationFallbackBrowser(\"navigation-plugin\");\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["#router","#api","#options","#browser","#removeStartInterceptor","#removeExtensions","#claim","#urlClaim","#lifecycle","#syncing","#capturedMeta","#pendingTraverseKey"],"sources":["../../../../shared/browser-env/detect.ts","../../../../shared/browser-env/utils.ts","../../../../shared/browser-env/ssr-fallback.ts","../../../../shared/browser-env/validation.ts","../../../../shared/browser-env/url-context.ts","../../../../shared/browser-env/plugin-utils.ts","../../../../shared/browser-env/url-parsing.ts","../../../../shared/browser-env/url-utils.ts","../../src/constants.ts","../../src/navigation-browser.ts","../../src/history-extensions.ts","../../src/navigate-handler.ts","../../src/plugin.ts","../../src/ssr-fallback.ts","../../src/validation.ts","../../src/factory.ts"],"sourcesContent":["export const isBrowserEnvironment = (): boolean =>\n typeof globalThis.window !== \"undefined\" && !!globalThis.history;\n","/**\n * Normalizes base path to canonical form: leading slash, no trailing slash,\n * no repeated slashes. Isolated \"/\" collapses to \"\".\n *\n * @example\n * normalizeBase(\"app\") // \"/app\"\n * normalizeBase(\"/app/\") // \"/app\"\n * normalizeBase(\"//app//\") // \"/app\"\n * normalizeBase(\"\") // \"\"\n * normalizeBase(\"/\") // \"\"\n */\nexport function normalizeBase(base: string): string {\n if (!base) {\n return base;\n }\n\n let result = base.replaceAll(/\\/+/g, \"/\");\n\n if (!result.startsWith(\"/\")) {\n result = `/${result}`;\n }\n\n if (result.length > 1 && result.endsWith(\"/\")) {\n result = result.slice(0, -1);\n }\n\n return result === \"/\" ? \"\" : result;\n}\n\nexport const safelyEncodePath = (path: string): string => {\n try {\n return encodeURI(decodeURI(path));\n } catch (error) {\n console.warn(`[browser-env] Could not encode path \"${path}\"`, error);\n\n return path;\n }\n};\n","import type { HistoryBrowser } from \"./types.js\";\n\nconst NOOP = (): void => {};\n\nexport const createWarnOnce = (context: string) => {\n let hasWarned = false;\n\n return (method: string): void => {\n if (!hasWarned) {\n console.warn(\n `[browser-env] Browser API is running in a non-browser environment (context: \"${context}\"). ` +\n `Method \"${method}\" is a no-op. ` +\n `This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`,\n );\n hasWarned = true;\n }\n };\n};\n\nexport const createHistoryFallbackBrowser = (\n context: string,\n): HistoryBrowser => {\n const warnOnce = createWarnOnce(context);\n\n return {\n pushState: () => {\n warnOnce(\"pushState\");\n },\n replaceState: () => {\n warnOnce(\"replaceState\");\n },\n addPopstateListener: () => {\n warnOnce(\"addPopstateListener\");\n\n return NOOP;\n },\n getHash: () => {\n warnOnce(\"getHash\");\n\n return \"\";\n },\n };\n};\n","export interface OptionRule<T> {\n validate: (value: T) => string | null;\n}\n\nexport type OptionRules<T extends object> = {\n [K in keyof T]?: OptionRule<NonNullable<T[K]>>;\n};\n\nexport function createOptionsValidator<T extends object>(\n defaults: Required<T>,\n loggerContext: string,\n rules?: OptionRules<T>,\n): (opts: Partial<T> | undefined) => void {\n return (opts) => {\n if (!opts) {\n return;\n }\n\n for (const key of Object.keys(opts)) {\n if (!(key in defaults)) {\n continue;\n }\n\n const value = opts[key as keyof typeof opts];\n\n if (value === undefined) {\n continue;\n }\n\n const expected = typeof defaults[key as keyof typeof defaults];\n const actual = typeof value;\n\n if (actual !== expected) {\n throw new Error(\n `[${loggerContext}] Invalid type for '${key}': expected ${expected}, got ${actual}`,\n );\n }\n\n const rule = rules?.[key as keyof T];\n\n if (rule) {\n const msg = (rule.validate as (input: unknown) => string | null)(value);\n\n if (msg !== null) {\n throw new Error(`[${loggerContext}] Invalid '${key}': ${msg}`);\n }\n }\n }\n };\n}\n\n// eslint-disable-next-line no-control-regex -- control characters are exactly what this rule rejects\nconst CONTROL_CHARS = /[\\u0000-\\u001F\\u007F]/;\n\nexport const safeBaseRule: OptionRule<string> = {\n validate: (value) => {\n if (CONTROL_CHARS.test(value)) {\n return \"must not contain control characters\";\n }\n\n if (value.split(\"/\").includes(\"..\")) {\n return \"must not contain '..' segments\";\n }\n\n return null;\n },\n};\n\nexport const safeHashPrefixRule: OptionRule<string> = {\n validate: (value) => {\n if (CONTROL_CHARS.test(value)) {\n return \"must not contain control characters\";\n }\n\n if (value.includes(\"/\")) {\n return \"must not contain '/' (slash is added before the path automatically)\";\n }\n\n if (value.includes(\"#\")) {\n return \"must not contain '#' (it is added as the hash delimiter)\";\n }\n\n if (value.includes(\"?\")) {\n return \"must not contain '?' (it conflicts with the query delimiter)\";\n }\n\n return null;\n },\n};\n\nexport const nonNegativeIntegerRule: OptionRule<number> = {\n validate: (value) => {\n if (!Number.isFinite(value)) {\n return `expected finite number, got ${String(value)}`;\n }\n\n if (!Number.isInteger(value)) {\n return `expected integer, got ${String(value)}`;\n }\n\n if (value < 0) {\n return `expected non-negative integer, got ${value}`;\n }\n\n return null;\n },\n};\n","/**\n * URL fragment (\"hash\") shared layer (#532).\n *\n * Both URL plugins (navigation-plugin, browser-plugin) claim the `\"url\"`\n * `state.context` namespace and write `UrlContext` on every transition.\n * Mutually exclusive at runtime — only one URL plugin is installed per router.\n *\n * Hash form: decoded, no leading \"#\" — symmetric to `params` (no leading \"?\").\n * Encoding to/from URL form happens at the boundary (URL build / URL parse).\n */\n\nexport interface UrlContext {\n /** Decoded fragment, no leading \"#\". Empty string when URL has no fragment. */\n hash: string;\n /** Whether `hash` differs from the previous transition's `state.context.url.hash`. */\n hashChanged: boolean;\n}\n\n/**\n * Encode for URL fragment per RFC 3986: preserves sub-delims (`&`, `=`, `?`,\n * `:`, etc.) and the path/query characters that `encodeURI` already leaves\n * alone. Defensively percent-escapes `#` (a stray `#` in a decoded fragment\n * would otherwise terminate the fragment in the rendered URL).\n *\n * `encodeURIComponent` over-encodes RFC-3986 sub-delims (`&` → `%26`) and is\n * therefore wrong for fragments.\n */\nexport function encodeHashFragment(decoded: string): string {\n return encodeURI(decoded).replaceAll(\"#\", \"%23\");\n}\n\n/**\n * Decode a percent-encoded fragment. Falls back to the raw input on malformed\n * escapes — matches the resilience pattern in scroll-restore.\n */\nexport function decodeHashFragment(encoded: string): string {\n try {\n return decodeURIComponent(encoded);\n } catch {\n return encoded;\n }\n}\n\n/**\n * Normalize user-provided hash input: strip a leading \"#\" if present, then\n * decode. Defensive against `<Link hash=\"#section\">` — the prop is documented\n * to accept the fragment name without \"#\", but we accept both gracefully.\n */\nexport function normalizeHashInput(input: string): string {\n const stripped = input.startsWith(\"#\") ? input.slice(1) : input;\n\n return decodeHashFragment(stripped);\n}\n\n/**\n * Read the current browser hash in decoded form, no leading \"#\".\n * Accepts any object with a `getHash()` method — works for both `Browser`\n * (History API) and `NavigationBrowser` (Navigation API). SSR-safe via the\n * abstractions, which return `\"\"` outside a real browser.\n */\nexport function getDecodedHash(browser: { getHash: () => string }): string {\n const raw = browser.getHash();\n\n if (!raw) {\n return \"\";\n }\n\n const stripped = raw.startsWith(\"#\") ? raw.slice(1) : raw;\n\n return decodeHashFragment(stripped);\n}\n","import { encodeHashFragment, normalizeHashInput } from \"./url-context.js\";\n\nimport type {\n NavigationOptions,\n Params,\n Router,\n State,\n} from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\nexport interface LocationSource {\n getLocation: () => string;\n}\n\n/**\n * Minimal browser surface needed by `createReplaceHistoryState`.\n *\n * Both `Browser` (History API) and navigation-plugin's `NavigationBrowser`\n * (Navigation API) satisfy this structurally — the function never needs\n * `pushState`/`addPopstateListener`, only the replace path.\n */\nexport interface ReplaceStateBrowser {\n replaceState: (state: unknown, url: string) => void;\n getHash: () => string;\n}\n\n/**\n * Hash override option for `replaceHistoryState` (#532). Tri-state semantics:\n * `undefined` — preserve the current browser hash (legacy behavior, default)\n * `\"\"` — explicitly clear the fragment\n * non-empty — explicitly set the fragment (decoded form, no leading \"#\")\n */\nexport interface ReplaceHistoryStateOptions {\n hash?: string;\n}\n\nexport function createStartInterceptor(\n api: PluginApi,\n browser: LocationSource,\n): () => void {\n return api.addInterceptor(\"start\", (next, path) =>\n next(path ?? browser.getLocation()),\n );\n}\n\nexport function createReplaceHistoryState(\n api: PluginApi,\n router: Router,\n browser: ReplaceStateBrowser,\n buildUrl: (\n name: string,\n params?: Params,\n options?: ReplaceHistoryStateOptions,\n ) => string,\n preserveHash = true,\n): (\n name: string,\n params?: Params,\n options?: ReplaceHistoryStateOptions,\n) => void {\n // Reusable buffer — browsers structured-clone state synchronously inside\n // replaceState, so the buffer never escapes. Eliminates one allocation per\n // navigation on the hot path. (Mirrors createUpdateBrowserState.)\n const buffer = {\n name: \"\",\n params: {} as Params,\n path: \"\",\n };\n\n return (\n name: string,\n params: Params = {},\n options?: ReplaceHistoryStateOptions,\n ) => {\n const state = api.buildState(name, params);\n\n if (!state) {\n throw new Error(\n `[real-router] Cannot replace state: route \"${name}\" is not found`,\n );\n }\n\n const builtState = api.makeState(\n state.name,\n state.params,\n router.buildPath(state.name, state.params),\n {\n params: state.meta,\n },\n );\n\n // Tri-state hash semantics (#532):\n // options.hash === undefined → preserve (legacy behavior, controlled by\n // preserveHash flag — true for browser/\n // navigation plugins, false for hash-plugin)\n // options.hash === \"\" → explicitly clear\n // options.hash === \"value\" → explicitly set\n let hashSegment: string;\n\n if (options?.hash !== undefined) {\n const norm = normalizeHashInput(options.hash);\n\n hashSegment = norm ? `#${encodeHashFragment(norm)}` : \"\";\n } else if (preserveHash) {\n hashSegment = browser.getHash();\n } else {\n hashSegment = \"\";\n }\n\n // Pass hash through buildUrl when the plugin understands it (avoids\n // double-append). Hash-plugin's buildUrl ignores the option and warns,\n // so call without options here for semantic clarity — but the result is\n // identical because hashSegment is \"\" in that branch (preserveHash=false).\n const url = buildUrl(name, params) + hashSegment;\n\n buffer.name = builtState.name;\n buffer.params = builtState.params;\n buffer.path = builtState.path;\n\n browser.replaceState(buffer, url);\n };\n}\n\nexport function shouldReplaceHistory(\n navOptions: NavigationOptions,\n toState: State,\n fromState: State | undefined,\n): boolean {\n if (navOptions.replace === true) {\n return true;\n }\n\n if (!fromState) {\n return navOptions.replace !== false;\n }\n\n return !!navOptions.reload && toState.path === fromState.path;\n}\n","export interface ParsedUrl {\n pathname: string;\n search: string;\n hash: string;\n}\n\n/**\n * Scheme-agnostic URL parser.\n *\n * Extracts `pathname`, `search`, and `hash` from any string — absolute\n * (`scheme://authority/path?q#h`), path-relative (`/path?q#h`), or opaque\n * (`data:...`, `javascript:...`). Never throws, never returns null.\n *\n * Routing does not care about scheme or authority, only about the path part.\n * This keeps `browser-plugin`, `navigation-plugin`, and `hash-plugin` working\n * in Electron (`file://`, `app://`), Tauri (`tauri://`, `https://`), and any\n * other webview that may ship with non-HTTP origins. See issue #496.\n */\nexport function safeParseUrl(url: string): ParsedUrl {\n let rest = url;\n\n const schemeIdx = rest.indexOf(\"://\");\n\n if (schemeIdx !== -1) {\n const authorityStart = schemeIdx + 3;\n let pathStart = rest.length;\n\n for (let i = authorityStart; i < rest.length; i++) {\n const ch = rest[i];\n\n if (ch === \"/\" || ch === \"?\" || ch === \"#\") {\n pathStart = i;\n\n break;\n }\n }\n\n rest = pathStart === rest.length ? \"/\" : rest.slice(pathStart);\n\n if (rest.startsWith(\"?\") || rest.startsWith(\"#\")) {\n rest = `/${rest}`;\n }\n }\n\n const hashIdx = rest.indexOf(\"#\");\n const hash = hashIdx === -1 ? \"\" : rest.slice(hashIdx);\n const beforeHash = hashIdx === -1 ? rest : rest.slice(0, hashIdx);\n\n const queryIdx = beforeHash.indexOf(\"?\");\n const search = queryIdx === -1 ? \"\" : beforeHash.slice(queryIdx);\n const pathname = queryIdx === -1 ? beforeHash : beforeHash.slice(0, queryIdx);\n\n return { pathname, search, hash };\n}\n","import { decodeHashFragment } from \"./url-context.js\";\nimport { safeParseUrl } from \"./url-parsing.js\";\n\nexport function extractPath(pathname: string, base: string): string {\n if (!pathname) {\n return \"/\";\n }\n\n if (base && (pathname === base || pathname.startsWith(`${base}/`))) {\n const stripped = pathname.slice(base.length);\n\n return stripped.startsWith(\"/\") ? stripped : `/${stripped}`;\n }\n\n return pathname.startsWith(\"/\") ? pathname : `/${pathname}`;\n}\n\nexport function buildUrl(path: string, base: string): string {\n if (!path) {\n return base;\n }\n\n if (!base) {\n return path.startsWith(\"/\") ? path : `/${path}`;\n }\n\n // Path \"/\" with a non-empty base would otherwise produce `\"${base}/\"` —\n // a trailing-slash URL (e.g. `/app/`). The canonical form of the base\n // (normalizeBase strips trailing slash) is `/app`, and the router's\n // `extractPath(\"/app\", \"/app\")` round-trips to `\"/\"` regardless. Collapse\n // the index case to the canonical base to keep URLs symmetric.\n if (path === \"/\") {\n return base;\n }\n\n return path.startsWith(\"/\") ? `${base}${path}` : `${base}/${path}`;\n}\n\nexport function urlToPath(url: string, base: string): string {\n const parsedUrl = safeParseUrl(url);\n\n return extractPath(parsedUrl.pathname, base) + parsedUrl.search;\n}\n\n/**\n * Like `urlToPath` but also returns the decoded URL fragment (#532).\n *\n * Used by URL plugins to extract `event.destination.url`'s hash without\n * dropping it the way `urlToPath` does. The hash is returned in decoded form\n * with no leading \"#\" — same form as stored in `state.context.url.hash`.\n */\nexport function urlToPathAndHash(\n url: string,\n base: string,\n): { path: string; hash: string } {\n const parsed = safeParseUrl(url);\n const path = extractPath(parsed.pathname, base) + parsed.search;\n const hash = parsed.hash ? decodeHashFragment(parsed.hash.slice(1)) : \"\";\n\n return { path, hash };\n}\n\n/**\n * Parses an absolute URL and returns its path + search, stripped of `base`.\n * Alias of {@link urlToPath} kept for call-site readability — history-query\n * paths (Navigation API entries, etc.) are absolute URLs by contract.\n */\nexport function extractPathFromAbsoluteUrl(url: string, base: string): string {\n return urlToPath(url, base);\n}\n","import type { NavigationPluginOptions } from \"./types\";\n\nexport const defaultOptions: Required<NavigationPluginOptions> = {\n // Default `false` respects `canDeactivate` guards on browser back/forward,\n // matching the documented contract of `browser-plugin` and the core router.\n // Apps that want the browser's native history buttons to bypass guards\n // (e.g. to avoid dead-end UX) can opt in via `forceDeactivate: true`.\n forceDeactivate: false,\n base: \"\",\n};\n\n/**\n * Source identifier for transitions triggered by navigate events.\n * Distinguishes browser-initiated navigation (back/forward, link clicks)\n * from programmatic navigation (router.navigate()).\n */\nexport const source = \"navigate\";\n\nexport const LOGGER_CONTEXT = \"navigation-plugin\";\n","import { safelyEncodePath, extractPath } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\n\n/**\n * Mutable cell carrying the \"syncing-from-router\" flag shared between\n * `wrapNavigationBrowserWithSyncing` (which raises it around every router-driven\n * mutation) and the plugin's navigate handler (which reads it to short-circuit\n * the event fired by the plugin's own write).\n *\n * Internal to navigation-plugin — not part of the public type surface.\n */\nexport interface SyncingFlag {\n current: boolean;\n}\n\n/**\n * Creates a NavigationBrowser wrapping the real Navigation API.\n * Only call this when `\"navigation\" in globalThis` is true.\n */\nexport function createNavigationBrowser(base: string): NavigationBrowser {\n const nav = globalThis.navigation;\n\n return {\n getLocation: () =>\n safelyEncodePath(extractPath(globalThis.location.pathname, base)) +\n globalThis.location.search,\n\n getHash: () => globalThis.location.hash,\n\n navigate: (url, options) => {\n nav.navigate(url, options);\n },\n\n replaceState: (state, url) => {\n nav.navigate(url, {\n state,\n history: \"replace\",\n });\n },\n\n updateCurrentEntry: (options) => {\n nav.updateCurrentEntry(options);\n },\n\n traverseTo: (key) => {\n nav.traverseTo(key);\n },\n\n addNavigateListener: (fn) => {\n nav.addEventListener(\"navigate\", fn);\n\n return () => {\n nav.removeEventListener(\"navigate\", fn);\n };\n },\n\n entries: () => nav.entries(),\n\n get currentEntry() {\n return nav.currentEntry;\n },\n\n getActivationType: () => nav.activation?.navigationType,\n };\n}\n\n/**\n * Wraps every router-driven mutation of a NavigationBrowser with the syncing\n * flag — raised before the underlying call, lowered after, including the\n * throw path. The plugin's navigate handler reads `syncing.current` to\n * short-circuit the navigate event fired by the plugin's own write\n * (`nav.navigate(...)` and `nav.navigate({history:\"replace\"})` both fire\n * navigate events synchronously).\n *\n * Applied at the factory level to both the built-in `createNavigationBrowser`\n * and any user-supplied browser, so consumers don't need to manage the flag.\n */\nexport function wrapNavigationBrowserWithSyncing(\n browser: NavigationBrowser,\n syncing: SyncingFlag,\n): NavigationBrowser {\n const wrap = <T>(fn: () => T): T => {\n syncing.current = true;\n try {\n return fn();\n } finally {\n syncing.current = false;\n }\n };\n\n return {\n getLocation: () => browser.getLocation(),\n getHash: () => browser.getHash(),\n\n navigate: (url, options) => {\n wrap(() => {\n browser.navigate(url, options);\n });\n },\n replaceState: (state, url) => {\n wrap(() => {\n browser.replaceState(state, url);\n });\n },\n updateCurrentEntry: (options) => {\n wrap(() => {\n browser.updateCurrentEntry(options);\n });\n },\n traverseTo: (key) => {\n wrap(() => {\n browser.traverseTo(key);\n });\n },\n\n addNavigateListener: (fn) => browser.addNavigateListener(fn),\n entries: () => browser.entries(),\n\n get currentEntry() {\n return browser.currentEntry;\n },\n\n getActivationType: () => browser.getActivationType(),\n };\n}\n","import { extractPathFromAbsoluteUrl } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\nimport type { State } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\n/**\n * Validates a candidate history entry for `traverseToLast(routeName)` and\n * returns both the entry (now known non-null) and the matched router state.\n * Extracted from `NavigationPlugin` so the three error branches (missing\n * entry, null url, unmatched url) can be tested directly without vi.spyOn\n * on module namespaces — the star-import spy pattern is fragile under ESM\n * and was working by accident in history-extensions.test.ts.\n *\n * Throws a descriptive Error on any failure; the caller (NavigationPlugin)\n * propagates it as the rejection of `traverseToLast`.\n */\nexport function resolveEntryToMatchedState(\n entry: NavigationHistoryEntry | undefined,\n routeName: string,\n api: PluginApi,\n base: string,\n): { entry: NavigationHistoryEntry; matchedState: State } {\n if (!entry) {\n throw new Error(`No history entry for route \"${routeName}\"`);\n }\n\n if (!entry.url) {\n throw new Error(`No matching route for entry URL \"${entry.url}\"`);\n }\n\n const path = extractPathFromAbsoluteUrl(entry.url, base);\n const matchedState = api.matchPath(path);\n\n if (!matchedState) {\n throw new Error(`No matching route for entry URL \"${entry.url}\"`);\n }\n\n return { entry, matchedState };\n}\n\n/**\n * Converts a NavigationHistoryEntry to a State via URL matching.\n * Uses URL matching (not entry.getState()) because:\n * - Entries before plugin init have no state\n * - Entries after router.replace(routes) may have stale state\n * - Entries from other SPAs on the same origin have foreign state\n */\nexport function entryToState(\n entry: NavigationHistoryEntry | undefined,\n api: PluginApi,\n base: string,\n): State | undefined {\n if (!entry?.url) {\n return undefined;\n }\n\n return (\n api.matchPath(extractPathFromAbsoluteUrl(entry.url, base)) ?? undefined\n );\n}\n\nfunction peekAt(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n offset: number,\n): State | undefined {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return undefined;\n }\n\n return entryToState(browser.entries()[idx + offset], api, base);\n}\n\nexport function peekBack(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): State | undefined {\n return peekAt(browser, api, base, -1);\n}\n\nexport function peekForward(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): State | undefined {\n return peekAt(browser, api, base, 1);\n}\n\nexport function hasVisited(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): boolean {\n return browser.entries().some((entry) => {\n const state = entryToState(entry, api, base);\n\n return state?.name === routeName;\n });\n}\n\nexport function getVisitedRoutes(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n): string[] {\n const names = new Set<string>();\n\n for (const entry of browser.entries()) {\n const state = entryToState(entry, api, base);\n\n if (state) {\n names.add(state.name);\n }\n }\n\n return [...names];\n}\n\nexport function getRouteVisitCount(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): number {\n let count = 0;\n\n for (const entry of browser.entries()) {\n if (entryToState(entry, api, base)?.name === routeName) {\n count++;\n }\n }\n\n return count;\n}\n\n/**\n * Finds the last NavigationHistoryEntry matching the given route name,\n * excluding the current entry (to avoid SAME_STATES on traverseToLast(\"current-route\")).\n */\nexport function findLastEntryForRoute(\n entries: NavigationHistoryEntry[],\n routeName: string,\n api: PluginApi,\n base: string,\n currentKey: string | undefined,\n): NavigationHistoryEntry | undefined {\n for (let i = entries.length - 1; i >= 0; i--) {\n const entry = entries[i];\n\n if (entry.key === currentKey) {\n continue;\n }\n\n const state = entryToState(entry, api, base);\n\n if (state?.name === routeName) {\n return entry;\n }\n }\n\n return undefined;\n}\n\nexport function canGoBack(browser: NavigationBrowser): boolean {\n const idx = browser.currentEntry?.index;\n\n return idx != null && idx > 0;\n}\n\nexport function canGoForward(browser: NavigationBrowser): boolean {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return false;\n }\n\n return idx < browser.entries().length - 1;\n}\n\nexport function canGoBackTo(\n browser: NavigationBrowser,\n api: PluginApi,\n base: string,\n routeName: string,\n): boolean {\n const idx = browser.currentEntry?.index;\n\n if (idx == null) {\n return false;\n }\n\n const entries = browser.entries();\n\n for (let i = idx - 1; i >= 0; i--) {\n const state = entryToState(entries[i], api, base);\n\n if (state?.name === routeName) {\n return true;\n }\n }\n\n return false;\n}\n","import { errorCodes, RouterError } from \"@real-router/core\";\n\nimport { urlToPathAndHash } from \"./browser-env\";\n\nimport type {\n NavigationBrowser,\n NavigationDirection,\n NavigationMeta,\n} from \"./types\";\nimport type { Router } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\ninterface NavigateHandlerDeps {\n router: Router;\n api: PluginApi;\n browser: NavigationBrowser;\n isSyncingFromRouter: () => boolean;\n setCapturedMeta: (meta: NavigationMeta) => void;\n base: string;\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n };\n}\n\nexport function computeDirection(\n navigationType: NavigationMeta[\"navigationType\"],\n destinationIndex: number,\n currentIndex: number,\n): NavigationDirection {\n if (navigationType === \"traverse\") {\n if (destinationIndex === currentIndex) {\n return \"unknown\";\n }\n\n return destinationIndex > currentIndex ? \"forward\" : \"back\";\n }\n\n return navigationType === \"push\" ? \"forward\" : \"unknown\";\n}\n\nexport function createNavigateHandler(deps: NavigateHandlerDeps) {\n const { router, api, browser, isSyncingFromRouter, base, transitionOptions } =\n deps;\n const { allowNotFound } = api.getOptions();\n\n return function handleNavigateEvent(event: NavigateEvent): void {\n if (!event.canIntercept || !router.isActive()) {\n return;\n }\n\n if (isSyncingFromRouter()) {\n // Plugin-originated navigate event after its own successful transition\n // (onTransitionSuccess calls browser.navigate to sync URL). We must still\n // intercept — a bare `return` leaves the event un-intercepted, and\n // Chromium falls back to a cross-document navigation (full page reload).\n // The noop handler cancels the fallback without running router logic;\n // state is already committed.\n event.intercept({\n handler: async () => {},\n });\n\n return;\n }\n\n const { path, hash } = urlToPathAndHash(event.destination.url, base);\n const matchedState = api.matchPath(path);\n\n const navType = event.navigationType as NavigationMeta[\"navigationType\"];\n const currentIndex = browser.currentEntry?.index ?? -1;\n\n deps.setCapturedMeta({\n navigationType: navType,\n userInitiated: event.userInitiated,\n info: event.info,\n direction: computeDirection(\n navType,\n event.destination.index,\n currentIndex,\n ),\n sourceElement: event.sourceElement ?? null,\n });\n\n const withRecovery = async (run: () => Promise<unknown>): Promise<void> => {\n try {\n await run();\n } catch (error) {\n if (!(error instanceof RouterError)) {\n recoverFromNavigateError(error, router, browser);\n\n return;\n }\n\n // TRANSITION_CANCELLED: a newer navigation aborted this one — the\n // newer navigate event is (or will be) handled by this same plugin,\n // and THAT event is responsible for syncing URL/state. Firing our\n // own sync here races against it: browser.navigate(replace, same-url)\n // would cancel the in-flight newer transition, which is exactly the\n // rapid-fire-events storm failure mode.\n //\n // SAME_STATES: router refused because router.getState() already equals\n // the target. URL and router state are already consistent — no sync\n // needed.\n if (\n error.code === errorCodes.TRANSITION_CANCELLED ||\n error.code === errorCodes.SAME_STATES\n ) {\n return;\n }\n\n // Other RouterError codes (CANNOT_DEACTIVATE, CANNOT_ACTIVATE,\n // ROUTE_NOT_FOUND, …) — router rejected the transition, state is\n // unchanged, but URL may have already committed to a different\n // value by the Navigation API. Sync the URL back to the current\n // router state in a single visible transition (headless Chromium\n // and some cross-origin setups leave \"committed-then-reverted\"\n // windows if we relied on the native rollback via intercept reject).\n // Observers that care about the error see it through the router's\n // TRANSITION_ERROR event.\n syncUrlToRouterState(router, browser);\n }\n };\n\n if (matchedState) {\n event.intercept({\n handler: () =>\n withRecovery(() =>\n // api.navigateToState: matchPath already applied forwardState +\n // matchSourceTrailingSlash; reusing the State avoids the redundant\n // round-trip and preserves trailing slashes (#525). Plugin-only\n // entry point — not on the public Router/Navigator surface.\n //\n // Hash extraction (#532): pass through the destination's hash so\n // onTransitionSuccess sets state.context.url.hash. When the\n // browser fires hashChange (same-document fragment-only nav),\n // add force+hashChange to bypass SAME_STATES — subscribers\n // disambiguate via state.context.url.hashChanged, not via the\n // overloaded force flag.\n api.navigateToState(matchedState, {\n ...transitionOptions,\n hash,\n ...(event.hashChange ? { force: true, hashChange: true } : {}),\n signal: event.signal,\n }),\n ),\n });\n } else if (allowNotFound) {\n event.intercept({\n handler: () => {\n router.navigateToNotFound(path);\n },\n });\n } else {\n // Strict mode — unmatched URL is an error. Emit $$error and reject the\n // intercept so the Navigation API auto-rolls back the URL. No silent\n // fallback to defaultRoute.\n event.intercept({\n // eslint-disable-next-line @typescript-eslint/require-await -- Navigation API requires async handler; synchronous throw is the rollback signal\n handler: async () => {\n const err = new RouterError(errorCodes.ROUTE_NOT_FOUND, { path });\n\n api.emitTransitionError(err);\n\n throw err;\n },\n });\n }\n };\n}\n\nfunction recoverFromNavigateError(\n error: unknown,\n router: Router,\n browser: NavigationBrowser,\n): void {\n console.error(\n \"[navigation-plugin] Critical error in navigate handler\",\n error,\n );\n\n syncUrlToRouterState(router, browser);\n}\n\nfunction syncUrlToRouterState(\n router: Router,\n browser: NavigationBrowser,\n): void {\n try {\n const currentState = router.getState();\n\n if (currentState) {\n // Preserve hash on recovery (#532): reading from state.context.url\n // keeps the visible URL fragment intact when a guard rejects a hash-\n // bearing navigation.\n const ctxHash = (\n currentState.context as { url?: { hash?: string } } | undefined\n )?.url?.hash;\n const url = router.buildUrl(\n currentState.name,\n currentState.params,\n ctxHash ? { hash: ctxHash } : undefined,\n );\n\n // The syncing flag is raised/lowered inside NavigationBrowser around\n // browser.navigate, including the throw path — no manual try/finally\n // needed here.\n browser.navigate(url, {\n state: {\n name: currentState.name,\n params: currentState.params,\n path: currentState.path,\n },\n history: \"replace\",\n });\n }\n } catch (syncError) {\n console.error(\n \"[navigation-plugin] Failed to sync URL to router state\",\n syncError,\n );\n }\n}\n","import { UNKNOWN_ROUTE } from \"@real-router/core\";\n\nimport {\n shouldReplaceHistory,\n buildUrl,\n urlToPath,\n createStartInterceptor,\n createReplaceHistoryState,\n encodeHashFragment,\n getDecodedHash,\n normalizeHashInput,\n} from \"./browser-env\";\nimport {\n peekBack,\n peekForward,\n hasVisited,\n getVisitedRoutes,\n getRouteVisitCount,\n findLastEntryForRoute,\n resolveEntryToMatchedState,\n canGoBack,\n canGoForward,\n canGoBackTo,\n} from \"./history-extensions\";\nimport { createNavigateHandler } from \"./navigate-handler\";\nimport { wrapNavigationBrowserWithSyncing } from \"./navigation-browser\";\n\nimport type { UrlContext } from \"./browser-env\";\nimport type { SyncingFlag } from \"./navigation-browser\";\nimport type {\n NavigationBrowser,\n NavigationMeta,\n NavigationPluginOptions,\n NavigationSharedState,\n} from \"./types\";\nimport type {\n NavigationOptions,\n Params,\n Router,\n State,\n Plugin,\n} from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\nexport function deriveNavigationType(\n navOptions: NavigationOptions,\n toState: State,\n fromState: State | undefined,\n): NavigationMeta[\"navigationType\"] {\n if (navOptions.reload && toState.path === fromState?.path) {\n return \"reload\";\n }\n\n if (shouldReplaceHistory(navOptions, toState, fromState)) {\n return \"replace\";\n }\n\n return \"push\";\n}\n\nexport class NavigationPlugin {\n readonly #router: Router;\n readonly #api: PluginApi;\n readonly #options: Required<NavigationPluginOptions>;\n readonly #browser: NavigationBrowser;\n readonly #removeStartInterceptor: () => void;\n readonly #removeExtensions: () => void;\n readonly #claim: {\n write: (state: State, value: NavigationMeta) => void;\n release: () => void;\n };\n readonly #urlClaim: {\n write: (state: State, value: UrlContext) => void;\n release: () => void;\n };\n readonly #lifecycle: Pick<Plugin, \"onStart\" | \"onStop\" | \"teardown\">;\n readonly #syncing: SyncingFlag = { current: false };\n\n #capturedMeta: NavigationMeta | undefined;\n #pendingTraverseKey: string | undefined;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<NavigationPluginOptions>,\n browser: NavigationBrowser,\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n },\n shared: NavigationSharedState,\n ) {\n this.#router = router;\n this.#api = api;\n this.#options = options;\n // Wrap mutations with the syncing flag so the navigate handler can\n // short-circuit re-entrant events fired by the plugin's own writes\n // (`nav.navigate` and `nav.navigate({history:\"replace\"})` fire navigate\n // events synchronously). The flag is per-instance — never shared across\n // plugins — so multiple routers running concurrent transitions don't\n // bleed syncing state into each other.\n this.#browser = wrapNavigationBrowserWithSyncing(browser, this.#syncing);\n\n this.#claim = api.claimContextNamespace(\"navigation\");\n this.#urlClaim = api.claimContextNamespace(\"url\");\n this.#removeStartInterceptor = createStartInterceptor(api, this.#browser);\n\n // Cross-document load priming (#531). On F5, browser back/forward across\n // a page boundary, or a fresh URL bar entry, the prior JS context is\n // discarded — the navigate event handler never sees the activation.\n // Without this, deriveNavigationType in onTransitionSuccess falls through\n // to \"replace\" for every initial transition, breaking scroll restore on\n // reload (#497) and any consumer branching on navigationType.\n // navigation.activation reflects the cross-document navigation that\n // activated this document; it stays constant across same-document\n // navigations, so this only affects the FIRST transition.\n const activationType = this.#browser.getActivationType();\n\n if (activationType) {\n this.#capturedMeta = {\n navigationType: activationType,\n userInitiated: false,\n direction: activationType === \"push\" ? \"forward\" : \"unknown\",\n sourceElement: null,\n };\n }\n\n // Hash for the first transition (#532) is read lazily inside\n // onTransitionSuccess via `getDecodedHash(browser)` — capturing in the\n // constructor is too eager (in tests, the mock URL is set after the\n // plugin is constructed). The lazy read still covers F5 / fresh URL\n // bar entry: by the time onTransitionSuccess fires the browser already\n // reflects the destination URL.\n\n const pluginBuildUrl = (\n route: string,\n params?: Params,\n opts?: { hash?: string },\n ) => {\n const path = router.buildPath(route, params);\n const url = buildUrl(path, options.base);\n\n if (opts?.hash === undefined) {\n return url;\n }\n\n const norm = normalizeHashInput(opts.hash);\n\n return norm ? `${url}#${encodeHashFragment(norm)}` : url;\n };\n\n this.#removeExtensions = api.extendRouter({\n buildUrl: pluginBuildUrl,\n matchUrl: (url: string) =>\n api.matchPath(urlToPath(url, options.base)) ?? undefined,\n replaceHistoryState: createReplaceHistoryState(\n api,\n router,\n this.#browser,\n pluginBuildUrl,\n ),\n\n peekBack: () => peekBack(this.#browser, api, options.base),\n peekForward: () => peekForward(this.#browser, api, options.base),\n hasVisited: (routeName: string) =>\n hasVisited(this.#browser, api, options.base, routeName),\n getVisitedRoutes: () =>\n getVisitedRoutes(this.#browser, api, options.base),\n getRouteVisitCount: (routeName: string) =>\n getRouteVisitCount(this.#browser, api, options.base, routeName),\n traverseToLast: (routeName: string) => this.traverseToLast(routeName),\n canGoBack: () => canGoBack(this.#browser),\n canGoForward: () => canGoForward(this.#browser),\n canGoBackTo: (routeName: string) =>\n canGoBackTo(this.#browser, api, options.base, routeName),\n });\n\n const handler = createNavigateHandler({\n router,\n api,\n browser: this.#browser,\n isSyncingFromRouter: () => this.#syncing.current,\n setCapturedMeta: (meta) => {\n this.#capturedMeta = meta;\n },\n base: options.base,\n transitionOptions,\n });\n\n this.#lifecycle = createNavigateLifecycle({\n browser: this.#browser,\n shared,\n handler,\n removeStartInterceptor: this.#removeStartInterceptor,\n removeExtensions: this.#removeExtensions,\n releaseClaim: () => {\n this.#claim.release();\n this.#urlClaim.release();\n },\n });\n }\n\n async traverseToLast(routeName: string): Promise<State> {\n const entries = this.#browser.entries();\n const currentKey = this.#browser.currentEntry?.key;\n const candidate = findLastEntryForRoute(\n entries,\n routeName,\n this.#api,\n this.#options.base,\n currentKey,\n );\n\n // resolveEntryToMatchedState throws for missing entry, null url, or\n // unmatched url — same three error branches the old inline checks\n // produced. Extracted so the error paths can be unit-tested directly\n // without namespace-level vi.spyOn gymnastics.\n const { entry, matchedState } = resolveEntryToMatchedState(\n candidate,\n routeName,\n this.#api,\n this.#options.base,\n );\n\n const currentEntry = this.#browser.currentEntry;\n\n if (!currentEntry) {\n // Invariant violation: traverseToLast is only callable after\n // router.start(), which guarantees a current entry. A null here means\n // the plugin was stopped mid-call or the browser abstraction is\n // broken — either way, silently picking direction \"forward\" from a\n // fallback `-1` would mask the bug. Fail loudly instead.\n throw new Error(\n `[navigation-plugin] Cannot determine direction for traverseToLast(\"${routeName}\"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`,\n );\n }\n\n this.#capturedMeta = {\n navigationType: \"traverse\",\n userInitiated: false,\n direction: entry.index > currentEntry.index ? \"forward\" : \"back\",\n sourceElement: null,\n };\n this.#pendingTraverseKey = entry.key;\n\n return this.#router.navigate(matchedState.name, matchedState.params);\n }\n\n getPlugin(): Plugin {\n return {\n ...this.#lifecycle,\n\n onTransitionStart: (toState: State) => {\n if (this.#capturedMeta) {\n this.#claim.write(toState, this.#capturedMeta);\n }\n },\n\n onTransitionSuccess: (\n toState: State,\n fromState: State | undefined,\n navOptions: NavigationOptions,\n ) => {\n if (!this.#capturedMeta) {\n const navigationType = deriveNavigationType(\n navOptions,\n toState,\n fromState,\n );\n\n this.#capturedMeta = {\n navigationType,\n userInitiated: false,\n direction: navigationType === \"push\" ? \"forward\" : \"unknown\",\n sourceElement: null,\n };\n }\n\n const frozenMeta = Object.freeze(this.#capturedMeta);\n\n this.#claim.write(toState, frozenMeta);\n this.#capturedMeta = undefined;\n\n // Consume pendingTraverseKey BEFORE calling browser.traverseTo.\n // If traverseTo throws (Navigation API can reject on evicted keys\n // under memory pressure), we must not leave the stale key behind —\n // otherwise the NEXT transition's onTransitionSuccess would see it\n // and replay the traverse against the same already-broken key.\n // The syncing flag is raised/lowered inside NavigationBrowser around\n // each mutation, so we do not need to manage it here.\n const traverseKey = this.#pendingTraverseKey;\n\n this.#pendingTraverseKey = undefined;\n\n if (traverseKey) {\n this.#browser.traverseTo(traverseKey);\n } else {\n // Tri-state hash resolution (#532).\n // navOptions.hash === undefined → preserve current browser hash\n // navOptions.hash === \"\" → explicitly clear\n // navOptions.hash === \"value\" → explicitly set\n //\n // The \"preserve\" branch reads location.hash from the browser, not\n // fromState.context.url.hash — this captures dynamic fragment\n // changes the user makes outside the plugin (anchor clicks,\n // manual location.hash assignment) instead of replaying the\n // last-published value.\n //\n // hashChanged compares the chosen hash against the *published*\n // previous hash (fromState.context.url.hash), so subscribers see\n // a true signal regardless of whether the value came from\n // navOptions or the browser.\n const browserHash = getDecodedHash(this.#browser);\n const publishedPrevHash =\n (fromState?.context as { url?: { hash?: string } } | undefined)?.url\n ?.hash ?? \"\";\n\n const hash =\n navOptions.hash === undefined\n ? browserHash\n : normalizeHashInput(navOptions.hash);\n\n this.#urlClaim.write(\n toState,\n Object.freeze({\n hash,\n hashChanged: navOptions.hashChange ?? hash !== publishedPrevHash,\n }),\n );\n\n const url = buildUrl(toState.path, this.#options.base);\n const finalUrl = hash ? `${url}#${encodeHashFragment(hash)}` : url;\n const historyState = {\n name: toState.name,\n params: toState.params,\n path: toState.path,\n };\n\n if (toState.name === UNKNOWN_ROUTE) {\n this.#browser.updateCurrentEntry({ state: historyState });\n } else {\n const replace = frozenMeta.navigationType !== \"push\";\n\n this.#browser.navigate(finalUrl, {\n state: historyState,\n history: replace ? \"replace\" : \"push\",\n });\n }\n }\n },\n\n onTransitionCancel: () => {\n this.#capturedMeta = undefined;\n this.#pendingTraverseKey = undefined;\n },\n\n onTransitionError: () => {\n this.#capturedMeta = undefined;\n this.#pendingTraverseKey = undefined;\n },\n };\n }\n}\n\ninterface NavigateLifecycleDeps {\n browser: NavigationBrowser;\n handler: (event: NavigateEvent) => void;\n removeStartInterceptor: () => void;\n removeExtensions: () => void;\n releaseClaim: () => void;\n shared: NavigationSharedState;\n}\n\nfunction createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {\n return {\n onStart() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = deps.browser.addNavigateListener(\n deps.handler,\n );\n },\n\n onStop() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = undefined;\n },\n\n teardown() {\n deps.shared.removeNavigateListener?.();\n deps.shared.removeNavigateListener = undefined;\n deps.removeStartInterceptor();\n deps.removeExtensions();\n deps.releaseClaim();\n },\n };\n}\n","import { createWarnOnce } from \"./browser-env\";\n\nimport type { NavigationBrowser } from \"./types\";\n\nconst NOOP = (): void => {};\n\nexport const createNavigationFallbackBrowser = (\n context: string,\n): NavigationBrowser => {\n const warnOnce = createWarnOnce(context);\n\n return {\n getLocation: () => {\n warnOnce(\"getLocation\");\n\n return \"/\";\n },\n getHash: () => {\n warnOnce(\"getHash\");\n\n return \"\";\n },\n navigate: () => {\n warnOnce(\"navigate\");\n },\n replaceState: () => {\n warnOnce(\"replaceState\");\n },\n updateCurrentEntry: () => {\n warnOnce(\"updateCurrentEntry\");\n },\n traverseTo: () => {\n warnOnce(\"traverseTo\");\n },\n addNavigateListener: () => {\n warnOnce(\"addNavigateListener\");\n\n return NOOP;\n },\n entries: () => {\n warnOnce(\"entries\");\n\n return [];\n },\n currentEntry: null,\n getActivationType: () => undefined,\n };\n};\n","import { createOptionsValidator, safeBaseRule } from \"./browser-env\";\nimport { LOGGER_CONTEXT, defaultOptions } from \"./constants\";\n\nimport type { NavigationPluginOptions } from \"./types\";\n\nexport const validateOptions = createOptionsValidator<NavigationPluginOptions>(\n defaultOptions,\n LOGGER_CONTEXT,\n { base: safeBaseRule },\n);\n","import { getPluginApi } from \"@real-router/core/api\";\n\nimport { isBrowserEnvironment, normalizeBase } from \"./browser-env\";\nimport { defaultOptions, source } from \"./constants\";\nimport { createNavigationBrowser } from \"./navigation-browser\";\nimport { NavigationPlugin } from \"./plugin\";\nimport { createNavigationFallbackBrowser } from \"./ssr-fallback\";\nimport { validateOptions } from \"./validation\";\n\nimport type {\n NavigationPluginOptions,\n NavigationBrowser,\n NavigationSharedState,\n} from \"./types\";\nimport type { PluginFactory, Router } from \"@real-router/core\";\n\nexport function navigationPluginFactory(\n opts?: Partial<NavigationPluginOptions>,\n browser?: NavigationBrowser,\n): PluginFactory {\n if (!browser && isBrowserEnvironment() && !(\"navigation\" in globalThis)) {\n throw new Error(\n \"[navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.\",\n );\n }\n\n validateOptions(opts);\n\n const options: Required<NavigationPluginOptions> = {\n ...defaultOptions,\n ...opts,\n };\n\n options.base = normalizeBase(options.base);\n\n const resolvedBrowser = browser ?? createBrowser(options.base);\n\n const forceDeactivate = options.forceDeactivate;\n const transitionOptions = { forceDeactivate, source, replace: true as const };\n const shared: NavigationSharedState = { removeNavigateListener: undefined };\n\n return (routerBase) => {\n const api = getPluginApi(routerBase);\n\n const plugin = new NavigationPlugin(\n routerBase as Router,\n api,\n options,\n resolvedBrowser,\n transitionOptions,\n shared,\n );\n\n return plugin.getPlugin();\n };\n}\n\nfunction createBrowser(base: string): NavigationBrowser {\n if (\"navigation\" in globalThis) {\n return createNavigationBrowser(base);\n }\n\n return createNavigationFallbackBrowser(\"navigation-plugin\");\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/navigation-plugin",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "type": "commonjs",
5
5
  "description": "Navigation API integration plugin for browser URL synchronization",
6
6
  "main": "./dist/cjs/index.js",
package/src/index.ts CHANGED
@@ -14,23 +14,50 @@ export type {
14
14
  declare module "@real-router/types" {
15
15
  interface StateContext {
16
16
  navigation?: import("./types").NavigationMeta;
17
+ /**
18
+ * URL fragment ("hash") layer state (#532). Populated by both URL plugins
19
+ * (navigation-plugin, browser-plugin) — they are mutually exclusive at
20
+ * runtime, so only one writes to this namespace.
21
+ */
22
+ url?: import("./browser-env").UrlContext;
23
+ }
24
+
25
+ interface NavigationOptions {
26
+ /**
27
+ * URL fragment override (decoded, no leading "#") (#532).
28
+ * Tri-state: `undefined` → preserve current; `""` → clear; non-empty → set.
29
+ */
30
+ hash?: string;
31
+ /**
32
+ * @internal — set by URL plugins on hash-only browser-driven navigation.
33
+ * Subscribers should branch on `state.context.url.hashChanged` instead.
34
+ */
35
+ hashChange?: boolean;
17
36
  }
18
37
  }
19
38
 
20
39
  declare module "@real-router/core" {
21
40
  interface Router {
22
- buildUrl: (name: string, params?: Params) => string;
23
- matchUrl: (url: string) => State | undefined;
24
- replaceHistoryState: (name: string, params?: Params) => void;
25
- peekBack: () => State | undefined;
26
- peekForward: () => State | undefined;
27
- hasVisited: (routeName: string) => boolean;
28
- getVisitedRoutes: () => string[];
29
- getRouteVisitCount: (routeName: string) => number;
30
- traverseToLast: (routeName: string) => Promise<State>;
31
- canGoBack: () => boolean;
32
- canGoForward: () => boolean;
33
- canGoBackTo: (routeName: string) => boolean;
41
+ buildUrl(
42
+ name: string,
43
+ params?: Params,
44
+ options?: { hash?: string },
45
+ ): string;
46
+ matchUrl(url: string): State | undefined;
47
+ replaceHistoryState(
48
+ name: string,
49
+ params?: Params,
50
+ options?: { hash?: string },
51
+ ): void;
52
+ peekBack(): State | undefined;
53
+ peekForward(): State | undefined;
54
+ hasVisited(routeName: string): boolean;
55
+ getVisitedRoutes(): string[];
56
+ getRouteVisitCount(routeName: string): number;
57
+ traverseToLast(routeName: string): Promise<State>;
58
+ canGoBack(): boolean;
59
+ canGoForward(): boolean;
60
+ canGoBackTo(routeName: string): boolean;
34
61
  start(path?: string): Promise<State>;
35
62
  }
36
63
  }
@@ -1,6 +1,6 @@
1
1
  import { errorCodes, RouterError } from "@real-router/core";
2
2
 
3
- import { urlToPath } from "./browser-env";
3
+ import { urlToPathAndHash } from "./browser-env";
4
4
 
5
5
  import type {
6
6
  NavigationBrowser,
@@ -64,7 +64,7 @@ export function createNavigateHandler(deps: NavigateHandlerDeps) {
64
64
  return;
65
65
  }
66
66
 
67
- const path = urlToPath(event.destination.url, base);
67
+ const { path, hash } = urlToPathAndHash(event.destination.url, base);
68
68
  const matchedState = api.matchPath(path);
69
69
 
70
70
  const navType = event.navigationType as NavigationMeta["navigationType"];
@@ -130,8 +130,17 @@ export function createNavigateHandler(deps: NavigateHandlerDeps) {
130
130
  // matchSourceTrailingSlash; reusing the State avoids the redundant
131
131
  // round-trip and preserves trailing slashes (#525). Plugin-only
132
132
  // entry point — not on the public Router/Navigator surface.
133
+ //
134
+ // Hash extraction (#532): pass through the destination's hash so
135
+ // onTransitionSuccess sets state.context.url.hash. When the
136
+ // browser fires hashChange (same-document fragment-only nav),
137
+ // add force+hashChange to bypass SAME_STATES — subscribers
138
+ // disambiguate via state.context.url.hashChanged, not via the
139
+ // overloaded force flag.
133
140
  api.navigateToState(matchedState, {
134
141
  ...transitionOptions,
142
+ hash,
143
+ ...(event.hashChange ? { force: true, hashChange: true } : {}),
135
144
  signal: event.signal,
136
145
  }),
137
146
  ),
@@ -181,7 +190,17 @@ function syncUrlToRouterState(
181
190
  const currentState = router.getState();
182
191
 
183
192
  if (currentState) {
184
- const url = router.buildUrl(currentState.name, currentState.params);
193
+ // Preserve hash on recovery (#532): reading from state.context.url
194
+ // keeps the visible URL fragment intact when a guard rejects a hash-
195
+ // bearing navigation.
196
+ const ctxHash = (
197
+ currentState.context as { url?: { hash?: string } } | undefined
198
+ )?.url?.hash;
199
+ const url = router.buildUrl(
200
+ currentState.name,
201
+ currentState.params,
202
+ ctxHash ? { hash: ctxHash } : undefined,
203
+ );
185
204
 
186
205
  // The syncing flag is raised/lowered inside NavigationBrowser around
187
206
  // browser.navigate, including the throw path — no manual try/finally
package/src/plugin.ts CHANGED
@@ -6,6 +6,9 @@ import {
6
6
  urlToPath,
7
7
  createStartInterceptor,
8
8
  createReplaceHistoryState,
9
+ encodeHashFragment,
10
+ getDecodedHash,
11
+ normalizeHashInput,
9
12
  } from "./browser-env";
10
13
  import {
11
14
  peekBack,
@@ -22,6 +25,7 @@ import {
22
25
  import { createNavigateHandler } from "./navigate-handler";
23
26
  import { wrapNavigationBrowserWithSyncing } from "./navigation-browser";
24
27
 
28
+ import type { UrlContext } from "./browser-env";
25
29
  import type { SyncingFlag } from "./navigation-browser";
26
30
  import type {
27
31
  NavigationBrowser,
@@ -65,6 +69,10 @@ export class NavigationPlugin {
65
69
  write: (state: State, value: NavigationMeta) => void;
66
70
  release: () => void;
67
71
  };
72
+ readonly #urlClaim: {
73
+ write: (state: State, value: UrlContext) => void;
74
+ release: () => void;
75
+ };
68
76
  readonly #lifecycle: Pick<Plugin, "onStart" | "onStop" | "teardown">;
69
77
  readonly #syncing: SyncingFlag = { current: false };
70
78
 
@@ -95,6 +103,7 @@ export class NavigationPlugin {
95
103
  this.#browser = wrapNavigationBrowserWithSyncing(browser, this.#syncing);
96
104
 
97
105
  this.#claim = api.claimContextNamespace("navigation");
106
+ this.#urlClaim = api.claimContextNamespace("url");
98
107
  this.#removeStartInterceptor = createStartInterceptor(api, this.#browser);
99
108
 
100
109
  // Cross-document load priming (#531). On F5, browser back/forward across
@@ -117,10 +126,28 @@ export class NavigationPlugin {
117
126
  };
118
127
  }
119
128
 
120
- const pluginBuildUrl = (route: string, params?: Params) => {
129
+ // Hash for the first transition (#532) is read lazily inside
130
+ // onTransitionSuccess via `getDecodedHash(browser)` — capturing in the
131
+ // constructor is too eager (in tests, the mock URL is set after the
132
+ // plugin is constructed). The lazy read still covers F5 / fresh URL
133
+ // bar entry: by the time onTransitionSuccess fires the browser already
134
+ // reflects the destination URL.
135
+
136
+ const pluginBuildUrl = (
137
+ route: string,
138
+ params?: Params,
139
+ opts?: { hash?: string },
140
+ ) => {
121
141
  const path = router.buildPath(route, params);
142
+ const url = buildUrl(path, options.base);
143
+
144
+ if (opts?.hash === undefined) {
145
+ return url;
146
+ }
147
+
148
+ const norm = normalizeHashInput(opts.hash);
122
149
 
123
- return buildUrl(path, options.base);
150
+ return norm ? `${url}#${encodeHashFragment(norm)}` : url;
124
151
  };
125
152
 
126
153
  this.#removeExtensions = api.extendRouter({
@@ -169,6 +196,7 @@ export class NavigationPlugin {
169
196
  removeExtensions: this.#removeExtensions,
170
197
  releaseClaim: () => {
171
198
  this.#claim.release();
199
+ this.#urlClaim.release();
172
200
  },
173
201
  });
174
202
  }
@@ -268,11 +296,41 @@ export class NavigationPlugin {
268
296
  if (traverseKey) {
269
297
  this.#browser.traverseTo(traverseKey);
270
298
  } else {
299
+ // Tri-state hash resolution (#532).
300
+ // navOptions.hash === undefined → preserve current browser hash
301
+ // navOptions.hash === "" → explicitly clear
302
+ // navOptions.hash === "value" → explicitly set
303
+ //
304
+ // The "preserve" branch reads location.hash from the browser, not
305
+ // fromState.context.url.hash — this captures dynamic fragment
306
+ // changes the user makes outside the plugin (anchor clicks,
307
+ // manual location.hash assignment) instead of replaying the
308
+ // last-published value.
309
+ //
310
+ // hashChanged compares the chosen hash against the *published*
311
+ // previous hash (fromState.context.url.hash), so subscribers see
312
+ // a true signal regardless of whether the value came from
313
+ // navOptions or the browser.
314
+ const browserHash = getDecodedHash(this.#browser);
315
+ const publishedPrevHash =
316
+ (fromState?.context as { url?: { hash?: string } } | undefined)?.url
317
+ ?.hash ?? "";
318
+
319
+ const hash =
320
+ navOptions.hash === undefined
321
+ ? browserHash
322
+ : normalizeHashInput(navOptions.hash);
323
+
324
+ this.#urlClaim.write(
325
+ toState,
326
+ Object.freeze({
327
+ hash,
328
+ hashChanged: navOptions.hashChange ?? hash !== publishedPrevHash,
329
+ }),
330
+ );
331
+
271
332
  const url = buildUrl(toState.path, this.#options.base);
272
- const shouldPreserveHash =
273
- !fromState || fromState.path === toState.path;
274
- const hash = shouldPreserveHash ? this.#browser.getHash() : "";
275
- const finalUrl = hash ? url + hash : url;
333
+ const finalUrl = hash ? `${url}#${encodeHashFragment(hash)}` : url;
276
334
  const historyState = {
277
335
  name: toState.name,
278
336
  params: toState.params,