@symbo.ls/router 3.14.601 → 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
@@ -18,6 +18,9 @@ router('/about', element)
18
18
 
19
19
  // With state and options
20
20
  router('/dashboard', element, { userId: 1 }, { scrollToTop: true })
21
+
22
+ // Replace the current history entry instead of pushing a new one
23
+ router('/login', element, {}, { replace: true })
21
24
  ```
22
25
 
23
26
  Define routes on your DOMQL element:
@@ -153,14 +156,37 @@ export const team = {
153
156
  }
154
157
  ```
155
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
+
156
180
  ## Options
157
181
 
158
182
  | Option | Type | Default | Description |
159
183
  |---|---|---|---|
160
184
  | `level` | `number` | `0` | Route nesting level (which path segment to match) |
161
- | `pushState` | `boolean` | `true` | Push to browser history |
185
+ | `pushState` | `boolean` | `true` | Write the navigation to browser history (`false` writes nothing) |
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` |
162
187
  | `initialRender` | `boolean` | `false` | Whether this is the initial page render |
163
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) |
164
190
  | `scrollToNode` | `boolean` | `false` | Scroll within the element node |
165
191
  | `scrollNode` | `Element` | `document.documentElement` | Node to scroll |
166
192
  | `scrollToOffset` | `number` | `0` | Offset when scrolling to hash anchors |
@@ -172,6 +198,7 @@ export const team = {
172
198
  | `useParamsMatching` | `boolean` | `false` | Enable dynamic `:param` route matching |
173
199
  | `guards` | `function[]` | `undefined` | Array of guard/middleware functions |
174
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) |
175
202
 
176
203
  ## Exported Utilities
177
204
 
@@ -223,12 +250,38 @@ The router triggers an `onRouteChanged` event on the element after navigation co
223
250
  ```js
