what-router 0.11.7 → 0.12.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
@@ -134,6 +134,27 @@ const requireRole = asyncGuard(
134
134
  }
135
135
  ```
136
136
 
137
+ `redirect()` throws a navigation signal. Two places catch it: route middleware,
138
+ and a component body.
139
+
140
+ ```js
141
+ {
142
+ path: '/admin',
143
+ component: AdminPanel,
144
+ middleware: [() => isLoggedIn() || redirect('/login')],
145
+ }
146
+
147
+ function Private() {
148
+ if (!isLoggedIn()) redirect('/login');
149
+ return h(Secret, {});
150
+ }
151
+ ```
152
+
153
+ From an event handler, a promise callback or a timer, nothing catches the
154
+ signal and it surfaces as an uncaught error carrying `ERR_REDIRECT_NOT_CAUGHT`.
155
+ Call `navigate(to)` there instead. A `try/catch` around a `redirect()` call
156
+ also swallows the signal, so rethrow anything whose `name` is `RouterRedirect`.
157
+
137
158
  ## View Transitions
138
159
 
139
160
  Navigation uses the View Transitions API by default when available. Use helpers to customize:
@@ -165,6 +186,13 @@ enableScrollRestoration(); // call once at app entry
165
186
  | `navigate(to, opts?)` | Programmatic navigation |
166
187
  | `route` | Reactive route state object |
167
188
  | `useRoute()` | Hook returning computed route properties |
189
+ | `useParams()` | Current route params |
190
+ | `useSearch()` | Parsed query string of the last matched route (stale on a 404, like `route.query`) |
191
+ | `useNavigate()` | Returns `navigate` |
192
+ | `redirect(to, opts?)` | Abort route matching and navigate, **from route middleware** (throws, never returns) |
193
+ | `prefetchRoute(href)` | Prefetch a route's assets |
194
+ | `beforeNavigate(fn)` | Guard run before each route navigation (not hash links); return `false` to cancel |
195
+ | `afterNavigate(fn)` | Callback run after each committed navigation |
168
196
  | `defineRoutes(config)` | Create routes from flat object |
169
197
  | `nestedRoutes(base, children, opts?)` | Nested route helper |
170
198
  | `routeGroup(name, routes, opts?)` | Group routes without affecting URL |
package/dist/index.min.js CHANGED
@@ -1 +1,15 @@
1
- import{signal as x,effect as U,computed as S,batch as I,h as a,ErrorBoundary as V}from"what-core";function L(e){let t=e.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(r,c)=>`*:${c}`).replace(/\[(\w+)\]/g,":$1"),n=[],s=null,o=t.split("/").map(r=>r.startsWith("*:")?(s=r.slice(2),n.push(s),"(.+)"):r==="*"?(s="rest",n.push("rest"),"(.+)"):r.startsWith(":")?(n.push(r.slice(1)),"([^/]+)"):r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:n,catchAll:s}}function A(e,t){let s=t.filter(o=>o.path).sort((o,i)=>{let r=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0),c=(i.path.match(/:/g)||[]).length+(i.path.includes("*")?100:0);return r-c});for(let o of s){let{regex:i,paramNames:r}=L(o.path),c=e.match(i);if(c){let d={};return r.forEach((u,l)=>{d[u]=decodeURIComponent(c[l+1])}),{route:o,params:d}}}return null}function q(e){let t={};if(!e)return t;let n=e.startsWith("?")?e.slice(1):e;for(let s of n.split("&")){let[o,i]=s.split("=");if(!o)continue;let r=decodeURIComponent(o),c=i?decodeURIComponent(i):"";r in t?Array.isArray(t[r])?t[r].push(c):t[r]=[t[r],c]:t[r]=c}return t}function C(e){if(typeof e!="string")return!1;let n=e.trim().replace(/[\s\x00-\x1f]/g,"").toLowerCase();return!(n.startsWith("javascript:")||n.startsWith("data:")||n.startsWith("vbscript:"))}var p=x(typeof location<"u"?location.pathname+location.search+location.hash:"/"),T=x({}),$=x({}),w=x(!1),W=x(null),h={get url(){return p()},get path(){return p().split("?")[0].split("#")[0]},get params(){return T()},get query(){return $()},get hash(){let e=p().split("#")[1];return e?"#"+e:""},get isNavigating(){return w()},get error(){return W()}};async function v(e,t={}){let{replace:n=!1,state:s=null,transition:o=!0,_fromPopstate:i=!1}=t;if(!C(e)){typeof console<"u"&&console.warn(`[what-router] Blocked navigation to unsafe URL: ${e}`);return}if(typeof window<"u"&&e.startsWith("#")){let u=p().split("#")[0]+e;history.replaceState(s,"",u),p.set(u);let l=document.querySelector(e);l&&l.scrollIntoView({behavior:"smooth"});return}if(e===p()||w.peek())return;w.set(!0),W.set(null);let r=()=>{i||(typeof window<"u"&&k.set(p(),{x:scrollX,y:scrollY}),n?history.replaceState(s,"",e):history.pushState(s,"",e)),p.set(e),w.set(!1)};if(o&&typeof document<"u"&&document.startViewTransition)try{await document.startViewTransition(r).finished}catch{}else r()}typeof window<"u"&&window.addEventListener("popstate",()=>{k.set(p(),{x:scrollX,y:scrollY});let e=location.pathname+location.search+location.hash;v(e,{replace:!0,_fromPopstate:!0,transition:!1}).then(()=>{let t=k.get(e);t&&requestAnimationFrame(()=>window.scrollTo(t.x,t.y))})});function K(e,t){let n=[];if(!e.path)return n;let s=e.path.split("/").filter(Boolean),o="";for(let i of s){o+="/"+i;let r=t.find(c=>c.layout&&c.path===o+"/_layout");r&&n.push(r.layout)}return e.layout&&n.push(e.layout),n}var g=[],B=10;function G({routes:e,fallback:t,globalLayout:n}){let s=()=>{let o=p(),i=o.split("?")[0].split("#")[0],r=o.split("?")[1]?.split("#")[0]||"",c=w(),d=A(i,e);if(d){I(()=>{T.set(d.params),$.set(q(r))});let{route:u,params:l}=d,m=q(r);if(u.middleware&&u.middleware.length>0)for(let b of u.middleware){let P=b({path:i,params:l,query:m,route:u});if(P===!1)return t?a(t,{}):a("div",{class:"what-403"},a("h1",null,"403"),a("p",null,"Access denied"));if(typeof P=="string"){if(g.push(P),g.length>B){let R=g.slice(-5).join(" \u2192 ");return g.length=0,console.error(`[what-router] Redirect loop detected: ${R}`),w.set(!1),a("div",{class:"what-redirect-loop"},a("h1",null,"Redirect Loop"),a("p",null,"Too many redirects. Check your middleware configuration."))}let E=new Set,_=!1;for(let R of g){if(E.has(R)){_=!0;break}E.add(R)}if(_){let R=g.join(" \u2192 ");return g.length=0,console.error(`[what-router] Redirect cycle detected: ${R}`),w.set(!1),a("div",{class:"what-redirect-loop"},a("h1",null,"Redirect Loop"),a("p",null,"Circular redirect detected. Check your middleware configuration."))}return v(P,{replace:!0}),null}}g.length=0;let y;u.loading&&c?y=a(u.loading,{}):y=a(u.component,{params:l,query:m,route:u}),u.error&&(y=a(V,{fallback:u.error},y));let f=K(u,e);for(let b of f.reverse())y=a(b,{params:l,query:m},y);return y}return t?a(t,{}):a("div",{class:"what-404"},a("h1",null,"404"),a("p",null,"Page not found"))};return n?a(n,{},s):s}function z({href:e,class:t,className:n,children:s,replace:o,prefetch:i=!0,activeClass:r="active",exactActiveClass:c="exact-active",transition:d=!0,...u}){let l=C(e)?e:"about:blank";!C(e)&&typeof console<"u"&&console.warn(`[what-router] Link blocked unsafe href: ${e}`);let m=l.split("?")[0].split("#")[0];return a("a",{href:l,class:()=>{let f=h.path,b=m==="/"?f==="/":f===m||f.startsWith(m+"/");return[t||n,b&&r,f===m&&c].filter(Boolean).join(" ")||void 0},onclick:f=>{f.ctrlKey||f.metaKey||f.shiftKey||f.altKey||f.button!==0||(f.preventDefault(),v(l,{replace:o,transition:d}))},onmouseenter:i?()=>j(l):void 0,...u},...Array.isArray(s)?s:[s])}function Y(e){return z(e)}function H(e){return Object.entries(e).map(([t,n])=>typeof n=="function"?{path:t,component:n}:{path:t,...n})}function M(e,t,n={}){let{layout:s,loading:o,error:i}=n;return t.map(r=>({...r,path:e+r.path,layout:r.layout||s,loading:r.loading||o,error:r.error||i}))}function J(e,t,n={}){let{layout:s,middleware:o}=n;return t.map(i=>({...i,_group:e,layout:i.layout||s,middleware:[...i.middleware||[],...o||[]]}))}function Z({to:e}){return v(e,{replace:!0}),null}function ee(e,t){return n=>function(o){let i=e(o);return i instanceof Promise?a("div",{class:"what-guard-loading"},"Loading..."):i?a(n,o):typeof t=="string"?(v(t,{replace:!0}),null):a(t,o)}}function te(e,t={}){let{fallback:n="/login",loading:s=null}=t;return o=>function(r){let c=x("pending"),d=x(null),u=!1;return U(()=>(u=!1,Promise.resolve(e(r)).then(l=>{u||(d.set(l),c.set(l?"allowed":"denied"))}).catch(()=>{u||c.set("denied")}),()=>{u=!0})),()=>{let l=c();return l==="pending"?s?a(s,{}):null:l==="allowed"?a(o,r):typeof n=="string"?(v(n,{replace:!0}),null):a(n,r)}}}var N=new Set;function j(e){if(typeof document>"u"||N.has(e))return;N.add(e);let t=document.createElement("link");t.rel="prefetch",t.href=e,document.head.appendChild(t)}var k=new Map;function ne(){typeof window>"u"||(window.addEventListener("beforeunload",()=>{k.set(location.pathname,window.scrollY)}),U(()=>{let e=h.path,t=k.get(e);requestAnimationFrame(()=>{t!==void 0?window.scrollTo(0,t):h.hash?document.querySelector(h.hash)?.scrollIntoView():window.scrollTo(0,0)})}))}function re(e){return{style:{viewTransitionName:e}}}function oe(e){typeof document>"u"||(document.documentElement.dataset.transition=e)}function ie(){return{path:S(()=>h.path),params:S(()=>h.params),query:S(()=>h.query),hash:S(()=>h.hash),isNavigating:S(()=>h.isNavigating),navigate:v,prefetch:j}}function se({children:e}){return e||null}function ae({routes:e,layout:t,fallback:n,error:s}){let o=e.map(i=>({path:i.path,component:i.component,layout:i.layout||void 0,_mode:i.mode||"client"}));return G({routes:o,globalLayout:t,fallback:n||D})}function D(){return a("div",{style:"text-align:center;padding:60px 20px"},a("h1",{style:"font-size:48px;margin-bottom:8px"},"404"),a("p",{style:"color:#64748b"},"Page not found"))}export{ae as FileRouter,z as Link,Y as NavLink,se as Outlet,Z as Redirect,G as Router,te as asyncGuard,L as compilePath,H as defineRoutes,ne as enableScrollRestoration,ee as guard,C as isSafeUrl,A as matchRoute,v as navigate,M as nestedRoutes,q as parseQuery,j as prefetch,h as route,J as routeGroup,oe as setViewTransition,ie as useRoute,re as viewTransitionName};
1
+ import{signal as x,effect as I,computed as b,batch as M,h as c,ErrorBoundary as X}from"what-core";function O(e){let t=e.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(i,u)=>`*:${u}`).replace(/\[(\w+)\]/g,":$1"),n=[],r=null,o=t.split("/").map(i=>i.startsWith("*:")?(r=i.slice(2),n.push(r),"(.+)"):i==="*"?(r="rest",n.push("rest"),"(.+)"):i.startsWith(":")?(n.push(i.slice(1)),"([^/]+)"):i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:n,catchAll:r}}var _=new Map,F=1e3;function H(e){let t=_.get(e);return t||(t=O(e),_.size>=F&&_.clear(),_.set(e,t)),t}function K(e,t){let n;try{n=decodeURIComponent(e)}catch{return null}let r=n.split(/[/\\]/);return!t&&r.length>1||r.includes("..")?null:n}function A(e,t){let n=t.filter(r=>r.path);n.sort((r,o)=>{let a=(r.path.match(/:/g)||[]).length+(r.path.includes("*")?100:0),i=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0);return a-i});for(let r of n){let{regex:o,paramNames:a,catchAll:i}=H(r.path),u=e.match(o);if(u){let p={},l=!1;if(a.forEach((s,d)=>{let g=K(u[d+1],s===i);if(g===null){l=!0;return}p[s]=g}),l)continue;return{route:r,params:p}}}return null}function C(e){let t=Object.create(null);if(!e)return t;let n=e.startsWith("?")?e.slice(1):e;for(let r of n.split("&")){let[o,a]=r.split("=");if(!o)continue;let i=decodeURIComponent(o),u=a?decodeURIComponent(a):"";Object.prototype.hasOwnProperty.call(t,i)?Array.isArray(t[i])?t[i].push(u):t[i]=[t[i],u]:t[i]=u}return t}var Q=new Set(["http:","https:","mailto:","tel:"]);function k(e){if(typeof e!="string")return!1;let n=e.trim().replace(/[\s\x00-\x1f]/g,"").toLowerCase();if(n.startsWith("javascript:")||n.startsWith("data:")||n.startsWith("vbscript:")||/^[/\\]{2}/.test(n))return!1;let r=n.match(/^([a-z][a-z0-9+.-]*:)/);return r?Q.has(r[1]):!n.includes("\\")}var h=x(typeof location<"u"?location.pathname+location.search+location.hash:"/"),G=x({}),D=x({}),y=x(!1),R=x(null),V=x(null),m={get url(){return h()},get path(){return h().split("?")[0].split("#")[0]},get params(){return G()},get query(){return D()},get hash(){let e=h().split("#")[1];return e?"#"+e:""},get isNavigating(){return y()},get error(){return V()}},T=[],q=[];function z(e,t){return e.push(t),()=>{let n=e.indexOf(t);n!==-1&&e.splice(n,1)}}function ue(e){return z(T,e)}function le(e){return z(q,e)}async function v(e,t={}){let{replace:n=!1,state:r=null,transition:o=!0,_fromPopstate:a=!1,_redirectChain:i=!1}=t;if(i||(w.length=0),!k(e)){typeof console<"u"&&console.warn("[what-router] Blocked navigation to unsafe URL:",e);return}if(typeof window<"u"&&e.startsWith("#")){let d=h().split("#")[0]+e;history.replaceState(r,"",d),h.set(d);let g=document.querySelector(e);g&&g.scrollIntoView({behavior:"smooth"});return}if(e===h()||y.peek())return;y.set(!0),R.set(e);let u=h();if(T.length){let l=!1;try{for(let s of T.slice())if(await s(e,u)===!1){l=!0;break}}catch(s){throw y.set(!1),R.set(null),s}if(l){y.set(!1),R.set(null),a&&typeof history<"u"&&history.pushState(null,"",u);return}}V.set(null);let p=()=>{a||(typeof window<"u"&&E.set(h(),{x:window.scrollX,y:window.scrollY}),n?history.replaceState(r,"",e):history.pushState(r,"",e)),h.set(e),y.set(!1),R.set(null)};if(o&&typeof document<"u"&&document.startViewTransition)try{await document.startViewTransition(p).finished}catch{}else p();if(q.length)for(let l of q.slice())l(e,u)}var B=Symbol.for("what.router.redirect"),Y=Symbol.for("what.navigation.signal");function fe(e,t={}){if(!k(e)){let r=typeof e=="string"?e:Object.prototype.toString.call(e),o=new Error(`[what-router] redirect() refused an unsafe target: ${r}`);throw o.code="ERR_UNSAFE_REDIRECT",o.suggestion='redirect() accepts same-origin paths and http:, https:, mailto: or tel: URLs only. Protocol-relative ("//host"), backslash-smuggled and javascript:/data: targets are open-redirect vectors. Check a user-supplied target against an allowlist first.',o.codeExample=`// Bad - a user-controlled target can leave your origin:
2
+ redirect(query.next);
3
+
4
+ // Good - allowlist the target first:
5
+ redirect(ALLOWED.has(query.next) ? query.next : '/');`,o}let n=new Error(`[what-router] redirect to ${e}`);throw n.name="RouterRedirect",n.code="ERR_REDIRECT_NOT_CAUGHT",n.suggestion="Seeing this signal in your console means nothing caught it. redirect() works from route middleware and from a component body, where the Router catches it. From an event handler, a promise callback or a timer, call navigate(to) instead. A try/catch around the redirect() call also swallows it.",n.codeExample=`// Bad - an event handler runs long after the render the Router caught:
6
+ <button onclick={() => redirect('/login')}>Sign in</button>
7
+
8
+ // Good - navigate() from a handler:
9
+ <button onclick={() => navigate('/login')}>Sign in</button>
10
+
11
+ // Good - redirect() from a component body, which the Router catches:
12
+ function Private() {
13
+ if (!user()) redirect('/login');
14
+ return <Secret />;
15
+ }`,n[B]=!0,n[Y]=()=>{L(e,t)},n.to=e,n.options=t,n}typeof window<"u"&&window.addEventListener("popstate",()=>{E.set(h(),{x:window.scrollX,y:window.scrollY});let e=location.pathname+location.search+location.hash;v(e,{replace:!0,_fromPopstate:!0,transition:!1}).then(()=>{let t=E.get(e);t&&requestAnimationFrame(()=>window.scrollTo(t.x,t.y))})});function J(e,t){let n=[];if(!e.path)return n;let r=e.path.split("/").filter(Boolean),o="";for(let a of r){o+="/"+a;let i=t.find(u=>u.layout&&u.path===o+"/_layout");i&&n.push(i.layout)}return e.layout&&n.push(e.layout),n}var w=[],Z=10;function W(e){return c("div",{class:"what-redirect-loop"},c("h1",null,"Redirect Loop"),c("p",null,e))}function L(e,t){if(w.push(e),w.length>Z){let o=w.slice(-5).join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect loop detected: ${o}`),y.set(!1),R.set(null),W("Too many redirects. Check your middleware configuration.")}let n=new Set,r=!1;for(let o of w){if(n.has(o)){r=!0;break}n.add(o)}if(r){let o=w.join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect cycle detected: ${o}`),y.set(!1),R.set(null),W("Circular redirect detected. Check your middleware configuration.")}return v(e,{replace:!0,...t,_redirectChain:!0}),null}function ee(e,t,n){if(!e)return null;let r=e.split("?")[0].split("#")[0],o=A(r,t);return!o||o.route===n?null:o.route.loading||null}function te({routes:e,fallback:t,globalLayout:n}){let r=()=>{let a=h(),i=a.split("?")[0].split("#")[0],u=a.split("?")[1]?.split("#")[0]||"",p=y(),l=A(i,e);if(l){M(()=>{G.set(l.params),D.set(C(u))});let{route:s,params:d}=l,g=C(u);if(s.middleware&&s.middleware.length>0)for(let P of s.middleware){let N=P({path:i,params:d,query:g,route:s});if(N===!1)return t?c(t,{}):c("div",{class:"what-403"},c("h1",null,"403"),c("p",null,"Access denied"));if(typeof N=="string")return L(N)}let f,S=p?ee(R(),e,s):null;S?f=c(S,{}):s.loading&&p?f=c(s.loading,{}):f=c(s.component,{params:d,query:g,route:s}),s.error&&(f=c(X,{fallback:s.error},f));let j=J(s,e);for(let P of j.reverse())f=c(P,{params:d,query:g},f);return f}return t?c(t,{}):c("div",{class:"what-404"},c("h1",null,"404"),c("p",null,"Page not found"))},o=()=>{let a=null;try{return r()}catch(i){if(!i||!i[B])throw i;a=i}return L(a.to,a.options)};return n?c(n,{},o):o}function ne({href:e,class:t,className:n,children:r,replace:o,prefetch:a=!0,activeClass:i="active",exactActiveClass:u="exact-active",transition:p=!0,...l}){let s=k(e)?e:"about:blank";!k(e)&&typeof console<"u"&&console.warn("[what-router] Link blocked unsafe href:",e);let d=s.split("?")[0].split("#")[0];return c("a",{href:s,class:()=>{let f=m.path,S=d==="/"?f==="/":f===d||f.startsWith(d+"/");return[t||n,S&&i,f===d&&u].filter(Boolean).join(" ")||void 0},onclick:f=>{f.ctrlKey||f.metaKey||f.shiftKey||f.altKey||f.button!==0||(f.preventDefault(),v(s,{replace:o,transition:p}))},onmouseenter:a?()=>U(s):void 0,...l},...Array.isArray(r)?r:[r])}function de(e){return ne(e)}function pe(e){return Object.entries(e).map(([t,n])=>typeof n=="function"?{path:t,component:n}:{path:t,...n})}function re(e,t){let n=e.endsWith("/")?e.slice(0,-1):e,r=!t||t==="/"?"/":t.startsWith("/")?t:`/${t}`;return r==="/"?n||"/":n+r}function he(e,t,n={}){let{layout:r,loading:o,error:a}=n;return t.map(i=>({...i,path:re(e,i.path),layout:i.layout||r,loading:i.loading||o,error:i.error||a}))}function me(e,t,n={}){let{layout:r,middleware:o}=n;return t.map(a=>({...a,_group:e,layout:a.layout||r,middleware:[...a.middleware||[],...o||[]]}))}function ge({to:e}){return v(e,{replace:!0}),null}function ye(e,t){return n=>function(o){let a=e(o);return a instanceof Promise?c("div",{class:"what-guard-loading"},"Loading..."):a?c(n,o):typeof t=="string"?(v(t,{replace:!0}),null):c(t,o)}}function we(e,t={}){let{fallback:n="/login",loading:r=null}=t;return o=>function(i){let u=x("pending"),p=x(null),l=!1;return I(()=>(l=!1,Promise.resolve(e(i)).then(s=>{l||(p.set(s),u.set(s?"allowed":"denied"))}).catch(()=>{l||u.set("denied")}),()=>{l=!0})),()=>{let s=u();return s==="pending"?r?c(r,{}):null:s==="allowed"?c(o,i):typeof n=="string"?(v(n,{replace:!0}),null):c(n,i)}}}var $=new Set;function U(e){if(typeof document>"u"||$.has(e))return;$.add(e);let t=document.createElement("link");t.rel="prefetch",t.href=e,document.head.appendChild(t)}var E=new Map;function xe(){typeof window>"u"||(window.addEventListener("beforeunload",()=>{E.set(location.pathname,window.scrollY)}),I(()=>{let e=m.path,t=E.get(e);requestAnimationFrame(()=>{t!==void 0?window.scrollTo(0,t):m.hash?document.querySelector(m.hash)?.scrollIntoView():window.scrollTo(0,0)})}))}function ve(e){return{style:{viewTransitionName:e}}}function Re(e){typeof document>"u"||(document.documentElement.dataset.transition=e)}function be(){return{path:b(()=>m.path),params:b(()=>m.params),query:b(()=>m.query),hash:b(()=>m.hash),isNavigating:b(()=>m.isNavigating),navigate:v,prefetch:U}}function Ee(){return m.params}function Se(){return m.query}function _e(){return v}function Ae(e){U(e)}function Ce({children:e}){return e||null}function ke({routes:e,layout:t,fallback:n,error:r}){let o=e.map(a=>({path:a.path,component:a.component,layout:a.layout||void 0,loading:a.loading||void 0,error:a.error||r||void 0,_mode:a.mode||"client"}));return te({routes:o,globalLayout:t,fallback:n||oe})}function oe(){return c("div",{style:"text-align:center;padding:60px 20px"},c("h1",{style:"font-size:48px;margin-bottom:8px"},"404"),c("p",{style:"color:#64748b"},"Page not found"))}export{ke as FileRouter,ne as Link,de as NavLink,Ce as Outlet,ge as Redirect,te as Router,le as afterNavigate,we as asyncGuard,ue as beforeNavigate,O as compilePath,pe as defineRoutes,xe as enableScrollRestoration,ye as guard,k as isSafeUrl,A as matchRoute,v as navigate,he as nestedRoutes,C as parseQuery,U as prefetch,Ae as prefetchRoute,fe as redirect,m as route,me as routeGroup,Re as setViewTransition,_e as useNavigate,Ee as useParams,be as useRoute,Se as useSearch,ve as viewTransitionName};
package/dist/match.min.js CHANGED
@@ -1 +1 @@
1
- function u(n){let e=n.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(t,o)=>`*:${o}`).replace(/\[(\w+)\]/g,":$1"),a=[],c=null,r=e.split("/").map(t=>t.startsWith("*:")?(c=t.slice(2),a.push(c),"(.+)"):t==="*"?(c="rest",a.push("rest"),"(.+)"):t.startsWith(":")?(a.push(t.slice(1)),"([^/]+)"):t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${r}$`),paramNames:a,catchAll:c}}function h(n,e){let c=e.filter(r=>r.path).sort((r,s)=>{let t=(r.path.match(/:/g)||[]).length+(r.path.includes("*")?100:0),o=(s.path.match(/:/g)||[]).length+(s.path.includes("*")?100:0);return t-o});for(let r of c){let{regex:s,paramNames:t}=u(r.path),o=n.match(s);if(o){let i={};return t.forEach((p,l)=>{i[p]=decodeURIComponent(o[l+1])}),{route:r,params:i}}}return null}function f(n){let e={};if(!n)return e;let a=n.startsWith("?")?n.slice(1):n;for(let c of a.split("&")){let[r,s]=c.split("=");if(!r)continue;let t=decodeURIComponent(r),o=s?decodeURIComponent(s):"";t in e?Array.isArray(e[t])?e[t].push(o):e[t]=[e[t],o]:e[t]=o}return e}export{u as compilePath,h as matchRoute,f as parseQuery};
1
+ function h(n){let t=n.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(e,s)=>`*:${s}`).replace(/\[(\w+)\]/g,":$1"),c=[],r=null,o=t.split("/").map(e=>e.startsWith("*:")?(r=e.slice(2),c.push(r),"(.+)"):e==="*"?(r="rest",c.push("rest"),"(.+)"):e.startsWith(":")?(c.push(e.slice(1)),"([^/]+)"):e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:c,catchAll:r}}var a=new Map,m=1e3;function g(n){let t=a.get(n);return t||(t=h(n),a.size>=m&&a.clear(),a.set(n,t)),t}function x(n,t){let c;try{c=decodeURIComponent(n)}catch{return null}let r=c.split(/[/\\]/);return!t&&r.length>1||r.includes("..")?null:c}function y(n,t){let c=t.filter(r=>r.path);c.sort((r,o)=>{let l=(r.path.match(/:/g)||[]).length+(r.path.includes("*")?100:0),e=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0);return l-e});for(let r of c){let{regex:o,paramNames:l,catchAll:e}=g(r.path),s=n.match(o);if(s){let i={},u=!1;if(l.forEach((p,d)=>{let f=x(s[d+1],p===e);if(f===null){u=!0;return}i[p]=f}),u)continue;return{route:r,params:i}}}return null}function A(n){let t=Object.create(null);if(!n)return t;let c=n.startsWith("?")?n.slice(1):n;for(let r of c.split("&")){let[o,l]=r.split("=");if(!o)continue;let e=decodeURIComponent(o),s=l?decodeURIComponent(l):"";Object.prototype.hasOwnProperty.call(t,e)?Array.isArray(t[e])?t[e].push(s):t[e]=[t[e],s]:t[e]=s}return t}export{h as compilePath,y as matchRoute,A as parseQuery};
package/index.d.ts CHANGED
@@ -49,7 +49,7 @@ export interface RouteConfig {
49
49
  /** Loading component */
50
50
  loading?: Component<{}>;
51
51
  /** Error component */
52
- error?: Component<{ error: Error }>;
52
+ error?: Component<{ error: Error; reset: () => void }>;
53
53
  /** Route middleware */
54
54
  middleware?: RouteMiddleware[];
55
55
  }
