@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 +55 -2
- package/dist/cjs/index.js +1 -1
- package/dist/esm/index.js +1 -1
- package/index.js +176 -26
- package/package.json +1 -1
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` |
|
|
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
|
|
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
|
|
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
|
-
//
|
|
286
|
-
//
|
|
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.
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
+
scrollNode.scrollTop -
|
|
554
|
+
scrollMarginTop -
|
|
405
555
|
(opts.scrollToOffset || 0)
|
|
406
556
|
scrollNode.scrollTo({
|
|
407
557
|
...(opts.scrollToOptions || {}),
|