@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 +50 -1
- package/dist/cjs/index.js +1 -1
- package/dist/esm/index.js +1 -1
- package/index.js +151 -24
- package/package.json +1 -1
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
|
|
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
|
@@ -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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
}
|