224
251
  const App = {
225
252
  routes: { ... },
226
- onRouteChanged: (element, options) => {
253
+ onRouteChanged: (element, state, context, options) => {
227
254
  console.log('Route changed:', element.state.route)
228
255
  }
229
256
  }
230
257
  ```
231
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
+
232
285
  ## License
233
286
 
234
287
  MIT
package/dist/cjs/index.js CHANGED
@@ -1 +1 @@
1
- "use strict";var O=Object.defineProperty;var G=Object.getOwnPropertyDescriptor;var Q=Object.getOwnPropertyNames;var D=Object.prototype.hasOwnProperty;var H=(e,o)=>{for(var r in o)O(e,r,{get:o[r],enumerable:!0})},J=(e,o,r,s)=>{if(o&&typeof o=="object"||typeof o=="function")for(let t of Q(o))!D.call(e,t)&&t!==r&&O(e,t,{get:()=>o[t],enumerable:!(s=G(o,t))||s.enumerable});return e};var V=e=>J(O({},"__esModule",{value:!0}),e);var ot={};H(ot,{default:()=>et,getActiveRoute:()=>I,lastLevel:()=>_,lastPathname:()=>C,matchRoute:()=>B,parseQuery:()=>F,parseRoutePattern:()=>k,router:()=>K,runGuards:()=>q});module.exports=V(ot);var i=require("@symbo.ls/utils");const j=/^:(.+)/,M=/^\*$/,A=new Map,k=e=>{const o=A.get(e);if(o)return o;const r=e.replace(/^\//,"").split("/"),s=[];let t=!1;for(let l=0;l<r.length;l++){const n=r[l].match(j);n?s.push({index:l,name:n[1]}):M.test(r[l])&&(t=!0)}const a={segments:r,params:s,hasWildcard:t,pattern:e};return A.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 l=null,n=-1,f={};for(const d in o){if(d==="/*")continue;const x=k(d),R=X(t,x);R>n&&(n=R,l=d,f=Y(t,x))}return!l&&o["/*"]&&(l="/*"),{key:l,content:l?o[l]:null,params:f,routePath:a}},X=(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 l=0;l<a;l++)if(r[l]===e[l])t+=3;else if(j.test(r[l]))t+=1;else if(M.test(r[l]))t+=.5;else return-1;return t},Y=(e,o)=>{const r={};for(const{index:s,name:t}of o.params)e[s]&&(r[t]=decodeURIComponent(e[s]));return r},F=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},q=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},I=(e=0,o)=>{o||(o=typeof i.window<"u"?i.window.location.pathname:"/");const s=o.split("/")[e+1];if(s)return`/${s}`};let C,_=0;const Z=e=>!e||e==="srcdoc"||e==="about:srcdoc"?"/":e,tt={level:_,pushState:!0,initialRender:!1,scrollToTop:!0,scrollToNode:!1,get scrollNode(){return typeof i.document<"u"&&(i.document.scrollingElement||i.document.documentElement)||null},scrollBody:!1,useFragment:!1,updateState:!0,scrollToOffset:0,contentElementKey:"content",scrollToOptions:{behavior:"smooth"},useParamsMatching:!0},K=async(e,o,r={},s={})=>{const t=o||void 0,a=t?.context?.window||i.window,l=t?.context?.document||i.document,n={...tt,...t.context.routerOptions,...s};_=n.lastLevel;const f=t.__ref;(n.contentElementKey!=="content"&&n.contentElementKey!==f.contentElementKey||!f.contentElementKey)&&(f.contentElementKey=n.contentElementKey||"content");const d=f.contentElementKey||n.contentElementKey||"content",x=a.location.origin!=="null"?a.location.origin:"http://localhost";e=Z(e);const R=typeof a.URL=="function"?a.URL:typeof globalThis<"u"&&globalThis.URL||URL,W=new R(x+e),{pathname:p,search:N,hash:T}=W,v=F(N),S=t.node,$=T&&T!==a.location.hash.slice(1),U=p!==C;C=p;const P=f.__routerRendered;let h,w,u,b;if(n.useParamsMatching){const c=B(p,t.routes,n.level);h=c.key,w=c.routePath,u=c.content,b=c.params}else{h=I(n.level,p),w=h;const c=t.routes;u=c?c[h||"/"]||c["/*"]:null,b={}}const E=n.scrollToNode?S:n.scrollNode;if(t.state?.root?.debugging){t.state.root.debugging=!1;return}if(!u){n.onNotFound?n.onNotFound({pathname:p,route:h,element:t}):n.silent||console.warn("[smbls/router] no content matched for path",p,"\u2014 available routes:",Object.keys(t.routes||{}));return}if(n.guards&&n.guards.length){const c={pathname:p,route:h,params:b,query:v,hash:T,element:t,state:r},g=await q(n.guards,c);if(g===!1)return;if(typeof g=="string")return K(g,o,r,{...s,guards:[]})}if(n.pushState)try{a.history.pushState(r,null,p+(N||"")+(T||""))}catch{}if(U||!$){const c={route:h,routePath:w,hash:T,debugging:!1};Object.keys(b).length&&(c.params=b),Object.keys(v).length&&(c.query=v),n.updateState&&t.state.update(c,{preventContentUpdate:!0})}const z=!P||P.content!==u||P.route!==h;if(U||z||n.force===!0){d&&n.removeOldElement&&t[d].remove();const c=t.__ref?.origin?.content,g={};if(c)for(const m in c){const y=c[m];m==="__ref"||m==="props"||m==="node"||m==="parent"||m==="key"||(typeof y=="string"||typeof y=="number"||typeof y=="boolean"||typeof y=="object"&&y!==null&&!y.node&&!y.__ref)&&(g[m]=y)}const L={...g,...typeof u=="object"?u:{extends:u}};n.useFragment&&(L.tag="fragment");try{if(typeof t?.set!="function")return;t.set(L,{contentElementKey:d}),f.__routerRendered={content:u,route:h}}catch(m){console.error("[smbls/router] failed to render route content",p,m)}}if(n.scrollToTop&&E?.scrollTo&&E.scrollTo({...n.scrollToOptions||{},top:0,left:0}),n.scrollToNode&&u[d]?.node?.scrollTo&&u[d].node.scrollTo({...n.scrollToOptions||{},top:0,left:0}),T){const c=l.getElementById(T);if(c&&E?.scrollTo){const g=c.getBoundingClientRect().top+S.scrollTop-(n.scrollToOffset||0);E.scrollTo({...n.scrollToOptions||{},top:g,left:0})}}(0,i.triggerEventOn)("routeChanged",t,n)};var et=K;
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 q}from"@symbo.ls/utils";const S=/^:(.+)/,U=/^\*$/,L=new Map,I=o=>{const n=L.get(o);if(n)return n;const r=o.replace(/^\//,"").split("/"),c=[];let t=!1;for(let l=0;l<r.length;l++){const e=r[l].match(S);e?c.push({index:l,name:e[1]}):U.test(r[l])&&(t=!0)}const a={segments:r,params:c,hasWildcard:t,pattern:o};return L.set(o,a),a},W=(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 l=null,e=-1,u={};for(const f in n){if(f==="/*")continue;const b=I(f),x=$(t,b);x>e&&(e=x,l=f,u=z(t,b))}return!l&&n["/*"]&&(l="/*"),{key:l,content:l?n[l]:null,params:u,routePath:a}},$=(o,n)=>{const{segments:r,hasWildcard:c}=n;if(!c&&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 l=0;l<a;l++)if(r[l]===o[l])t+=3;else if(S.test(r[l]))t+=1;else if(U.test(r[l]))t+=.5;else return-1;return t},z=(o,n)=>{const r={};for(const{index:c,name:t}of n.params)o[c]&&(r[t]=decodeURIComponent(o[c]));return r},G=o=>{if(!o||o==="?")return{};const n={};return new URLSearchParams(o).forEach((c,t)=>{n[t]!==void 0?(Array.isArray(n[t])||(n[t]=[n[t]]),n[t].push(c)):n[t]=c}),n},Q=async(o,n)=>{if(!o||!o.length)return!0;for(const r of o){const c=await r(n);if(c===!1)return!1;if(typeof c=="string")return c}return!0},D=(o=0,n)=>{n||(n=typeof O<"u"?O.location.pathname:"/");const c=n.split("/")[o+1];if(c)return`/${c}`};let j,M=0;const H=o=>!o||o==="srcdoc"||o==="about:srcdoc"?"/":o,J={level:M,pushState:!0,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},A=async(o,n,r={},c={})=>{const t=n||void 0,a=t?.context?.window||O,l=t?.context?.document||E,e={...J,...t.context.routerOptions,...c};M=e.lastLevel;const u=t.__ref;(e.contentElementKey!=="content"&&e.contentElementKey!==u.contentElementKey||!u.contentElementKey)&&(u.contentElementKey=e.contentElementKey||"content");const f=u.contentElementKey||e.contentElementKey||"content",b=a.location.origin!=="null"?a.location.origin:"http://localhost";o=H(o);const x=typeof a.URL=="function"?a.URL:typeof globalThis<"u"&&globalThis.URL||URL,k=new x(b+o),{pathname:d,search:C,hash:y}=k,v=G(C),_=t.node,B=y&&y!==a.location.hash.slice(1),K=d!==j;j=d;const P=u.__routerRendered;let p,w,i,T;if(e.useParamsMatching){const s=W(d,t.routes,e.level);p=s.key,w=s.routePath,i=s.content,T=s.params}else{p=D(e.level,d),w=p;const s=t.routes;i=s?s[p||"/"]||s["/*"]:null,T={}}const R=e.scrollToNode?_:e.scrollNode;if(t.state?.root?.debugging){t.state.root.debugging=!1;return}if(!i){e.onNotFound?e.onNotFound({pathname:d,route:p,element:t}):e.silent||console.warn("[smbls/router] no content matched for path",d,"\u2014 available routes:",Object.keys(t.routes||{}));return}if(e.guards&&e.guards.length){const s={pathname:d,route:p,params:T,query:v,hash:y,element:t,state:r},m=await Q(e.guards,s);if(m===!1)return;if(typeof m=="string")return A(m,n,r,{...c,guards:[]})}if(e.pushState)try{a.history.pushState(r,null,d+(C||"")+(y||""))}catch{}if(K||!B){const s={route:p,routePath:w,hash:y,debugging:!1};Object.keys(T).length&&(s.params=T),Object.keys(v).length&&(s.query=v),e.updateState&&t.state.update(s,{preventContentUpdate:!0})}const F=!P||P.content!==i||P.route!==p;if(K||F||e.force===!0){f&&e.removeOldElement&&t[f].remove();const s=t.__ref?.origin?.content,m={};if(s)for(const h in s){const g=s[h];h==="__ref"||h==="props"||h==="node"||h==="parent"||h==="key"||(typeof g=="string"||typeof g=="number"||typeof g=="boolean"||typeof g=="object"&&g!==null&&!g.node&&!g.__ref)&&(m[h]=g)}const N={...m,...typeof i=="object"?i:{extends:i}};e.useFragment&&(N.tag="fragment");try{if(typeof t?.set!="function")return;t.set(N,{contentElementKey:f}),u.__routerRendered={content:i,route:p}}catch(h){console.error("[smbls/router] failed to render route content",d,h)}}if(e.scrollToTop&&R?.scrollTo&&R.scrollTo({...e.scrollToOptions||{},top:0,left:0}),e.scrollToNode&&i[f]?.node?.scrollTo&&i[f].node.scrollTo({...e.scrollToOptions||{},top:0,left:0}),y){const s=l.getElementById(y);if(s&&R?.scrollTo){const m=s.getBoundingClientRect().top+_.scrollTop-(e.scrollToOffset||0);R.scrollTo({...e.scrollToOptions||{},top:m,left:0})}}q("routeChanged",t,e)};var Y=A;export{Y as default,D as getActiveRoute,M as lastLevel,j as lastPathname,W as matchRoute,G as parseQuery,I as parseRoutePattern,A as router,Q 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
@@ -159,6 +159,11 @@ const normalizePath = (p) => (!p || p === 'srcdoc' || p === 'about:srcdoc') ? '/
159
159
  const defaultOptions = {
160
160
  level: lastLevel,
161
161
  pushState: true,
162
+ // `replace: true` REWRITES the current history entry (replaceState)
163
+ // instead of adding one — a redirect, a canonical-URL rewrite, or a
164
+ // filter/query change that must not stack Back-button entries. It only
165
+ // chooses how the entry is written: `pushState: false` still writes none.
166
+ replace: false,
162
167
  initialRender: false,
163
168
  scrollToTop: true,
164
169
  scrollToNode: false,
@@ -176,7 +181,76 @@ const defaultOptions = {
176
181
  scrollToOffset: 0,
177
182
  contentElementKey: 'content',
178
183
  scrollToOptions: { behavior: 'smooth' },
179
- 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
180
254
  }
181
255
 
182
256
  export const router = async (path, el, state = {}, options = {}) => {
@@ -282,25 +356,76 @@ export const router = async (path, el, state = {}, options = {}) => {
282
356
  }
283
357
  }
284
358
 
285
- // pushState always fires before render so the URL bar stays in sync even
286
- // if the content-render step below no-ops due to an extends/ref mismatch.
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
+
394
+ // The history write always fires before render so the URL bar stays in
395
+ // sync even if the content-render step below no-ops due to an extends/ref
396
+ // mismatch. `replace` rewrites the current entry instead of pushing one.
287
397
  if (opts.pushState) {
398
+ const url = pathname + (search || '') + (hash || '')
288
399
  try {
289
- win.history.pushState(state, null, pathname + (search || '') + (hash || ''))
400
+ if (opts.replace) win.history.replaceState(state, null, url)
401
+ else win.history.pushState(state, null, url)
290
402
  } catch (e) {} // expected in sandboxed iframes (e.g. about:srcdoc) where pushState is restricted
291
403
  }
292
404
 
293
- if (pathChanged || !hashChanged) {
294
- const stateUpdate = { route, routePath, hash, debugging: false }
295
- if (Object.keys(params).length) stateUpdate.params = params
296
- if (Object.keys(query).length) stateUpdate.query = query
297
-
298
- if (opts.updateState) {
299
- element.state.update(
300
- stateUpdate,
301
- { preventContentUpdate: true }
302
- )
303
- }
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
+ )
304
429
  }
305
430
 
306
431
  // ROUTER-QUERY-SWAP-1 (fable.md): `content` is matched purely from
@@ -333,9 +458,7 @@ export const router = async (path, el, state = {}, options = {}) => {
333
458
  // intact, because a query-only nav re-matches the SAME route entry and so
334
459
  // yields the identical `content` reference. `opts.force` stays available
335
460
  // for a caller that knows the content object was mutated in place.
336
- const contentChanged =
337
- !lastRendered || lastRendered.content !== content || lastRendered.route !== route
338
- const shouldSwap = pathChanged || contentChanged || opts.force === true
461
+ // (`contentChanged` / `shouldSwap` are computed above, before the exit.)
339
462
  if (shouldSwap) {
340
463
  if (contentElementKey && opts.removeOldElement) {
341
464
  element[contentElementKey].remove()
@@ -361,6 +484,8 @@ export const router = async (path, el, state = {}, options = {}) => {
361
484
  }
362
485
  if (opts.useFragment) nextContent.tag = 'fragment'
363
486
 
487
+ const claim = { content, route }
488
+ const previous = ref.__routerRendered
364
489
  try {
365
490
  // Defensive: when the popstate handler fires AFTER an app's element
366
491
  // has been disposed (test-isolation, multi-app teardown, hot-reload)
@@ -370,18 +495,38 @@ export const router = async (path, el, state = {}, options = {}) => {
370
495
  // earlier. Skipping silently here matches the rest of the router's
371
496
  // "bail without exploding" contract for dead elements.
372
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
373
511
  element.set(nextContent, { contentElementKey })
374
- // Recorded only after the swap actually happened, so a dead element
375
- // (the early return above) or a throw below never marks content as
376
- // rendered that never reached the DOM — that would re-arm exactly the
377
- // stuck state this gate exists to clear.
378
- ref.__routerRendered = { content, route }
379
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
380
519
  console.error('[smbls/router] failed to render route content', pathname, err)
381
520
  }
382
521
  }
383
522
 
384
- if (opts.scrollToTop && scrollNode?.scrollTo) {
523
+ // FW-ROUTER-HASH-PUSH-SCROLLS-TO-TOP-1: a push that only changes the hash
524
+ // (pathname unchanged) must land on the hash target, not the top — the
525
+ // unconditional scrollToTop below used to cancel a project's own
526
+ // scroll-into-view on every hash-only push.
527
+ const hashOnlyNav = !pathChanged && hashChanged
528
+
529
+ if (opts.scrollToTop && !hashOnlyNav && scrollNode?.scrollTo) {
385
530
  scrollNode.scrollTo({
386
531
  ...(opts.scrollToOptions || {}),
387
532
  top: 0,
@@ -397,11 +542,16 @@ export const router = async (path, el, state = {}, options = {}) => {
397
542
  }
398
543
 
399
544
  if (hash) {
400
- const activeNode = doc.getElementById(hash)
545
+ // `hash` comes from `URL.hash` and always includes the leading `#`;
546
+ // `getElementById` never does — this lookup used to always miss.
547
+ const activeNode = doc.getElementById(hash.slice(1))
401
548
  if (activeNode && scrollNode?.scrollTo) {
549
+ const style = win.getComputedStyle ? win.getComputedStyle(activeNode) : null
550
+ const scrollMarginTop = (style && parseFloat(style.scrollMarginTop)) || 0
402
551
  const top =
403
552
  activeNode.getBoundingClientRect().top +
404
- rootNode.scrollTop -
553
+ scrollNode.scrollTop -
554
+ scrollMarginTop -
405
555
  (opts.scrollToOffset || 0)
406
556
  scrollNode.scrollTo({
407
557
  ...(opts.scrollToOptions || {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/router",
3
- "version": "3.14.601",
3
+ "version": "3.14.603",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",