@symbo.ls/router 3.14.602 → 3.14.603

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
@@ -156,6 +156,27 @@ export const team = {
156
156
  }
157
157
  ```
158
158
 
159
+ ## Scroll Restoration
160
+
161
+ On back/forward the browser restores its own scroll offset (`history.scrollRestoration = 'auto'`) before the router renders. An app that positions the page itself, such as a feed that focuses a view under a sticky header, then jumps to the old offset and corrects it, which shows as a flicker. Set `scrollRestoration` on `router` in your `config.js` to take the restoration over:
162
+
163
+ ```js
164
+ // symbols/config.js
165
+ export default {
166
+ router: {
167
+ scrollRestoration: 'manual' // 'manual' | 'auto'
168
+ }
169
+ }
170
+ ```
171
+
172
+ - Unset (the default): the framework never reads or writes `history.scrollRestoration`. The browser default stays.
173
+ - `'manual'`: the browser stops restoring the offset. The app positions the page.
174
+ - `'auto'`: sets the browser default explicitly.
175
+ - It is applied once, when `create()` builds the app, and `app.destroy()` gives back the value found before the app. Two apps in one realm are safe in any destroy order: the newest live app decides, and the found value returns when the last one is destroyed.
176
+ - The browser stores the mode on each history entry, and a pushed entry inherits it. `destroy()` therefore rewrites the current entry only: entries pushed while the app lived keep `'manual'`.
177
+ - Any other value logs a warning and changes nothing. A realm whose `History` has no `scrollRestoration`, or is not fully active (a detached iframe), is skipped without an error.
178
+ - Calling `router()` directly never touches `history.scrollRestoration`. Do not write it from project code.
179
+
159
180
  ## Options
160
181
 
161
182
  | Option | Type | Default | Description |
@@ -165,6 +186,7 @@ export const team = {
165
186
  | `replace` | `boolean` | `false` | Rewrite the current history entry (`history.replaceState`) instead of pushing a new one — redirects, canonical-URL rewrites, filter/query changes that must not stack Back entries. Ignored when `pushState` is `false` |
166
187
  | `initialRender` | `boolean` | `false` | Whether this is the initial page render |
167
188
  | `scrollToTop` | `boolean` | `true` | Scroll to top after navigation |
189
+ | `scrollRestoration` | `'manual' \| 'auto'` | unset | Create-time `router` option, not per call: takes over the browser's scroll restoration on back/forward. See [Scroll Restoration](#scroll-restoration) |
168
190
  | `scrollToNode` | `boolean` | `false` | Scroll within the element node |
169
191
  | `scrollNode` | `Element` | `document.documentElement` | Node to scroll |
170
192
  | `scrollToOffset` | `number` | `0` | Offset when scrolling to hash anchors |
@@ -176,6 +198,7 @@ export const team = {
176
198
  | `useParamsMatching` | `boolean` | `false` | Enable dynamic `:param` route matching |
177
199
  | `guards` | `function[]` | `undefined` | Array of guard/middleware functions |
178
200
  | `onNotFound` | `function` | `undefined` | Callback when no route matches |
201
+ | `exitTimeout` | `number` | `300` | Longest wait, in ms, for an `onRouteExit` handler before the page swaps anyway. See [Exit motion](#exit-motion-onrouteexit) |
179
202
 
180
203
  ## Exported Utilities
181
204
 
@@ -227,12 +250,38 @@ The router triggers an `onRouteChanged` event on the element after navigation co
227
250
  ```js
