what-router 0.12.2 → 0.12.4

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/dist/index.min.js CHANGED
@@ -1,8 +1,8 @@
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:
1
+ import{signal as R,effect as $,computed as b,batch as M,h as c,ErrorBoundary as X}from"what-core";function O(e){let n=e.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(i,l)=>`*:${l}`).replace(/\[(\w+)\]/g,":$1"),t=[],r=null,o=n.split("/").map(i=>i.startsWith("*:")?(r=i.slice(2),t.push(r),"(.+)"):i==="*"?(r="rest",t.push("rest"),"(.+)"):i.startsWith(":")?(t.push(i.slice(1)),"([^/]+)"):i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:t,catchAll:r}}var S=new Map,z=1e3;function H(e){let n=S.get(e);return n||(n=O(e),S.size>=z&&S.clear(),S.set(e,n)),n}function K(e,n){let t;try{t=decodeURIComponent(e)}catch{return null}let r=t.split(/[/\\]/);return!n&&r.length>1||r.includes("..")?null:t}function _(e,n){let t=n.filter(r=>r.path);t.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 t){let{regex:o,paramNames:a,catchAll:i}=H(r.path),l=e.match(o);if(l){let p={},u=!1;if(a.forEach((s,d)=>{let m=K(l[d+1],s===i);if(m===null){u=!0;return}p[s]=m}),u)continue;return{route:r,params:p}}}return null}function A(e){let n=Object.create(null);if(!e)return n;let t=e.startsWith("?")?e.slice(1):e;for(let r of t.split("&")){let[o,a]=r.split("=");if(!o)continue;let i=decodeURIComponent(o),l=a?decodeURIComponent(a):"";Object.prototype.hasOwnProperty.call(n,i)?Array.isArray(n[i])?n[i].push(l):n[i]=[n[i],l]:n[i]=l}return n}var Q=new Set(["http:","https:","mailto:","tel:"]);function C(e){if(typeof e!="string")return!1;let t=e.trim().replace(/[\s\x00-\x1f]/g,"").toLowerCase();if(t.startsWith("javascript:")||t.startsWith("data:")||t.startsWith("vbscript:")||/^[/\\]{2}/.test(t))return!1;let r=t.match(/^([a-z][a-z0-9+.-]*:)/);return r?Q.has(r[1]):!t.includes("\\")}var h=R(typeof location<"u"?location.pathname+location.search+location.hash:"/"),G=R({}),F=R({}),y=R(!1),x=R(null),B=R(null),g={get url(){return h()},get path(){return h().split("?")[0].split("#")[0]},get params(){return G()},get query(){return F()},get hash(){let e=h().split("#")[1];return e?"#"+e:""},get isNavigating(){return y()},get error(){return B()}},T=[],q=[];function D(e,n){return e.push(n),()=>{let t=e.indexOf(n);t!==-1&&e.splice(t,1)}}function le(e){return D(T,e)}function fe(e){return D(q,e)}async function v(e,n={}){let{replace:t=!1,state:r=null,transition:o=!0,_fromPopstate:a=!1,_redirectChain:i=!1}=n;if(i||(w.length=0),!C(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 m=document.querySelector(e);m&&m.scrollIntoView({behavior:"smooth"});return}if(e===h()||y.peek())return;y.set(!0),x.set(e);let l=h();if(T.length){let u=!1;try{for(let s of T.slice())if(await s(e,l)===!1){u=!0;break}}catch(s){throw y.set(!1),x.set(null),s}if(u){y.set(!1),x.set(null),a&&typeof history<"u"&&history.pushState(null,"",l);return}}B.set(null);let p=()=>{a||(typeof window<"u"&&k.set(h(),{x:window.scrollX,y:window.scrollY}),t?history.replaceState(r,"",e):history.pushState(r,"",e)),h.set(e),y.set(!1),x.set(null)};if(o&&typeof document<"u"&&document.startViewTransition)try{let u=document.startViewTransition(p);u.ready&&typeof u.ready.catch=="function"&&u.ready.catch(()=>{}),await u.finished}catch{}else p();if(q.length)for(let u of q.slice())u(e,l)}var V=Symbol.for("what.router.redirect"),Y=Symbol.for("what.navigation.signal");function de(e,n={}){if(!C(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
2
  redirect(query.next);
3
3
 
4
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:
5
+ redirect(ALLOWED.has(query.next) ? query.next : '/');`,o}let t=new Error(`[what-router] redirect to ${e}`);throw t.name="RouterRedirect",t.code="ERR_REDIRECT_NOT_CAUGHT",t.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.",t.codeExample=`// Bad - an event handler runs long after the render the Router caught:
6
6
  <button onclick={() => redirect('/login')}>Sign in</button>
7
7
 
8
8
  // Good - navigate() from a handler:
@@ -12,4 +12,4 @@ redirect(ALLOWED.has(query.next) ? query.next : '/');`,o}let n=new Error(`[what-
12
12
  function Private() {
13
13
  if (!user()) redirect('/login');
14
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};
15
+ }`,t[V]=!0,t[Y]=()=>{L(e,n)},t.to=e,t.options=n,t}typeof window<"u"&&window.addEventListener("popstate",()=>{k.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 n=k.get(e);n&&requestAnimationFrame(()=>window.scrollTo(n.x,n.y))})});function J(e,n){let t=[];if(!e.path)return t;let r=e.path.split("/").filter(Boolean),o="";for(let a of r){o+="/"+a;let i=n.find(l=>l.layout&&l.path===o+"/_layout");i&&t.push(i.layout)}return e.layout&&t.push(e.layout),t}var w=[],Z=10;function I(e){return c("div",{class:"what-redirect-loop"},c("h1",null,"Redirect Loop"),c("p",null,e))}function L(e,n){if(x.peek()===e)return null;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),x.set(null),I("Too many redirects. Check your middleware configuration.")}let t=new Set,r=!1;for(let o of w){if(t.has(o)){r=!0;break}t.add(o)}if(r){let o=w.join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect cycle detected: ${o}`),y.set(!1),x.set(null),I("Circular redirect detected. Check your middleware configuration.")}return v(e,{replace:!0,...n,_redirectChain:!0}),null}function ee(e,n,t){if(!e)return null;let r=e.split("?")[0].split("#")[0],o=_(r,n);return!o||o.route===t?null:o.route.loading||null}function te({routes:e,fallback:n,globalLayout:t}){let r=()=>{let a=h(),i=a.split("?")[0].split("#")[0],l=a.split("?")[1]?.split("#")[0]||"",p=y(),u=_(i,e);if(u){M(()=>{G.set(u.params),F.set(A(l))});let{route:s,params:d}=u,m=A(l);if(s.middleware&&s.middleware.length>0)for(let N of s.middleware){let P=N({path:i,params:d,query:m,route:s});if(P===!1)return n?c(n,{}):c("div",{class:"what-403"},c("h1",null,"403"),c("p",null,"Access denied"));if(typeof P=="string")return L(P)}let f,E=p?ee(x(),e,s):null;E?f=c(E,{}):s.loading&&p?f=c(s.loading,{}):f=c(s.component,{params:d,query:m,route:s}),s.error&&(f=c(X,{fallback:s.error},f));let j=J(s,e);for(let N of j.reverse())f=c(N,{params:d,query:m},f);return f}return n?c(n,{}):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[V])throw i;a=i}return L(a.to,a.options)};return t?c(t,{},o):o}function ne({href:e,class:n,className:t,children:r,replace:o,prefetch:a=!0,activeClass:i="active",exactActiveClass:l="exact-active",transition:p=!0,...u}){let s=C(e)?e:"about:blank";!C(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=g.path,E=d==="/"?f==="/":f===d||f.startsWith(d+"/");return[n||t,E&&i,f===d&&l].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,...u},...Array.isArray(r)?r:[r])}function pe(e){return ne(e)}function he(e){return Object.entries(e).map(([n,t])=>typeof t=="function"?{path:n,component:t}:{path:n,...t})}function re(e,n){let t=e.endsWith("/")?e.slice(0,-1):e,r=!n||n==="/"?"/":n.startsWith("/")?n:`/${n}`;return r==="/"?t||"/":t+r}function me(e,n,t={}){let{layout:r,loading:o,error:a}=t;return n.map(i=>({...i,path:re(e,i.path),layout:i.layout||r,loading:i.loading||o,error:i.error||a}))}function ge(e,n,t={}){let{layout:r,middleware:o}=t;return n.map(a=>({...a,_group:e,layout:a.layout||r,middleware:[...a.middleware||[],...o||[]]}))}function ye({to:e}){return v(e,{replace:!0}),null}function we(e,n){return t=>function(o){let a=e(o);return a instanceof Promise?c("div",{class:"what-guard-loading"},"Loading..."):a?c(t,o):typeof n=="string"?(v(n,{replace:!0}),null):c(n,o)}}function xe(e,n={}){let{fallback:t="/login",loading:r=null}=n;return o=>function(i){let l=R("pending"),p=R(null),u=!1;return $(()=>(u=!1,Promise.resolve(e(i)).then(s=>{u||(p.set(s),l.set(s?"allowed":"denied"))}).catch(()=>{u||l.set("denied")}),()=>{u=!0})),()=>{let s=l();return s==="pending"?r?c(r,{}):null:s==="allowed"?c(o,i):typeof t=="string"?(v(t,{replace:!0}),null):c(t,i)}}}var W=new Set;function U(e){if(typeof document>"u"||W.has(e))return;W.add(e);let n=document.createElement("link");n.rel="prefetch",n.href=e,document.head.appendChild(n)}var k=new Map;function Re(){if(typeof window>"u")return;let e=!0;$(()=>{let n=g.url,t=k.get(n),r=g.hash,o=e;e=!1,requestAnimationFrame(()=>{r?oe(r)?.scrollIntoView():t?window.scrollTo(t.x,t.y):o||window.scrollTo(0,0)})})}function oe(e){try{return document.querySelector(e)}catch{return document.getElementById(e.slice(1))}}function ve(e){return{style:{viewTransitionName:e}}}function be(e){typeof document>"u"||(document.documentElement.dataset.transition=e)}function Ee(){return{path:b(()=>g.path),params:b(()=>g.params),query:b(()=>g.query),hash:b(()=>g.hash),isNavigating:b(()=>g.isNavigating),navigate:v,prefetch:U}}function Se(){return g.params}function _e(){return g.query}function Ae(){return v}function Ce(e){U(e)}function ke({children:e}){return e||null}function Ne({routes:e,layout:n,fallback:t,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:n,fallback:t||ie})}function ie(){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{Ne as FileRouter,ne as Link,pe as NavLink,ke as Outlet,ye as Redirect,te as Router,fe as afterNavigate,xe as asyncGuard,le as beforeNavigate,O as compilePath,he as defineRoutes,Re as enableScrollRestoration,we as guard,C as isSafeUrl,_ as matchRoute,v as navigate,me as nestedRoutes,A as parseQuery,U as prefetch,Ce as prefetchRoute,de as redirect,g as route,ge as routeGroup,be as setViewTransition,Ae as useNavigate,Se as useParams,Ee as useRoute,_e as useSearch,ve as viewTransitionName};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-router",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "What Framework - File-based & programmatic router with View Transitions",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -35,7 +35,7 @@
35
35
  "license": "MIT",
36
36
  "dependencies": {},
37
37
  "peerDependencies": {
38
- "what-core": "^0.12.2"
38
+ "what-core": "^0.12.4"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
package/src/index.js CHANGED
@@ -166,7 +166,9 @@ export async function navigate(to, opts = {}) {
166
166
  const doNavigation = () => {
167
167
  // Skip history manipulation on popstate (browser already updated the URL)
168
168
  if (!_fromPopstate) {
169
- // Save scroll position for current URL before navigating away
169
+ // Save scroll position for current URL before navigating away.
170
+ // Full URL as the key, { x, y } as the value: see scrollPositions below,
171
+ // every writer and the reader must agree on both.
170
172
  if (typeof window !== 'undefined') {
171
173
  scrollPositions.set(_url(), { x: window.scrollX, y: window.scrollY });
172
174
  }
@@ -184,7 +186,16 @@ export async function navigate(to, opts = {}) {
184
186
  // Use View Transitions API if available and enabled
185
187
  if (transition && typeof document !== 'undefined' && document.startViewTransition) {
186
188
  try {
187
- await document.startViewTransition(doNavigation).finished;
189
+ const viewTransition = document.startViewTransition(doNavigation);
190
+ // `.ready` rejects when the transition is skipped, which two navigations
191
+ // in quick succession do routinely. Awaiting only `.finished` left that
192
+ // rejection unhandled, so it surfaced as an uncaught page error
193
+ // ("Transition was skipped") and landed in whatever error reporter the
194
+ // app had installed, for a navigation that actually succeeded.
195
+ if (viewTransition.ready && typeof viewTransition.ready.catch === 'function') {
196
+ viewTransition.ready.catch(() => {});
197
+ }
198
+ await viewTransition.finished;
188
199
  } catch (e) {
189
200
  // Transition failed, navigation still happened
190
201
  }
@@ -328,6 +339,23 @@ function loopScreen(message) {
328
339
  // instead would never catch a cycle between two route components, because each
329
340
  // hop matches successfully before its component throws the next redirect.
330
341
  function handleRedirect(target, options) {
342
+ // A navigation to this exact target is already in flight, so this is a
343
+ // re-match of the same hop, not a second one.
344
+ //
345
+ // It happens on every re-match that occurs before the URL commits. With the
346
+ // View Transitions API, navigate() defers the `_url` write into the
347
+ // transition callback while `_isNavigating` flips immediately, and
348
+ // renderMatch reads `_isNavigating`, so the match re-runs against the
349
+ // still-old URL, the same middleware returns the same target, and the hop was
350
+ // counted twice. One guarded deep link produced 25 false "Redirect cycle
351
+ // detected" errors and a flash of the redirect-loop screen, and the detector
352
+ // then cleared `_isNavigating` / `_pendingUrl` while the real navigation was
353
+ // still in flight, corrupting router state mid-navigation.
354
+ //
355
+ // Chromium-only, because without View Transitions doNavigation() runs
356
+ // synchronously and the URL and the flag change together.
357
+ if (_pendingUrl.peek() === target) return null;
358
+
331
359
  _redirectHistory.push(target);
332
360
 
333
361
  if (_redirectHistory.length > MAX_REDIRECTS) {
@@ -690,34 +718,97 @@ export function prefetch(href) {
690
718
 
691
719
  // --- Scroll Restoration ---
692
720
 
721
+ // Saved viewport offsets. ONE key (the full URL: path + search + hash) and ONE
722
+ // shape ({ x, y }) shared by every writer and by the reader below.
723
+ //
724
+ // They used to disagree on both. navigate() and the popstate handler wrote
725
+ // { x, y } keyed by the full URL, while enableScrollRestoration()'s own
726
+ // beforeunload handler wrote a bare NUMBER keyed by location.pathname, and the
727
+ // restore effect looked the entry up by route.path (the pattern-stripped path,
728
+ // never the query or the hash) and handed it straight to
729
+ // `window.scrollTo(0, savedPosition)`. A hit on a { x, y } record therefore
730
+ // produced `scrollTo(0, NaN)`, which browsers normalize to 0 and which jumps
731
+ // the page to the TOP; a URL carrying a query string or a hash missed the
732
+ // lookup entirely and got the same jump. Restoration could not work at all.
733
+ // If you add a writer, key it by the full URL and store { x, y }.
693
734
  const scrollPositions = new Map();
694
735
 
695
736
  export function enableScrollRestoration() {
696
737
  if (typeof window === 'undefined') return;
697
738
 
698
- // Save scroll position before navigation
699
- window.addEventListener('beforeunload', () => {
700
- scrollPositions.set(location.pathname, window.scrollY);
701
- });
702
-
703
- // Restore scroll position after navigation
739
+ // There is deliberately no beforeunload writer. scrollPositions is an
740
+ // in-memory Map that dies with the document, so anything written while the
741
+ // page is unloading can never be read back. The only case where the write
742
+ // survived is the one where the document does NOT go away (the user cancels
743
+ // the "leave site?" prompt), and there it poisoned the map with an entry of
744
+ // the wrong shape under the wrong key. Restoring scroll across a RELOAD is
745
+ // the browser's job (history.scrollRestoration), and the first-run guard
746
+ // below is what keeps this effect out of its way.
747
+
748
+ // The effect's first run is the URL the document loaded with, not a
749
+ // navigation this router performed: the browser has already positioned the
750
+ // document (top for a fresh load, the previous offset for a reload or a
751
+ // document-recreating back). Scrolling to the top there would undo exactly
752
+ // the restoration the comment above defers to. Nothing else is affected,
753
+ // because scrollPositions is necessarily empty on the first run, so the
754
+ // guard can only ever suppress the scroll-to-top branch.
755
+ let firstRun = true;
756
+
757
+ // Restore the saved position after each committed navigation. Reading
758
+ // route.url subscribes to the URL signal, so this re-runs for query-only and
759
+ // hash-only changes too, which is the granularity the map is keyed at.
704
760
  effect(() => {
705
- const path = route.path;
706
- const savedPosition = scrollPositions.get(path);
761
+ const url = route.url;
762
+ const saved = scrollPositions.get(url);
763
+ // Read inside the effect, not inside the frame callback: by the time the
764
+ // frame runs another navigation may already have moved the URL on. Same
765
+ // reason the first-run latch is consumed here rather than in the frame.
766
+ const hash = route.hash;
767
+ const isFirstRun = firstRun;
768
+ firstRun = false;
707
769
 
708
770
  requestAnimationFrame(() => {
709
- if (savedPosition !== undefined) {
710
- window.scrollTo(0, savedPosition);
711
- } else if (route.hash) {
712
- const el = document.querySelector(route.hash);
771
+ // Hash BEFORE saved, and the order is load-bearing. A URL carrying a
772
+ // hash is a request for a specific ELEMENT, and it outranks any offset
773
+ // saved under that same URL: clicking a "/guide#api" link a second time
774
+ // has to land on #api, not on wherever the user happened to be standing
775
+ // the last time they left /guide#api. Because navigate() keys the map by
776
+ // the full URL (hash included), checking `saved` first made this branch
777
+ // unreachable for every hash URL the user had already visited, and it
778
+ // also fought navigate()'s own same-page hash handling: that path
779
+ // scrollIntoViews the anchor synchronously, and a `saved` hit here
780
+ // clobbered it one frame later.
781
+ if (hash) {
782
+ // Deferred to the frame because an SSR-less deep link renders the
783
+ // target element after the URL commits, so it does not exist yet here.
784
+ const el = queryFragment(hash);
785
+ // No fallback when the anchor is missing: it may simply not have
786
+ // rendered yet, and yanking the page to the top (or to some unrelated
787
+ // saved offset) is the worse answer to "I asked for #api".
713
788
  el?.scrollIntoView();
714
- } else {
789
+ } else if (saved) {
790
+ window.scrollTo(saved.x, saved.y);
791
+ } else if (!isFirstRun) {
715
792
  window.scrollTo(0, 0);
716
793
  }
717
794
  });
718
795
  });
719
796
  }
720
797
 
798
+ // Resolve a URL fragment to its element. A fragment is an id, and an id is not
799
+ // necessarily a valid CSS selector: "#1" is legal HTML that browsers navigate
800
+ // to happily, while querySelector('#1') THROWS "Invalid selector". The throw
801
+ // happened inside a requestAnimationFrame callback, where nothing could catch
802
+ // it, so a single numeric anchor took out the frame as an uncaught error.
803
+ function queryFragment(hash) {
804
+ try {
805
+ return document.querySelector(hash);
806
+ } catch {
807
+ // Not selector-parseable, so ask the way the browser itself does.
808
+ return document.getElementById(hash.slice(1));
809
+ }
810
+ }
811
+
721
812
  // --- View Transition Helpers ---
722
813
 
723
814
  export function viewTransitionName(name) {