@@ -78,6 +78,25 @@ export interface RouterProps {
78
78
 
79
79
  export function Router(props: RouterProps): VNode;
80
80
 
81
+ // --- File-Based Router ---
82
+
83
+ export interface FileRouteConfig {
84
+ path: string;
85
+ component: Component<RouteComponentProps>;
86
+ layout?: Component<LayoutProps>;
87
+ mode?: 'static' | 'server' | 'client' | 'hybrid';
88
+ }
89
+
90
+ export interface FileRouterProps {
91
+ routes: FileRouteConfig[];
92
+ layout?: Component<{ children?: VNodeChild }>;
93
+ fallback?: Component<{}>;
94
+ error?: Component<{ error: Error; reset: () => void }>;
95
+ }
96
+
97
+ /** Router driven by what-compiler's generated route manifest (virtual:what-routes). */
98
+ export function FileRouter(props: FileRouterProps): VNode;
99
+
81
100
  // --- Link Component ---
82
101
 
83
102
  export interface LinkProps {
@@ -160,6 +179,82 @@ export interface UseRouteResult {
160
179
 
161
180
  export function useRoute(): UseRouteResult;
162
181
 
182
+ // --- Route Accessors ---
183
+
184
+ /** Current route params. Subscribes when read inside a tracking scope. */
185
+ export function useParams<T = Record<string, string>>(): T;
186
+
187
+ /**
188
+ * Query string of the last successfully matched route, parsed. Subscribes when
189
+ * read inside a tracking scope. Only the Router's match branch writes it, so on
190
+ * an unmatched (404) route this is the previous route's query, not the current
191
+ * URL's. Same value and same caveat as `route.query`.
192
+ */
193
+ export function useSearch<T = Record<string, string>>(): T;
194
+
195
+ /** The navigate function, for symmetry with useParams/useSearch. */
196
+ export function useNavigate(): typeof navigate;
197
+
198
+ /** Prefetch a route's assets. */
199
+ export function prefetchRoute(href: string): void;
200
+
201
+ // --- Redirect Signal ---
202
+
203
+ /**
204
+ * Abort the current render and navigate.
205
+ *
206
+ * Throws a navigation signal. Two places catch it: route middleware, caught by
207
+ * the Router's matching pass, and a component body, caught by the runtime where
208
+ * it instantiates components. Anywhere else (an event handler, a promise
209
+ * callback, a timer, or a reactive thunk such as `{() => cond() && redirect(to)}`)
210
+ * nothing catches it and the signal surfaces as an uncaught error carrying
211
+ * `ERR_REDIRECT_NOT_CAUGHT`; call `navigate(to)` there instead. In a thunk the
212
+ * first render reports the error, but on a later re-run the navigation simply
213
+ * does not happen and the stale DOM stays, so prefer `navigate(to)` there.
214
+ * A `try/catch` around the call also swallows it, so rethrow anything whose
215
+ * `name` is `RouterRedirect`. On the server the signal escapes `renderToString`
216
+ * to its caller: read `.to` and emit a 302.
217
+ */
218
+ export function redirect(to: string, options?: NavigateOptions): never;
219
+
220
+ // --- Navigation Hooks ---
221
+
222
+ /**
223
+ * Run before every route navigation; return false to cancel. Returns an
224
+ * unsubscribe. Not consulted for same-page hash navigation (`navigate('#x')`
225
+ * scrolls, it does not change the route). Cancelling a back/forward navigation
226
+ * restores the address bar by pushing the previous URL as a new history entry:
227
+ * the entry the browser moved to is not recovered and its `history.state` is
228
+ * not carried over.
229
+ */
230
+ export function beforeNavigate(fn: (to: string, from: string) => boolean | Promise<boolean>): () => void;
231
+
232
+ /** Run after every committed navigation. Returns an unsubscribe. */
233
+ export function afterNavigate(fn: (to: string, from: string) => void): () => void;
234
+
163
235
  // --- Outlet ---
164
236
 
165
- export function Outlet(props: { children?: VNodeChild }): VNodeChild;
237
+ export function Outlet(props: { children?: VNodeChild }): VNode;
238
+
239
+ // --- Path Matching ---
240
+
241
+ export interface CompiledPath {
242
+ regex: RegExp;
243
+ paramNames: string[];
244
+ catchAll: string | null;
245
+ }
246
+
247
+ /** Compile a path pattern (`/users/:id`, `/posts/*`, `/[slug]`) to a matcher. */
248
+ export function compilePath(path: string): CompiledPath;
249
+
250
+ /** Match a pathname against routes, most specific first. */
251
+ export function matchRoute<T extends { path?: string }>(
252
+ path: string,
253
+ routes: T[],
254
+ ): { route: T; params: Record<string, string> } | null;
255
+
256
+ /** Parse a query string into a null-prototype object. */
257
+ export function parseQuery(search: string): Record<string, string>;
258
+
259
+ /** Reject javascript:, data:, vbscript: and protocol-relative URLs. */
260
+ export function isSafeUrl(url: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-router",
3
- "version": "0.11.7",
3
+ "version": "0.12.0",
4
4
  "description": "What Framework - File-based & programmatic router with View Transitions",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -33,8 +33,9 @@
33
33
  ],
34
34
  "author": "ZVN DEV (https://zvndev.com)",
35
35
  "license": "MIT",
36
+ "dependencies": {},
36
37
  "peerDependencies": {
37
- "what-core": "^0.11.7"
38
+ "what-core": "^0.12.0"
38
39
  },
39
40
  "repository": {
40
41
  "type": "git",
package/src/index.js CHANGED
@@ -6,7 +6,20 @@ import { signal, effect, computed, batch, h, ErrorBoundary } from 'what-core';
6
6
  import { compilePath, matchRoute, parseQuery } from './match.js';
7
7
 
8
8
  // --- URL Sanitization ---
9
- // Rejects javascript:, data:, vbscript: protocols (case-insensitive, trimmed).
9
+ // Rejects javascript:, data:, vbscript: protocols (case-insensitive, trimmed),
10
+ // any scheme outside the allowlist (blob:, about:, filesystem: ...), and
11
+ // protocol-relative / backslash-smuggled paths that resolve to a foreign
12
+ // origin. Browsers treat "\" like "/", so "/\evil.com" is an open redirect.
13
+ //
14
+ // Sibling predicate: safeLocalPath / safeRedirectTarget in
15
+ // packages/server/src/action-handler.js. That one gates a server-issued
16
+ // `Location:` header and must stay strictly narrower (same-origin local paths
17
+ // only), because a form POST target is attacker-controllable in a way a client
18
+ // navigation target is not. This one deliberately allows absolute http(s),
19
+ // mailto: and tel:. Harden one, re-read the other; do not unify them.
20
+ // packages/server/test/redirect-predicate-parity.test.js gates that ordering.
21
+
22
+ const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
10
23
 
11
24
  export function isSafeUrl(url) {
12
25
  if (typeof url !== 'string') return false;
@@ -16,6 +29,13 @@ export function isSafeUrl(url) {
16
29
  if (normalized.startsWith('javascript:')) return false;
17
30
  if (normalized.startsWith('data:')) return false;
18
31
  if (normalized.startsWith('vbscript:')) return false;
32
+ if (/^[/\\]{2}/.test(normalized)) return false;
33
+ const scheme = normalized.match(/^([a-z][a-z0-9+.-]*:)/);
34
+ if (scheme) return SAFE_PROTOCOLS.has(scheme[1]);
35
+ // Scheme-less only: browsers normalize a backslash to a forward slash, so
36
+ // `\evil.com` resolves off-origin exactly as `/\evil.com` does. Absolute
37
+ // URLs already passed the protocol allowlist above.
38
+ if (normalized.includes('\\')) return false;
19
39
  return true;
20
40
  }
21
41
 
@@ -25,6 +45,12 @@ const _url = signal(typeof location !== 'undefined' ? location.pathname + locati
25
45
  const _params = signal({});
26
46
  const _query = signal({});
27
47
  const _isNavigating = signal(false);
48
+ // The URL a navigation is heading TO, live for the duration of the navigation.
49
+ // _url only commits at the very end, so during an awaited guard the router still
50
+ // matches the route being LEFT. Without this, a destination route's `loading:`
51
+ // component could never be shown: the only `loading:` in scope belonged to the
52
+ // page the user was leaving.
53
+ const _pendingUrl = signal(null);
28
54
  const _navigationError = signal(null);
29
55
 
30
56
  export const route = {
@@ -40,15 +66,49 @@ export const route = {
40
66
  get error() { return _navigationError(); },
41
67
  };
42
68
 
69
+ // --- Navigation Hooks ---
70
+ // Subscriber lists consulted by navigate(). Guards run before the URL changes
71
+ // and can cancel by returning false; afterNavigate runs once the URL committed.
72
+ // Module singletons on purpose: this module is browser-only (it listens for
73
+ // popstate and mutates history), so its scope is one tab, not one request. The
74
+ // server adapters import what-router/match, never this file, so there is no
75
+ // shared-process path here of the kind b066671 fixed for server actions.
76
+
77
+ const _beforeHooks = [];
78
+ const _afterHooks = [];
79
+
80
+ function subscribe(list, fn) {
81
+ list.push(fn);
82
+ return () => {
83
+ const i = list.indexOf(fn);
84
+ if (i !== -1) list.splice(i, 1);
85
+ };
86
+ }
87
+
88
+ export function beforeNavigate(fn) {
89
+ return subscribe(_beforeHooks, fn);
90
+ }
91
+
92
+ export function afterNavigate(fn) {
93
+ return subscribe(_afterHooks, fn);
94
+ }
95
+
43
96
  // --- Navigation with View Transitions ---
44
97
 
45
98
  export async function navigate(to, opts = {}) {
46
- const { replace = false, state = null, transition = true, _fromPopstate = false } = opts;
99
+ const { replace = false, state = null, transition = true, _fromPopstate = false, _redirectChain = false } = opts;
100
+
101
+ // A navigation the user asked for starts a fresh redirect chain; a hop
102
+ // queued by handleRedirect continues the current one.
103
+ if (!_redirectChain) _redirectHistory.length = 0;
47
104
 
48
105
  // Reject unsafe URLs
49
106
  if (!isSafeUrl(to)) {
50
107
  if (typeof console !== 'undefined') {
51
- console.warn(`[what-router] Blocked navigation to unsafe URL: ${to}`);
108
+ // isSafeUrl() rejects non-strings, so the value reaching this warning is
109
+ // exactly the kind that can throw on template-literal coercion (Symbol,
110
+ // null-prototype object). Log it as a separate argument.
111
+ console.warn('[what-router] Blocked navigation to unsafe URL:', to);
52
112
  }
53
113
  return;
54
114
  }
@@ -68,10 +128,39 @@ export async function navigate(to, opts = {}) {
68
128
  // Don't navigate if already on the same URL
69
129
  if (to === _url()) return;
70
130
 
71
- // Prevent concurrent navigations wait for current to finish
131
+ // Prevent concurrent navigations: wait for the current one to finish. The
132
+ // flag is claimed in the same tick as the check, because an awaited
133
+ // beforeNavigate hook otherwise leaves a gap two navigations both get through.
72
134
  if (_isNavigating.peek()) return;
73
-
74
135
  _isNavigating.set(true);
136
+ _pendingUrl.set(to);
137
+
138
+ const from = _url();
139
+
140
+ // A popstate has already moved the browser URL, so cancelling one means
141
+ // pushing the previous entry back to keep the address bar in sync. That entry
142
+ // is a new one: the forward entry is not recoverable and its history.state is
143
+ // not carried over. Documented on beforeNavigate in index.d.ts.
144
+ if (_beforeHooks.length) {
145
+ let cancelled = false;
146
+ try {
147
+ for (const fn of _beforeHooks.slice()) {
148
+ if ((await fn(to, from)) === false) { cancelled = true; break; }
149
+ }
150
+ } catch (e) {
151
+ // A throwing guard must not leave the router permanently wedged.
152
+ _isNavigating.set(false);
153
+ _pendingUrl.set(null);
154
+ throw e;
155
+ }
156
+ if (cancelled) {
157
+ _isNavigating.set(false);
158
+ _pendingUrl.set(null);
159
+ if (_fromPopstate && typeof history !== 'undefined') history.pushState(null, '', from);
160
+ return;
161
+ }
162
+ }
163
+
75
164
  _navigationError.set(null);
76
165
 
77
166
  const doNavigation = () => {
@@ -79,7 +168,7 @@ export async function navigate(to, opts = {}) {
79
168
  if (!_fromPopstate) {
80
169
  // Save scroll position for current URL before navigating away
81
170
  if (typeof window !== 'undefined') {
82
- scrollPositions.set(_url(), { x: scrollX, y: scrollY });
171
+ scrollPositions.set(_url(), { x: window.scrollX, y: window.scrollY });
83
172
  }
84
173
  if (replace) {
85
174
  history.replaceState(state, '', to);
@@ -89,6 +178,7 @@ export async function navigate(to, opts = {}) {
89
178
  }
90
179
  _url.set(to);
91
180
  _isNavigating.set(false);
181
+ _pendingUrl.set(null);
92
182
  };
93
183
 
94
184
  // Use View Transitions API if available and enabled
@@ -101,13 +191,71 @@ export async function navigate(to, opts = {}) {
101
191
  } else {
102
192
  doNavigation();
103
193
  }
194
+
195
+ if (_afterHooks.length) {
196
+ for (const fn of _afterHooks.slice()) fn(to, from);
197
+ }
198
+ }
199
+
200
+ // --- redirect() ---
201
+ // Throws a navigation signal. Two places catch it:
202
+ //
203
+ // - Route middleware, caught by the Router's own matching pass below.
204
+ // - A component body, caught by core's createComponent, which invokes the
205
+ // handler the signal carries under Symbol.for('what.navigation.signal')
206
+ // rather than reporting the throw to an ErrorBoundary. h() is lazy, so a
207
+ // route component is instantiated after the matching pass has returned;
208
+ // that is why the second catch has to live in core.
209
+ //
210
+ // Anywhere else (an event handler, a promise callback, a timer) nothing catches
211
+ // it and the signal surfaces as an uncaught error. It carries a code, a fix and
212
+ // an example for exactly that case, because that is the only case a human reads
213
+ // it in.
214
+
215
+ const REDIRECT = Symbol.for('what.router.redirect');
216
+ const NAV_SIGNAL = Symbol.for('what.navigation.signal');
217
+
218
+ export function redirect(to, options = {}) {
219
+ if (!isSafeUrl(to)) {
220
+ const target = typeof to === 'string' ? to : Object.prototype.toString.call(to);
221
+ const err = new Error(`[what-router] redirect() refused an unsafe target: ${target}`);
222
+ err.code = 'ERR_UNSAFE_REDIRECT';
223
+ err.suggestion = 'redirect() accepts same-origin paths and http:, https:, mailto: or tel: URLs only. Protocol-relative ("//host"), backslash-smuggled and javascript:/data: targets are open-redirect vectors. Check a user-supplied target against an allowlist first.';
224
+ err.codeExample = `// Bad - a user-controlled target can leave your origin:
225
+ redirect(query.next);
226
+
227
+ // Good - allowlist the target first:
228
+ redirect(ALLOWED.has(query.next) ? query.next : '/');`;
229
+ throw err;
230
+ }
231
+
232
+ const sig = new Error(`[what-router] redirect to ${to}`);
233
+ sig.name = 'RouterRedirect';
234
+ sig.code = 'ERR_REDIRECT_NOT_CAUGHT';
235
+ sig.suggestion = 'Seeing this signal in your console means nothing caught it. redirect() works from route middleware and from a component body, where the Router catches it. From an event handler, a promise callback or a timer, call navigate(to) instead. A try/catch around the redirect() call also swallows it.';
236
+ sig.codeExample = `// Bad - an event handler runs long after the render the Router caught:
237
+ <button onclick={() => redirect('/login')}>Sign in</button>
238
+
239
+ // Good - navigate() from a handler:
240
+ <button onclick={() => navigate('/login')}>Sign in</button>
241
+
242
+ // Good - redirect() from a component body, which the Router catches:
243
+ function Private() {
244
+ if (!user()) redirect('/login');
245
+ return <Secret />;
246
+ }`;
247
+ sig[REDIRECT] = true;
248
+ sig[NAV_SIGNAL] = () => { handleRedirect(to, options); };
249
+ sig.to = to;
250
+ sig.options = options;
251
+ throw sig;
104
252
  }
105
253
 
106
254
  // Back/forward support — route through navigate() so middleware runs
107
255
  if (typeof window !== 'undefined') {
108
256
  window.addEventListener('popstate', () => {
109
257
  // Save scroll position for the URL we're leaving
110
- scrollPositions.set(_url(), { x: scrollX, y: scrollY });
258
+ scrollPositions.set(_url(), { x: window.scrollX, y: window.scrollY });
111
259
 
112
260
  const newUrl = location.pathname + location.search + location.hash;
113
261
  // Use _fromPopstate flag so navigate() skips pushState (browser already updated URL)
@@ -164,14 +312,71 @@ function buildLayoutChain(route, routes) {
164
312
  const _redirectHistory = [];
165
313
  const MAX_REDIRECTS = 10;
166
314
 
315
+ function loopScreen(message) {
316
+ return h('div', { class: 'what-redirect-loop' },
317
+ h('h1', null, 'Redirect Loop'),
318
+ h('p', null, message)
319
+ );
320
+ }
321
+
322
+ // Shared by the middleware string form and by a thrown redirect() signal.
323
+ // Returns null once the navigation is queued, or the loop screen if the
324
+ // redirect chain is cycling.
325
+ //
326
+ // The chain is scoped to one user navigation: navigate() clears the history
327
+ // unless it is being called from here. Clearing it on a successful match
328
+ // instead would never catch a cycle between two route components, because each
329
+ // hop matches successfully before its component throws the next redirect.
330
+ function handleRedirect(target, options) {
331
+ _redirectHistory.push(target);
332
+
333
+ if (_redirectHistory.length > MAX_REDIRECTS) {
334
+ const cycle = _redirectHistory.slice(-5).join(' → ');
335
+ _redirectHistory.length = 0;
336
+ console.error(`[what-router] Redirect loop detected: ${cycle}`);
337
+ _isNavigating.set(false);
338
+ _pendingUrl.set(null);
339
+ return loopScreen('Too many redirects. Check your middleware configuration.');
340
+ }
341
+
342
+ const seen = new Set();
343
+ let hasCycle = false;
344
+ for (const url of _redirectHistory) {
345
+ if (seen.has(url)) { hasCycle = true; break; }
346
+ seen.add(url);
347
+ }
348
+ if (hasCycle) {
349
+ const cycle = _redirectHistory.join(' → ');
350
+ _redirectHistory.length = 0;
351
+ console.error(`[what-router] Redirect cycle detected: ${cycle}`);
352
+ _isNavigating.set(false);
353
+ _pendingUrl.set(null);
354
+ return loopScreen('Circular redirect detected. Check your middleware configuration.');
355
+ }
356
+
357
+ navigate(target, { replace: true, ...options, _redirectChain: true });
358
+ return null;
359
+ }
360
+
167
361
  // --- Router Component ---
168
362
 
363
+ // The `loading:` component of the route a navigation is heading to, or null when
364
+ // there is no pending destination, it does not match, it declares no loading
365
+ // component, or it is the route already being rendered.
366
+ function destinationLoading(pendingUrl, routes, currentRoute) {
367
+ if (!pendingUrl) return null;
368
+ const destPath = pendingUrl.split('?')[0].split('#')[0];
369
+ const dest = matchRoute(destPath, routes);
370
+ if (!dest || dest.route === currentRoute) return null;
371
+ return dest.route.loading || null;
372
+ }
373
+
169
374
  export function Router({ routes, fallback, globalLayout }) {
170
375
  // The Router component runs ONCE. `content` is a reactive function child that
171
376
  // re-evaluates whenever _url changes; the fine-grained runtime reconciles only
172
377
  // the matched page in place. The globalLayout is rendered ONCE around it (below)
173
378
  // so the app shell persists across navigations instead of re-instantiating.
174
- const content = () => {
379
+ const renderMatch = () => {
175
380
  const currentUrl = _url();
176
381
  const path = currentUrl.split('?')[0].split('#')[0];
177
382
  const search = currentUrl.split('?')[1]?.split('#')[0] || '';
@@ -198,48 +403,26 @@ export function Router({ routes, fallback, globalLayout }) {
198
403
  return h('div', { class: 'what-403' }, h('h1', null, '403'), h('p', null, 'Access denied'));
199
404
  }
200
405
  if (typeof result === 'string') {
201
- // Redirect loop detection
202
- _redirectHistory.push(result);
203
- if (_redirectHistory.length > MAX_REDIRECTS) {
204
- const cycle = _redirectHistory.slice(-5).join(' → ');
205
- _redirectHistory.length = 0;
206
- console.error(`[what-router] Redirect loop detected: ${cycle}`);
207
- _isNavigating.set(false);
208
- return h('div', { class: 'what-redirect-loop' },
209
- h('h1', null, 'Redirect Loop'),
210
- h('p', null, 'Too many redirects. Check your middleware configuration.')
211
- );
212
- }
213
- // Check for direct cycle (A → B → A)
214
- const seen = new Set();
215
- let hasCycle = false;
216
- for (const url of _redirectHistory) {
217
- if (seen.has(url)) { hasCycle = true; break; }
218
- seen.add(url);
219
- }
220
- if (hasCycle) {
221
- const cycle = _redirectHistory.join(' → ');
222
- _redirectHistory.length = 0;
223
- console.error(`[what-router] Redirect cycle detected: ${cycle}`);
224
- _isNavigating.set(false);
225
- return h('div', { class: 'what-redirect-loop' },
226
- h('h1', null, 'Redirect Loop'),
227
- h('p', null, 'Circular redirect detected. Check your middleware configuration.')
228
- );
229
- }
230
406
  // Middleware returned a redirect path
231
- navigate(result, { replace: true });
232
- return null;
407
+ return handleRedirect(result);
233
408
  }
234
409
  }
235
410
  }
236
- // Successful render — clear redirect history
237
- _redirectHistory.length = 0;
238
411
 
239
412
  // Build element with loading state support
240
413
  let element;
241
414
 
242
- if (r.loading && isNavigating) {
415
+ // While a navigation is in flight, a `loading:` declared by the
416
+ // DESTINATION wins. _url has not committed yet, so `r` is still the route
417
+ // being left: without this, declaring `loading:` on the page you are
418
+ // navigating TO could never show it. Falling back to the departing route's
419
+ // `loading:` preserves the existing behaviour for the case where only the
420
+ // page being left declares one.
421
+ const pendingLoading = isNavigating ? destinationLoading(_pendingUrl(), routes, r) : null;
422
+
423
+ if (pendingLoading) {
424
+ element = h(pendingLoading, {});
425
+ } else if (r.loading && isNavigating) {
243
426
  element = h(r.loading, {});
244
427
  } else {
245
428
  element = h(r.component, {
@@ -271,6 +454,21 @@ export function Router({ routes, fallback, globalLayout }) {
271
454
  );
272
455
  };
273
456
 
457
+ // Catches a redirect() thrown by route middleware. A redirect() thrown by a
458
+ // route component is caught by core instead, because h() is lazy and the
459
+ // component runs after renderMatch has returned. Anything unbranded
460
+ // propagates to the app's ErrorBoundary unchanged.
461
+ const content = () => {
462
+ let sig = null;
463
+ try {
464
+ return renderMatch();
465
+ } catch (e) {
466
+ if (!e || !e[REDIRECT]) throw e;
467
+ sig = e;
468
+ }
469
+ return handleRedirect(sig.to, sig.options);
470
+ };
471
+
274
472
  // Render the global layout ONCE so it — and everything it mounts (sidebars,
275
473
  // toasters, command palettes, global key listeners) — PERSISTS across
276
474
  // navigations; the reactive `content` child swaps the matched page in place.
@@ -299,7 +497,7 @@ export function Link({
299
497
  // Sanitize href — reject dangerous protocols
300
498
  const safeHref = isSafeUrl(href) ? href : 'about:blank';
301
499
  if (!isSafeUrl(href) && typeof console !== 'undefined') {
302
- console.warn(`[what-router] Link blocked unsafe href: ${href}`);
500
+ console.warn('[what-router] Link blocked unsafe href:', href);
303
501
  }
304
502
 
305
503
  // Strip query string and hash from href for path comparison
@@ -356,12 +554,23 @@ export function defineRoutes(config) {
356
554
 
357
555
  // --- Nested Route Helper ---
358
556
 
557
+ // Join a base path with a child path. Naive concatenation turned the index
558
+ // child of `nestedRoutes('/dashboard', [{ path: '/' }, ...])` — the form the
559
+ // README documents — into '/dashboard/', which '/dashboard' does not match, so
560
+ // the documented example 404s on its own index route.
561
+ function joinRoutePath(basePath, childPath) {
562
+ const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
563
+ const child = !childPath || childPath === '/' ? '/' : (childPath.startsWith('/') ? childPath : `/${childPath}`);
564
+ if (child === '/') return base || '/';
565
+ return base + child;
566
+ }
567
+
359
568
  export function nestedRoutes(basePath, children, options = {}) {
360
569
  const { layout, loading, error } = options;
361
570
 
362
571
  return children.map(child => ({
363
572
  ...child,
364
- path: basePath + child.path,
573
+ path: joinRoutePath(basePath, child.path),
365
574
  layout: child.layout || layout,
366
575
  loading: child.loading || loading,
367
576
  error: child.error || error,
@@ -535,6 +744,29 @@ export function useRoute() {
535
744
  };
536
745
  }
537
746
 
747
+ // --- Route Accessors ---
748
+ // Read the singleton route state directly. Called inside a tracking scope
749
+ // (a reactive text binding, computed or effect) they subscribe to it.
750
+
751
+ export function useParams() {
752
+ return route.params;
753
+ }
754
+
755
+ // Same singleton `_query` signal as route.query: only the Router's match branch
756
+ // writes it, so on an unmatched (404) route this is the last matched route's
757
+ // query, not the current URL's. Documented on both declarations.
758
+ export function useSearch() {
759
+ return route.query;
760
+ }
761
+
762
+ export function useNavigate() {
763
+ return navigate;
764
+ }
765
+
766
+ export function prefetchRoute(href) {
767
+ prefetch(href);
768
+ }
769
+
538
770
  // --- Outlet Component ---
539
771
  // For nested route rendering
540
772
 
@@ -555,11 +787,16 @@ export function FileRouter({
555
787
  fallback,
556
788
  error: globalError,
557
789
  }) {
558
- // Convert file-router route format to Router's expected format
790
+ // Convert file-router route format to Router's expected format.
791
+ // loading/error used to be dropped here, so a route could declare either and
792
+ // never see it rendered, and the `error` prop was destructured and then never
793
+ // read: a documented public prop that silently did nothing.
559
794
  const routerRoutes = routes.map(r => ({
560
795
  path: r.path,
561
796
  component: r.component,
562
797
  layout: r.layout || undefined,
798
+ loading: r.loading || undefined,
799
+ error: r.error || globalError || undefined,
563
800
  // Attach page mode as metadata for build system
564
801
  _mode: r.mode || 'client',
565
802
  }));
package/src/match.js CHANGED
@@ -42,33 +42,75 @@ export function compilePath(path) {
42
42
  return { regex, paramNames, catchAll };
43
43
  }
44
44
 
45
+ // Compiled patterns are stable per route path, so cache them instead of
46
+ // rebuilding a RegExp for every route on every request.
47
+ const patternCache = new Map();
48
+ const PATTERN_CACHE_MAX = 1000;
49
+
50
+ function getPattern(path) {
51
+ let compiled = patternCache.get(path);
52
+ if (!compiled) {
53
+ compiled = compilePath(path);
54
+ if (patternCache.size >= PATTERN_CACHE_MAX) patternCache.clear();
55
+ patternCache.set(path, compiled);
56
+ }
57
+ return compiled;
58
+ }
59
+
60
+ // The `([^/]+)` segment guard runs against the still-encoded path, so a param
61
+ // only becomes multi-segment once it is decoded ("%2e%2e%2f" -> "../"). Reject
62
+ // any decoded value that smuggles in a separator or a traversal segment, and
63
+ // treat malformed percent-escapes as a non-match instead of throwing.
64
+ function decodeParam(value, isCatchAll) {
65
+ let decoded;
66
+ try {
67
+ decoded = decodeURIComponent(value);
68
+ } catch {
69
+ return null;
70
+ }
71
+ const segments = decoded.split(/[/\\]/);
72
+ if (!isCatchAll && segments.length > 1) return null;
73
+ if (segments.includes('..')) return null;
74
+ return decoded;
75
+ }
76
+
45
77
  export function matchRoute(path, routes) {
46
78
  // Filter out routes without a path (layout-only routes, etc.)
47
- const routable = routes.filter(r => r.path);
79
+ // `filter` already copies, so sorting below never touches the caller's array.
80
+ const sorted = routes.filter(r => r.path);
48
81
 
49
82
  // Sort routes by specificity (more specific first)
50
- const sorted = routable.sort((a, b) => {
83
+ sorted.sort((a, b) => {
51
84
  const aSpecific = (a.path.match(/:/g) || []).length + (a.path.includes('*') ? 100 : 0);
52
85
  const bSpecific = (b.path.match(/:/g) || []).length + (b.path.includes('*') ? 100 : 0);
53
86
  return aSpecific - bSpecific;
54
87
  });
55
88
 
56
89
  for (const route of sorted) {
57
- const { regex, paramNames } = compilePath(route.path);
90
+ const { regex, paramNames, catchAll } = getPattern(route.path);
58
91
  const match = path.match(regex);
59
92
  if (match) {
60
93
  const params = {};
94
+ let rejected = false;
61
95
  paramNames.forEach((name, i) => {
62
- params[name] = decodeURIComponent(match[i + 1]);
96
+ const decoded = decodeParam(match[i + 1], name === catchAll);
97
+ if (decoded === null) {
98
+ rejected = true;
99
+ return;
100
+ }
101
+ params[name] = decoded;
63
102
  });
103
+ if (rejected) continue;
64
104
  return { route, params };
65
105
  }
66
106
  }
67
107
  return null;
68
108
  }
69
109
 
110
+ // Null-prototype so query keys like "toString" or "__proto__" stay plain data
111
+ // instead of colliding with inherited members or replacing the prototype.
70
112
  export function parseQuery(search) {
71
- const params = {};
113
+ const params = Object.create(null);
72
114
  if (!search) return params;
73
115
  const qs = search.startsWith('?') ? search.slice(1) : search;
74
116
  for (const pair of qs.split('&')) {
@@ -76,7 +118,7 @@ export function parseQuery(search) {
76
118
  if (!key) continue;
77
119
  const decodedKey = decodeURIComponent(key);
78
120
  const decodedVal = val ? decodeURIComponent(val) : '';
79
- if (decodedKey in params) {
121
+ if (Object.prototype.hasOwnProperty.call(params, decodedKey)) {
80
122
  // Collect repeated keys into arrays
81
123
  if (Array.isArray(params[decodedKey])) {
82
124
  params[decodedKey].push(decodedVal);