228
251
  const App = {
229
252
  routes: { ... },
230
- onRouteChanged: (element, options) => {
253
+ onRouteChanged: (element, state, context, options) => {
231
254
  console.log('Route changed:', element.state.route)
232
255
  }
233
256
  }
234
257
  ```
235
258
 
259
+ ### Exit motion (`onRouteExit`)
260
+
261
+ `onRouteExit` runs on the same element BEFORE the router swaps the page, with the page that is leaving. Return a promise and the router waits for it before it writes history, updates the route state and mounts the new page:
262
+
263
+ ```js
264
+ const App = {
265
+ routes: { ... },
266
+ onRouteExit: (element, state, context, exit) => {
267
+ // exit = { page, from, to, pathname, params, query, hash, signal, timeout }
268
+ const anim = exit.page.node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 150 })
269
+ exit.signal?.addEventListener('abort', () => anim.cancel())
270
+ // A cancelled animation rejects `finished` — treat it as a finished exit,
271
+ // or every cut-short exit logs an async event error.
272
+ return anim.finished.catch(() => {})
273
+ },
274
+ // …and the entrance, after the swap:
275
+ onRouteChanged: (element) => { /* animate element.content.node in */ }
276
+ }
277
+ ```
278
+
279
+ - `exit.page` is the leaving page element (`element[contentElementKey]`); `from` / `to` are the route keys; `pathname`, `params`, `query`, `hash` describe the new URL.
280
+ - The wait is bounded by `exitTimeout` (default `300` ms). On the timeout `exit.signal` aborts and the swap goes ahead; a rejected promise is a finished exit. A slow or broken exit never blocks navigation.
281
+ - No exit runs (and nothing is awaited) for the first route of an element, for a navigation that keeps the page (query-only, same-path hash), under `prefers-reduced-motion: reduce`, or in a tab that is not visible. Without a handler the router behaves exactly as before: the swap happens in the same synchronous run.
282
+ - A navigation that starts while an exit runs takes over. The older one never mounts its page and writes no history entry; a newer navigation that leaves the same page joins the running exit instead of starting a second one.
283
+ - A handler that returns nothing (not a promise) runs, and the swap follows at once.
284
+
236
285
  ## License
237
286
 
238
287
  MIT
package/dist/cjs/index.js CHANGED
@@ -1 +1 @@
1
- "use strict";var O=Object.defineProperty;var Q=Object.getOwnPropertyDescriptor;var D=Object.getOwnPropertyNames;var H=Object.prototype.hasOwnProperty;var J=(e,o)=>{for(var r in o)O(e,r,{get:o[r],enumerable:!0})},V=(e,o,r,s)=>{if(o&&typeof o=="object"||typeof o=="function")for(let t of D(o))!H.call(e,t)&&t!==r&&O(e,t,{get:()=>o[t],enumerable:!(s=Q(o,t))||s.enumerable});return e};var X=e=>V(O({},"__esModule",{value:!0}),e);var nt={};J(nt,{default:()=>ot,getActiveRoute:()=>W,lastLevel:()=>_,lastPathname:()=>S,matchRoute:()=>B,parseQuery:()=>k,parseRoutePattern:()=>j,router:()=>N,runGuards:()=>I});module.exports=X(nt);var u=require("@symbo.ls/utils");const M=/^:(.+)/,A=/^\*$/,F=new Map,j=e=>{const o=F.get(e);if(o)return o;const r=e.replace(/^\//,"").split("/"),s=[];let t=!1;for(let c=0;c<r.length;c++){const n=r[c].match(M);n?s.push({index:c,name:n[1]}):A.test(r[c])&&(t=!0)}const a={segments:r,params:s,hasWildcard:t,pattern:e};return F.set(e,a),a},B=(e,o,r=0)=>{const t=e.replace(/^\//,"").split("/").filter(Boolean).slice(r),a="/"+(t[0]||"");if(!o)return{key:null,content:null,params:{},routePath:a};let c=null,n=-1,p={};for(const h in o){if(h==="/*")continue;const x=j(h),R=Y(t,x);R>n&&(n=R,c=h,p=Z(t,x))}return!c&&o["/*"]&&(c="/*"),{key:c,content:c?o[c]:null,params:p,routePath:a}},Y=(e,o)=>{const{segments:r,hasWildcard:s}=o;if(!s&&r.length!==e.length&&r.length!==1&&r.length>e.length)return-1;let t=0;const a=Math.min(r.length,e.length);for(let c=0;c<a;c++)if(r[c]===e[c])t+=3;else if(M.test(r[c]))t+=1;else if(A.test(r[c]))t+=.5;else return-1;return t},Z=(e,o)=>{const r={};for(const{index:s,name:t}of o.params)e[s]&&(r[t]=decodeURIComponent(e[s]));return r},k=e=>{if(!e||e==="?")return{};const o={};return new URLSearchParams(e).forEach((s,t)=>{o[t]!==void 0?(Array.isArray(o[t])||(o[t]=[o[t]]),o[t].push(s)):o[t]=s}),o},I=async(e,o)=>{if(!e||!e.length)return!0;for(const r of e){const s=await r(o);if(s===!1)return!1;if(typeof s=="string")return s}return!0},W=(e=0,o)=>{o||(o=typeof u.window<"u"?u.window.location.pathname:"/");const s=o.split("/")[e+1];if(s)return`/${s}`};let S,_=0;const tt=e=>!e||e==="srcdoc"||e==="about:srcdoc"?"/":e,et={level:_,pushState:!0,replace:!1,initialRender:!1,scrollToTop:!0,scrollToNode:!1,get scrollNode(){return typeof u.document<"u"&&(u.document.scrollingElement||u.document.documentElement)||null},scrollBody:!1,useFragment:!1,updateState:!0,scrollToOffset:0,contentElementKey:"content",scrollToOptions:{behavior:"smooth"},useParamsMatching:!0},N=async(e,o,r={},s={})=>{const t=o||void 0,a=t?.context?.window||u.window,c=t?.context?.document||u.document,n={...et,...t.context.routerOptions,...s};_=n.lastLevel;const p=t.__ref;(n.contentElementKey!=="content"&&n.contentElementKey!==p.contentElementKey||!p.contentElementKey)&&(p.contentElementKey=n.contentElementKey||"content");const h=p.contentElementKey||n.contentElementKey||"content",x=a.location.origin!=="null"?a.location.origin:"http://localhost";e=tt(e);const R=typeof a.URL=="function"?a.URL:typeof globalThis<"u"&&globalThis.URL||URL,$=new R(x+e),{pathname:m,search:K,hash:T}=$,U=k(K),q=t.node,L=T&&T!==a.location.hash.slice(1),P=m!==S;S=m;const w=p.__routerRendered;let g,C,f,v;if(n.useParamsMatching){const l=B(m,t.routes,n.level);g=l.key,C=l.routePath,f=l.content,v=l.params}else{g=W(n.level,m),C=g;const l=t.routes;f=l?l[g||"/"]||l["/*"]:null,v={}}const b=n.scrollToNode?q:n.scrollNode;if(t.state?.root?.debugging){t.state.root.debugging=!1;return}if(!f){n.onNotFound?n.onNotFound({pathname:m,route:g,element:t}):n.silent||console.warn("[smbls/router] no content matched for path",m,"\u2014 available routes:",Object.keys(t.routes||{}));return}if(n.guards&&n.guards.length){const l={pathname:m,route:g,params:v,query:U,hash:T,element:t,state:r},d=await I(n.guards,l);if(d===!1)return;if(typeof d=="string")return N(d,o,r,{...s,guards:[]})}if(n.pushState){const l=m+(K||"")+(T||"");try{n.replace?a.history.replaceState(r,null,l):a.history.pushState(r,null,l)}catch{}}if(P||!L){const l={route:g,routePath:C,hash:T,params:v,query:U,debugging:!1};n.updateState&&t.state.update(l,{preventContentUpdate:!0})}const z=!w||w.content!==f||w.route!==g;if(P||z||n.force===!0){h&&n.removeOldElement&&t[h].remove();const l=t.__ref?.origin?.content,d={};if(l)for(const i in l){const y=l[i];i==="__ref"||i==="props"||i==="node"||i==="parent"||i==="key"||(typeof y=="string"||typeof y=="number"||typeof y=="boolean"||typeof y=="object"&&y!==null&&!y.node&&!y.__ref)&&(d[i]=y)}const E={...d,...typeof f=="object"?f:{extends:f}};n.useFragment&&(E.tag="fragment");try{if(typeof t?.set!="function")return;t.set(E,{contentElementKey:h}),p.__routerRendered={content:f,route:g}}catch(i){console.error("[smbls/router] failed to render route content",m,i)}}const G=!P&&L;if(n.scrollToTop&&!G&&b?.scrollTo&&b.scrollTo({...n.scrollToOptions||{},top:0,left:0}),n.scrollToNode&&f[h]?.node?.scrollTo&&f[h].node.scrollTo({...n.scrollToOptions||{},top:0,left:0}),T){const l=c.getElementById(T.slice(1));if(l&&b?.scrollTo){const d=a.getComputedStyle?a.getComputedStyle(l):null,E=d&&parseFloat(d.scrollMarginTop)||0,i=l.getBoundingClientRect().top+b.scrollTop-E-(n.scrollToOffset||0);b.scrollTo({...n.scrollToOptions||{},top:i,left:0})}}(0,u.triggerEventOn)("routeChanged",t,n)};var ot=N;
1
+ "use strict";var K=Object.defineProperty;var Z=Object.getOwnPropertyDescriptor;var tt=Object.getOwnPropertyNames;var et=Object.prototype.hasOwnProperty;var ot=(e,o)=>{for(var n in o)K(e,n,{get:o[n],enumerable:!0})},nt=(e,o,n,s)=>{if(o&&typeof o=="object"||typeof o=="function")for(let t of tt(o))!et.call(e,t)&&t!==n&&K(e,t,{get:()=>o[t],enumerable:!(s=Z(o,t))||s.enumerable});return e};var rt=e=>nt(K({},"__esModule",{value:!0}),e);var ft={};ot(ft,{default:()=>ut,getActiveRoute:()=>Q,lastLevel:()=>U,lastPathname:()=>M,matchRoute:()=>$,parseQuery:()=>z,parseRoutePattern:()=>I,router:()=>A,runGuards:()=>G});module.exports=rt(ft);var d=require("@symbo.ls/utils");const q=/^:(.+)/,W=/^\*$/,j=new Map,I=e=>{const o=j.get(e);if(o)return o;const n=e.replace(/^\//,"").split("/"),s=[];let t=!1;for(let c=0;c<n.length;c++){const r=n[c].match(q);r?s.push({index:c,name:r[1]}):W.test(n[c])&&(t=!0)}const a={segments:n,params:s,hasWildcard:t,pattern:e};return j.set(e,a),a},$=(e,o,n=0)=>{const t=e.replace(/^\//,"").split("/").filter(Boolean).slice(n),a="/"+(t[0]||"");if(!o)return{key:null,content:null,params:{},routePath:a};let c=null,r=-1,i={};for(const u in o){if(u==="/*")continue;const b=I(u),y=st(t,b);y>r&&(r=y,c=u,i=lt(t,b))}return!c&&o["/*"]&&(c="/*"),{key:c,content:c?o[c]:null,params:i,routePath:a}},st=(e,o)=>{const{segments:n,hasWildcard:s}=o;if(!s&&n.length!==e.length&&n.length!==1&&n.length>e.length)return-1;let t=0;const a=Math.min(n.length,e.length);for(let c=0;c<a;c++)if(n[c]===e[c])t+=3;else if(q.test(n[c]))t+=1;else if(W.test(n[c]))t+=.5;else return-1;return t},lt=(e,o)=>{const n={};for(const{index:s,name:t}of o.params)e[s]&&(n[t]=decodeURIComponent(e[s]));return n},z=e=>{if(!e||e==="?")return{};const o={};return new URLSearchParams(e).forEach((s,t)=>{o[t]!==void 0?(Array.isArray(o[t])||(o[t]=[o[t]]),o[t].push(s)):o[t]=s}),o},G=async(e,o)=>{if(!e||!e.length)return!0;for(const n of e){const s=await n(o);if(s===!1)return!1;if(typeof s=="string")return s}return!0},Q=(e=0,o)=>{o||(o=typeof d.window<"u"?d.window.location.pathname:"/");const s=o.split("/")[e+1];if(s)return`/${s}`};let M,U=0;const ct=e=>!e||e==="srcdoc"||e==="about:srcdoc"?"/":e,B={level:U,pushState:!0,replace:!1,initialRender:!1,scrollToTop:!0,scrollToNode:!1,get scrollNode(){return typeof d.document<"u"&&(d.document.scrollingElement||d.document.documentElement)||null},scrollBody:!1,useFragment:!1,updateState:!0,scrollToOffset:0,contentElementKey:"content",scrollToOptions:{behavior:"smooth"},useParamsMatching:!0,exitTimeout:300},it=(e,o)=>{if(!e||e.visibilityState!=="visible")return!1;try{const n=o&&typeof o.matchMedia=="function"&&o.matchMedia("(prefers-reduced-motion: reduce)");if(n&&n.matches)return!1}catch{}return!0},k=e=>{const o=e.__routeExit;o&&(e.__routeExit=null,o.abort())},at=(e,o,n,s,t,a,c)=>{const r=o.__routeExit;if(r&&r.page===n)return r.wait;if(k(o),!n||!n.node||typeof e.onRouteExit>"u"||!it(a,c))return null;const i=typeof t=="number"&&t>=0?t:B.exitTimeout,u=typeof AbortController=="function"?new AbortController:null,b={...s,page:n,timeout:i,signal:u?u.signal:void 0};let y;try{y=(0,d.triggerEventOn)("routeExit",e,b)}catch{return null}if(!y||typeof y.then!="function")return null;const T={page:n,abort:()=>{u&&!u.signal.aborted&&u.abort()}};return T.wait=new Promise(f=>{const w=setTimeout(()=>{T.abort(),f()},i),m=()=>{clearTimeout(w),f()};y.then(m,m)}),o.__routeExit=T,T.wait},A=async(e,o,n={},s={})=>{const t=o||void 0,a=t?.context?.window||d.window,c=t?.context?.document||d.document,r={...B,...t.context.routerOptions,...s};U=r.lastLevel;const i=t.__ref;(r.contentElementKey!=="content"&&r.contentElementKey!==i.contentElementKey||!i.contentElementKey)&&(i.contentElementKey=r.contentElementKey||"content");const u=i.contentElementKey||r.contentElementKey||"content",b=a.location.origin!=="null"?a.location.origin:"http://localhost";e=ct(e);const y=typeof a.URL=="function"?a.URL:typeof globalThis<"u"&&globalThis.URL||URL,T=new y(b+e),{pathname:f,search:w,hash:m}=T,C=z(w),D=t.node,L=m&&m!==a.location.hash.slice(1),N=f!==M;M=f;const R=i.__routerRendered;let p,O,h,E;if(r.useParamsMatching){const l=$(f,t.routes,r.level);p=l.key,O=l.routePath,h=l.content,E=l.params}else{p=Q(r.level,f),O=p;const l=t.routes;h=l?l[p||"/"]||l["/*"]:null,E={}}const v=r.scrollToNode?D:r.scrollNode;if(t.state?.root?.debugging){t.state.root.debugging=!1;return}if(!h){r.onNotFound?r.onNotFound({pathname:f,route:p,element:t}):r.silent||console.warn("[smbls/router] no content matched for path",f,"\u2014 available routes:",Object.keys(t.routes||{}));return}if(r.guards&&r.guards.length){const l={pathname:f,route:p,params:E,query:C,hash:m,element:t,state:n},g=await G(r.guards,l);if(g===!1)return;if(typeof g=="string")return A(g,o,n,{...s,guards:[]})}const H=i.__routerNavSeq=(i.__routerNavSeq||0)+1,J=!R||R.content!==h||R.route!==p,F=N||J||r.force===!0;if(F&&R){const l=at(t,i,t[u],{from:R.route,to:p,pathname:f,params:E,query:C,hash:m},r.exitTimeout,c,a);if(l&&(await l,i.__routerNavSeq!==H||(i.__routeExit&&i.__routeExit.wait===l&&(i.__routeExit=null),typeof t?.set!="function")))return}else k(i);if(r.pushState){const l=f+(w||"")+(m||"");try{r.replace?a.history.replaceState(n,null,l):a.history.pushState(n,null,l)}catch{}}const V=N||!L?{route:p,routePath:O,hash:m,params:E,query:C,debugging:!1}:{params:E,query:C};if(r.updateState&&t.state.update(V,{preventContentUpdate:!0}),F){u&&r.removeOldElement&&t[u].remove();const l=t.__ref?.origin?.content,g={};if(l)for(const _ in l){const x=l[_];_==="__ref"||_==="props"||_==="node"||_==="parent"||_==="key"||(typeof x=="string"||typeof x=="number"||typeof x=="boolean"||typeof x=="object"&&x!==null&&!x.node&&!x.__ref)&&(g[_]=x)}const P={...g,...typeof h=="object"?h:{extends:h}};r.useFragment&&(P.tag="fragment");const S={content:h,route:p},Y=i.__routerRendered;try{if(typeof t?.set!="function")return;i.__routerRendered=S,t.set(P,{contentElementKey:u})}catch(_){i.__routerRendered===S&&(i.__routerRendered=Y),console.error("[smbls/router] failed to render route content",f,_)}}const X=!N&&L;if(r.scrollToTop&&!X&&v?.scrollTo&&v.scrollTo({...r.scrollToOptions||{},top:0,left:0}),r.scrollToNode&&h[u]?.node?.scrollTo&&h[u].node.scrollTo({...r.scrollToOptions||{},top:0,left:0}),m){const l=c.getElementById(m.slice(1));if(l&&v?.scrollTo){const g=a.getComputedStyle?a.getComputedStyle(l):null,P=g&&parseFloat(g.scrollMarginTop)||0,S=l.getBoundingClientRect().top+v.scrollTop-P-(r.scrollToOffset||0);v.scrollTo({...r.scrollToOptions||{},top:S,left:0})}}(0,d.triggerEventOn)("routeChanged",t,r)};var ut=A;
package/dist/esm/index.js CHANGED
@@ -1 +1 @@
1
- import{document as E,window as O,triggerEventOn as W}from"@symbo.ls/utils";const K=/^:(.+)/,U=/^\*$/,L=new Map,$=o=>{const n=L.get(o);if(n)return n;const r=o.replace(/^\//,"").split("/"),l=[];let t=!1;for(let c=0;c<r.length;c++){const e=r[c].match(K);e?l.push({index:c,name:e[1]}):U.test(r[c])&&(t=!0)}const a={segments:r,params:l,hasWildcard:t,pattern:o};return L.set(o,a),a},q=(o,n,r=0)=>{const t=o.replace(/^\//,"").split("/").filter(Boolean).slice(r),a="/"+(t[0]||"");if(!n)return{key:null,content:null,params:{},routePath:a};let c=null,e=-1,d={};for(const p in n){if(p==="/*")continue;const b=$(p),x=z(t,b);x>e&&(e=x,c=p,d=G(t,b))}return!c&&n["/*"]&&(c="/*"),{key:c,content:c?n[c]:null,params:d,routePath:a}},z=(o,n)=>{const{segments:r,hasWildcard:l}=n;if(!l&&r.length!==o.length&&r.length!==1&&r.length>o.length)return-1;let t=0;const a=Math.min(r.length,o.length);for(let c=0;c<a;c++)if(r[c]===o[c])t+=3;else if(K.test(r[c]))t+=1;else if(U.test(r[c]))t+=.5;else return-1;return t},G=(o,n)=>{const r={};for(const{index:l,name:t}of n.params)o[l]&&(r[t]=decodeURIComponent(o[l]));return r},Q=o=>{if(!o||o==="?")return{};const n={};return new URLSearchParams(o).forEach((l,t)=>{n[t]!==void 0?(Array.isArray(n[t])||(n[t]=[n[t]]),n[t].push(l)):n[t]=l}),n},D=async(o,n)=>{if(!o||!o.length)return!0;for(const r of o){const l=await r(n);if(l===!1)return!1;if(typeof l=="string")return l}return!0},H=(o=0,n)=>{n||(n=typeof O<"u"?O.location.pathname:"/");const l=n.split("/")[o+1];if(l)return`/${l}`};let M,A=0;const J=o=>!o||o==="srcdoc"||o==="about:srcdoc"?"/":o,V={level:A,pushState:!0,replace:!1,initialRender:!1,scrollToTop:!0,scrollToNode:!1,get scrollNode(){return typeof E<"u"&&(E.scrollingElement||E.documentElement)||null},scrollBody:!1,useFragment:!1,updateState:!0,scrollToOffset:0,contentElementKey:"content",scrollToOptions:{behavior:"smooth"},useParamsMatching:!0},F=async(o,n,r={},l={})=>{const t=n||void 0,a=t?.context?.window||O,c=t?.context?.document||E,e={...V,...t.context.routerOptions,...l};A=e.lastLevel;const d=t.__ref;(e.contentElementKey!=="content"&&e.contentElementKey!==d.contentElementKey||!d.contentElementKey)&&(d.contentElementKey=e.contentElementKey||"content");const p=d.contentElementKey||e.contentElementKey||"content",b=a.location.origin!=="null"?a.location.origin:"http://localhost";o=J(o);const x=typeof a.URL=="function"?a.URL:typeof globalThis<"u"&&globalThis.URL||URL,j=new x(b+o),{pathname:h,search:S,hash:y}=j,_=Q(S),B=t.node,N=y&&y!==a.location.hash.slice(1),P=h!==M;M=h;const w=d.__routerRendered;let m,C,u,R;if(e.useParamsMatching){const s=q(h,t.routes,e.level);m=s.key,C=s.routePath,u=s.content,R=s.params}else{m=H(e.level,h),C=m;const s=t.routes;u=s?s[m||"/"]||s["/*"]:null,R={}}const T=e.scrollToNode?B:e.scrollNode;if(t.state?.root?.debugging){t.state.root.debugging=!1;return}if(!u){e.onNotFound?e.onNotFound({pathname:h,route:m,element:t}):e.silent||console.warn("[smbls/router] no content matched for path",h,"\u2014 available routes:",Object.keys(t.routes||{}));return}if(e.guards&&e.guards.length){const s={pathname:h,route:m,params:R,query:_,hash:y,element:t,state:r},f=await D(e.guards,s);if(f===!1)return;if(typeof f=="string")return F(f,n,r,{...l,guards:[]})}if(e.pushState){const s=h+(S||"")+(y||"");try{e.replace?a.history.replaceState(r,null,s):a.history.pushState(r,null,s)}catch{}}if(P||!N){const s={route:m,routePath:C,hash:y,params:R,query:_,debugging:!1};e.updateState&&t.state.update(s,{preventContentUpdate:!0})}const k=!w||w.content!==u||w.route!==m;if(P||k||e.force===!0){p&&e.removeOldElement&&t[p].remove();const s=t.__ref?.origin?.content,f={};if(s)for(const i in s){const g=s[i];i==="__ref"||i==="props"||i==="node"||i==="parent"||i==="key"||(typeof g=="string"||typeof g=="number"||typeof g=="boolean"||typeof g=="object"&&g!==null&&!g.node&&!g.__ref)&&(f[i]=g)}const v={...f,...typeof u=="object"?u:{extends:u}};e.useFragment&&(v.tag="fragment");try{if(typeof t?.set!="function")return;t.set(v,{contentElementKey:p}),d.__routerRendered={content:u,route:m}}catch(i){console.error("[smbls/router] failed to render route content",h,i)}}const I=!P&&N;if(e.scrollToTop&&!I&&T?.scrollTo&&T.scrollTo({...e.scrollToOptions||{},top:0,left:0}),e.scrollToNode&&u[p]?.node?.scrollTo&&u[p].node.scrollTo({...e.scrollToOptions||{},top:0,left:0}),y){const s=c.getElementById(y.slice(1));if(s&&T?.scrollTo){const f=a.getComputedStyle?a.getComputedStyle(s):null,v=f&&parseFloat(f.scrollMarginTop)||0,i=s.getBoundingClientRect().top+T.scrollTop-v-(e.scrollToOffset||0);T.scrollTo({...e.scrollToOptions||{},top:i,left:0})}}W("routeChanged",t,e)};var Z=F;export{Z as default,H as getActiveRoute,A as lastLevel,M as lastPathname,q as matchRoute,Q as parseQuery,$ as parseRoutePattern,F as router,D as runGuards};
1
+ import{document as S,window as K,triggerEventOn as A}from"@symbo.ls/utils";const L=/^:(.+)/,F=/^\*$/,q=new Map,J=e=>{const n=q.get(e);if(n)return n;const r=e.replace(/^\//,"").split("/"),c=[];let t=!1;for(let l=0;l<r.length;l++){const o=r[l].match(L);o?c.push({index:l,name:o[1]}):F.test(r[l])&&(t=!0)}const a={segments:r,params:c,hasWildcard:t,pattern:e};return q.set(e,a),a},V=(e,n,r=0)=>{const t=e.replace(/^\//,"").split("/").filter(Boolean).slice(r),a="/"+(t[0]||"");if(!n)return{key:null,content:null,params:{},routePath:a};let l=null,o=-1,i={};for(const u in n){if(u==="/*")continue;const x=J(u),g=X(t,x);g>o&&(o=g,l=u,i=Y(t,x))}return!l&&n["/*"]&&(l="/*"),{key:l,content:l?n[l]:null,params:i,routePath:a}},X=(e,n)=>{const{segments:r,hasWildcard:c}=n;if(!c&&r.length!==e.length&&r.length!==1&&r.length>e.length)return-1;let t=0;const a=Math.min(r.length,e.length);for(let l=0;l<a;l++)if(r[l]===e[l])t+=3;else if(L.test(r[l]))t+=1;else if(F.test(r[l]))t+=.5;else return-1;return t},Y=(e,n)=>{const r={};for(const{index:c,name:t}of n.params)e[c]&&(r[t]=decodeURIComponent(e[c]));return r},Z=e=>{if(!e||e==="?")return{};const n={};return new URLSearchParams(e).forEach((c,t)=>{n[t]!==void 0?(Array.isArray(n[t])||(n[t]=[n[t]]),n[t].push(c)):n[t]=c}),n},tt=async(e,n)=>{if(!e||!e.length)return!0;for(const r of e){const c=await r(n);if(c===!1)return!1;if(typeof c=="string")return c}return!0},et=(e=0,n)=>{n||(n=typeof K<"u"?K.location.pathname:"/");const c=n.split("/")[e+1];if(c)return`/${c}`};let W,j=0;const ot=e=>!e||e==="srcdoc"||e==="about:srcdoc"?"/":e,B={level:j,pushState:!0,replace:!1,initialRender:!1,scrollToTop:!0,scrollToNode:!1,get scrollNode(){return typeof S<"u"&&(S.scrollingElement||S.documentElement)||null},scrollBody:!1,useFragment:!1,updateState:!0,scrollToOffset:0,contentElementKey:"content",scrollToOptions:{behavior:"smooth"},useParamsMatching:!0,exitTimeout:300},nt=(e,n)=>{if(!e||e.visibilityState!=="visible")return!1;try{const r=n&&typeof n.matchMedia=="function"&&n.matchMedia("(prefers-reduced-motion: reduce)");if(r&&r.matches)return!1}catch{}return!0},k=e=>{const n=e.__routeExit;n&&(e.__routeExit=null,n.abort())},rt=(e,n,r,c,t,a,l)=>{const o=n.__routeExit;if(o&&o.page===r)return o.wait;if(k(n),!r||!r.node||typeof e.onRouteExit>"u"||!nt(a,l))return null;const i=typeof t=="number"&&t>=0?t:B.exitTimeout,u=typeof AbortController=="function"?new AbortController:null,x={...c,page:r,timeout:i,signal:u?u.signal:void 0};let g;try{g=A("routeExit",e,x)}catch{return null}if(!g||typeof g.then!="function")return null;const b={page:r,abort:()=>{u&&!u.signal.aborted&&u.abort()}};return b.wait=new Promise(f=>{const v=setTimeout(()=>{b.abort(),f()},i),d=()=>{clearTimeout(v),f()};g.then(d,d)}),n.__routeExit=b,b.wait},I=async(e,n,r={},c={})=>{const t=n||void 0,a=t?.context?.window||K,l=t?.context?.document||S,o={...B,...t.context.routerOptions,...c};j=o.lastLevel;const i=t.__ref;(o.contentElementKey!=="content"&&o.contentElementKey!==i.contentElementKey||!i.contentElementKey)&&(i.contentElementKey=o.contentElementKey||"content");const u=i.contentElementKey||o.contentElementKey||"content",x=a.location.origin!=="null"?a.location.origin:"http://localhost";e=ot(e);const g=typeof a.URL=="function"?a.URL:typeof globalThis<"u"&&globalThis.URL||URL,b=new g(x+e),{pathname:f,search:v,hash:d}=b,w=Z(v),$=t.node,M=d&&d!==a.location.hash.slice(1),N=f!==W;W=f;const E=i.__routerRendered;let m,O,p,T;if(o.useParamsMatching){const s=V(f,t.routes,o.level);m=s.key,O=s.routePath,p=s.content,T=s.params}else{m=et(o.level,f),O=m;const s=t.routes;p=s?s[m||"/"]||s["/*"]:null,T={}}const R=o.scrollToNode?$:o.scrollNode;if(t.state?.root?.debugging){t.state.root.debugging=!1;return}if(!p){o.onNotFound?o.onNotFound({pathname:f,route:m,element:t}):o.silent||console.warn("[smbls/router] no content matched for path",f,"\u2014 available routes:",Object.keys(t.routes||{}));return}if(o.guards&&o.guards.length){const s={pathname:f,route:m,params:T,query:w,hash:d,element:t,state:r},h=await tt(o.guards,s);if(h===!1)return;if(typeof h=="string")return I(h,n,r,{...c,guards:[]})}const z=i.__routerNavSeq=(i.__routerNavSeq||0)+1,G=!E||E.content!==p||E.route!==m,U=N||G||o.force===!0;if(U&&E){const s=rt(t,i,t[u],{from:E.route,to:m,pathname:f,params:T,query:w,hash:d},o.exitTimeout,l,a);if(s&&(await s,i.__routerNavSeq!==z||(i.__routeExit&&i.__routeExit.wait===s&&(i.__routeExit=null),typeof t?.set!="function")))return}else k(i);if(o.pushState){const s=f+(v||"")+(d||"");try{o.replace?a.history.replaceState(r,null,s):a.history.pushState(r,null,s)}catch{}}const Q=N||!M?{route:m,routePath:O,hash:d,params:T,query:w,debugging:!1}:{params:T,query:w};if(o.updateState&&t.state.update(Q,{preventContentUpdate:!0}),U){u&&o.removeOldElement&&t[u].remove();const s=t.__ref?.origin?.content,h={};if(s)for(const y in s){const _=s[y];y==="__ref"||y==="props"||y==="node"||y==="parent"||y==="key"||(typeof _=="string"||typeof _=="number"||typeof _=="boolean"||typeof _=="object"&&_!==null&&!_.node&&!_.__ref)&&(h[y]=_)}const C={...h,...typeof p=="object"?p:{extends:p}};o.useFragment&&(C.tag="fragment");const P={content:p,route:m},H=i.__routerRendered;try{if(typeof t?.set!="function")return;i.__routerRendered=P,t.set(C,{contentElementKey:u})}catch(y){i.__routerRendered===P&&(i.__routerRendered=H),console.error("[smbls/router] failed to render route content",f,y)}}const D=!N&&M;if(o.scrollToTop&&!D&&R?.scrollTo&&R.scrollTo({...o.scrollToOptions||{},top:0,left:0}),o.scrollToNode&&p[u]?.node?.scrollTo&&p[u].node.scrollTo({...o.scrollToOptions||{},top:0,left:0}),d){const s=l.getElementById(d.slice(1));if(s&&R?.scrollTo){const h=a.getComputedStyle?a.getComputedStyle(s):null,C=h&&parseFloat(h.scrollMarginTop)||0,P=s.getBoundingClientRect().top+R.scrollTop-C-(o.scrollToOffset||0);R.scrollTo({...o.scrollToOptions||{},top:P,left:0})}}A("routeChanged",t,o)};var ct=I;export{ct as default,et as getActiveRoute,j as lastLevel,W as lastPathname,V as matchRoute,Z as parseQuery,J as parseRoutePattern,I as router,tt as runGuards};
package/index.js CHANGED
@@ -181,7 +181,76 @@ const defaultOptions = {
181
181
  scrollToOffset: 0,
182
182
  contentElementKey: 'content',
183
183
  scrollToOptions: { behavior: 'smooth' },
184
- useParamsMatching: true
184
+ useParamsMatching: true,
185
+ // How long the router waits for an `onRouteExit` handler before it swaps
186
+ // the page anyway, in ms (FW-ROUTER-PRE-SWAP-HOOK-FOR-EXIT-MOTION-1).
187
+ exitTimeout: 300
188
+ }
189
+
190
+ // --- Exit motion (FW-ROUTER-PRE-SWAP-HOOK-FOR-EXIT-MOTION-1) ---
191
+ //
192
+ // `onRouteExit(el, state, context, exit)` on the routed element (the one that
193
+ // receives `onRouteChanged`) runs BEFORE the router swaps the page, with the
194
+ // page that is leaving: `exit = { page, from, to, pathname, params, query,
195
+ // hash, signal, timeout }`. When it returns a promise, the router waits for it
196
+ // — at most `exitTimeout` ms (default 300) — and only then writes history and
197
+ // the route state and mounts the new page. A rejection is a finished exit; on
198
+ // the timeout `exit.signal` aborts and the swap goes ahead, so a slow or stuck
199
+ // exit never blocks navigation. There is no exit (and no wait) for the first
200
+ // route of an element (nothing leaves), for a navigation that keeps the page
201
+ // (query-only, same-path hash), under `prefers-reduced-motion: reduce`, in a
202
+ // tab that is not visible, and without a handler — then the swap stays in the
203
+ // same synchronous run as before this hook existed.
204
+ //
205
+ // A navigation that starts while an exit runs takes over: the older one never
206
+ // mounts its page (no double mount, no stale page) and writes no history
207
+ // entry. When the newer one leaves the SAME page it joins the running exit
208
+ // instead of starting a second one; any other newer navigation aborts it.
209
+ const motionAllowed = (doc, win) => {
210
+ if (!doc || doc.visibilityState !== 'visible') return false
211
+ try {
212
+ const reduce = win && typeof win.matchMedia === 'function' &&
213
+ win.matchMedia('(prefers-reduced-motion: reduce)')
214
+ if (reduce && reduce.matches) return false
215
+ } catch (e) {} // a realm without a usable matchMedia states no preference
216
+ return true
217
+ }
218
+
219
+ const endRouteExit = (ref) => {
220
+ const running = ref.__routeExit
221
+ if (!running) return
222
+ ref.__routeExit = null
223
+ running.abort()
224
+ }
225
+
226
+ // The wait for this navigation's exit, or null when there is nothing to wait
227
+ // for. Runs the handler at most once per leaving page.
228
+ const beginRouteExit = (element, ref, page, exitInfo, timeoutOption, doc, win) => {
229
+ const running = ref.__routeExit
230
+ if (running && running.page === page) return running.wait
231
+ endRouteExit(ref)
232
+ if (!page || !page.node || typeof element.onRouteExit === 'undefined') return null
233
+ if (!motionAllowed(doc, win)) return null
234
+ const timeout = typeof timeoutOption === 'number' && timeoutOption >= 0
235
+ ? timeoutOption
236
+ : defaultOptions.exitTimeout
237
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
238
+ const exit = { ...exitInfo, page, timeout, signal: controller ? controller.signal : undefined }
239
+ let result
240
+ try {
241
+ result = triggerEventOn('routeExit', element, exit)
242
+ } catch (e) {
243
+ return null // a handler that throws (strictMode) is a finished exit
244
+ }
245
+ if (!result || typeof result.then !== 'function') return null
246
+ const record = { page, abort: () => { if (controller && !controller.signal.aborted) controller.abort() } }
247
+ record.wait = new Promise((resolve) => {
248
+ const timer = setTimeout(() => { record.abort(); resolve() }, timeout)
249
+ const done = () => { clearTimeout(timer); resolve() }
250
+ result.then(done, done)
251
+ })
252
+ ref.__routeExit = record
253
+ return record.wait
185
254
  }
186
255
 
187
256
  export const router = async (path, el, state = {}, options = {}) => {
@@ -287,6 +356,41 @@ export const router = async (path, el, state = {}, options = {}) => {
287
356
  }
288
357
  }
289
358
 
359
+ // This navigation is committed (it passed the guards). Any navigation still
360
+ // waiting for an exit is superseded from here — see beginRouteExit.
361
+ const navSeq = (ref.__routerNavSeq = (ref.__routerNavSeq || 0) + 1)
362
+
363
+ // APP-PAGE-COLD-LOAD-NOT-FOUND-1 + ROUTER-QUERY-SWAP-1 — does the resolved
364
+ // content differ from what this element renders? (The full reasoning sits
365
+ // at the swap below.) Decided here, before the history write, because the
366
+ // exit of the leaving page runs first.
367
+ const contentChanged =
368
+ !lastRendered || lastRendered.content !== content || lastRendered.route !== route
369
+ const shouldSwap = pathChanged || contentChanged || opts.force === true
370
+
371
+ // FW-ROUTER-PRE-SWAP-HOOK-FOR-EXIT-MOTION-1 — the leaving page exits before
372
+ // anything else of this navigation happens (URL, state, swap), so a
373
+ // superseded navigation leaves no trace. Awaited ONLY when an exit runs.
374
+ if (shouldSwap && lastRendered) {
375
+ const exitWait = beginRouteExit(
376
+ element,
377
+ ref,
378
+ element[contentElementKey],
379
+ { from: lastRendered.route, to: route, pathname, params, query, hash },
380
+ opts.exitTimeout,
381
+ doc,
382
+ win
383
+ )
384
+ if (exitWait) {
385
+ await exitWait
386
+ if (ref.__routerNavSeq !== navSeq) return // a newer navigation took over
387
+ if (ref.__routeExit && ref.__routeExit.wait === exitWait) ref.__routeExit = null
388
+ if (typeof element?.set !== 'function') return // disposed while it exited
389
+ }
390
+ } else {
391
+ endRouteExit(ref)
392
+ }
393
+
290
394
  // The history write always fires before render so the URL bar stays in
291
395
  // sync even if the content-render step below no-ops due to an extends/ref
292
396
  // mismatch. `replace` rewrites the current entry instead of pushing one.
@@ -298,21 +402,30 @@ export const router = async (path, el, state = {}, options = {}) => {
298
402
  } catch (e) {} // expected in sandboxed iframes (e.g. about:srcdoc) where pushState is restricted
299
403
  }
300
404
 
301
- if (pathChanged || !hashChanged) {
302
- // ROUTER-STALE-PARAMS-1: always write params and query, empty or not.
303
- // Guarding them on `Object.keys(x).length` left the PREVIOUS route's
304
- // values in state whenever the new route had none — e.g. /cocktails/:id
305
- // → an explicit /bars/<id> key kept params.id = the cocktail, so a page
306
- // reading params resolved the wrong item (404); /cart?order=… → /cart
307
- // kept query.order. Nested state updates replace, so `{}` clears them.
308
- const stateUpdate = { route, routePath, hash, params, query, debugging: false }
309
-
310
- if (opts.updateState) {
311
- element.state.update(
312
- stateUpdate,
313
- { preventContentUpdate: true }
314
- )
315
- }
405
+ // ROUTER-STALE-PARAMS-1: always write params and query, empty or not.
406
+ // Guarding them on `Object.keys(x).length` left the PREVIOUS route's
407
+ // values in state whenever the new route had none — e.g. /cocktails/:id
408
+ // → an explicit /bars/<id> key kept params.id = the cocktail, so a page
409
+ // reading params resolved the wrong item (404); /cart?order=… → /cart
410
+ // kept query.order. Nested state updates replace, so `{}` clears them.
411
+ //
412
+ // FW-ROUTER-KEEPS-STALE-QUERY-AND-PARAMS-ON-BARE-URL-1: "always" has to
413
+ // hold for the timing gate too. `params` and `query` describe the URL just
414
+ // pushed, so they are written on EVERY navigation; the gate only decides
415
+ // whether the ROUTE fields (route, routePath, hash) ride along. A same-path
416
+ // navigation that carries a hash never passes it, so `/shop?gender=men` →
417
+ // `/shop#results` used to skip the whole write and left query.gender in
418
+ // state under a URL that no longer has it.
419
+ const shouldWriteRouteFields = pathChanged || !hashChanged
420
+ const stateUpdate = shouldWriteRouteFields
421
+ ? { route, routePath, hash, params, query, debugging: false }
422
+ : { params, query }
423
+
424
+ if (opts.updateState) {
425
+ element.state.update(
426
+ stateUpdate,
427
+ { preventContentUpdate: true }
428
+ )
316
429
  }
317
430
 
318
431
  // ROUTER-QUERY-SWAP-1 (fable.md): `content` is matched purely from
@@ -345,9 +458,7 @@ export const router = async (path, el, state = {}, options = {}) => {
345
458
  // intact, because a query-only nav re-matches the SAME route entry and so
346
459
  // yields the identical `content` reference. `opts.force` stays available
347
460
  // for a caller that knows the content object was mutated in place.
348
- const contentChanged =
349
- !lastRendered || lastRendered.content !== content || lastRendered.route !== route
350
- const shouldSwap = pathChanged || contentChanged || opts.force === true
461
+ // (`contentChanged` / `shouldSwap` are computed above, before the exit.)
351
462
  if (shouldSwap) {
352
463
  if (contentElementKey && opts.removeOldElement) {
353
464
  element[contentElementKey].remove()
@@ -373,6 +484,8 @@ export const router = async (path, el, state = {}, options = {}) => {
373
484
  }
374
485
  if (opts.useFragment) nextContent.tag = 'fragment'
375
486
 
487
+ const claim = { content, route }
488
+ const previous = ref.__routerRendered
376
489
  try {
377
490
  // Defensive: when the popstate handler fires AFTER an app's element
378
491
  // has been disposed (test-isolation, multi-app teardown, hot-reload)
@@ -382,13 +495,27 @@ export const router = async (path, el, state = {}, options = {}) => {
382
495
  // earlier. Skipping silently here matches the rest of the router's
383
496
  // "bail without exploding" contract for dead elements.
384
497
  if (typeof element?.set !== 'function') return
498
+ // FW-ROUTER-PAGE-MOUNTED-TWICE-ON-DIRECT-LOAD-WITH-QUERY-1: claim the
499
+ // rendered identity BEFORE the mount, not after it. `set()` runs the
500
+ // page's whole mount (onInit, onCreate, onRender, child effects) before
501
+ // it returns, and any of that code may call the router again — a
502
+ // catalog page rewriting the URL it was loaded with (`/shop?gender=men`)
503
+ // is the usual one. That nested pass reads `ref.__routerRendered`;
504
+ // recorded after the mount it read nothing, judged the SAME content
505
+ // "changed" and queued a second `set()`, so the page was built twice.
506
+ // Claimed first, the nested pass finds the identity it would render and
507
+ // no-ops. A nested pass for DIFFERENT content still swaps, and because
508
+ // it claims after this one the record ends on the LAST navigation (it
509
+ // used to end on this pass's stale content after a redirect).
510
+ ref.__routerRendered = claim
385
511
  element.set(nextContent, { contentElementKey })
386
- // Recorded only after the swap actually happened, so a dead element
387
- // (the early return above) or a throw below never marks content as
388
- // rendered that never reached the DOM — that would re-arm exactly the
389
- // stuck state this gate exists to clear.
390
- ref.__routerRendered = { content, route }
391
512
  } catch (err) {
513
+ // A mount that threw is not rendered content — that would re-arm
514
+ // exactly the stuck state this gate exists to clear
515
+ // (APP-PAGE-COLD-LOAD-NOT-FOUND-1). Hand the previous identity back,
516
+ // but only while this pass's own claim is still the record: a nested
517
+ // pass that claimed since owns it, and its swap ran in set()'s queue.
518
+ if (ref.__routerRendered === claim) ref.__routerRendered = previous
392
519
  console.error('[smbls/router] failed to render route content', pathname, err)
393
520
  }
394
521
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/router",
3
- "version": "3.14.602",
3
+ "version": "3.14.603",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",