stx-router 0.2.527 → 0.2.528

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.
Files changed (2) hide show
  1. package/dist/client.js +165 -16
  2. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -55,7 +55,7 @@ function routerSource() {
55
55
  if(window.__stxRouter&&window.__stxRouter.__rev===ROUTER_REV)return;
56
56
 
57
57
  // \u2500\u2500 Configuration \u2500\u2500
58
- var defaults={container:'main',loadingClass:'stx-navigating',viewTransitions:true,cache:true,scrollToTop:true,prefetch:true,progress:true,progressColor:'#78dce8',progressHeight:'2px',interceptAllLinks:false,prefetchCacheMax:50,routeFocus:true,announceRoute:true,interceptForms:false,cssLoadTimeout:1500,scrollRestoration:true,screens:'auto',screenLimit:8,navDuration:350,swipeBack:true,swipeEdge:20,prefetchVisible:true,prefetchVisibleMax:12,revalidate:true,revalidateAfter:3000};
58
+ var defaults={container:'main',loadingClass:'stx-navigating',viewTransitions:true,cache:true,scrollToTop:true,prefetch:true,progress:true,progressColor:'#78dce8',progressHeight:'2px',interceptAllLinks:false,prefetchCacheMax:50,routeFocus:true,announceRoute:true,interceptForms:false,cssLoadTimeout:1500,scrollRestoration:true,screens:'auto',screenLimit:8,navDuration:350,swipeBack:true,swipeEdge:20,prefetchVisible:true,prefetchVisibleMax:12,revalidate:true,revalidateAfter:3000,keepOpening:true};
59
59
  var o=Object.assign({},defaults,window.__stxRouterConfig||{},window.STX_ROUTER_OPTIONS||{});
60
60
  var containerSel=o.container;
61
61
  var debug=!!o.debug;
@@ -135,8 +135,14 @@ function routerSource() {
135
135
  function rememberScroll(){
136
136
  if(active&&active.el&&!active.el.hasAttribute(HIDDEN))saveScroll(active);
137
137
  if(!restoreScroll)return;
138
+ // The window's position as the swap began, like the scrollers' below.
139
+ // Read now it is wrong: the outgoing page has been replaced by one still
140
+ // filling in, a few hundred pixels tall, and the window has clamped to it
141
+ // -- every entry was filed at 0,0 and Back went to the top.
142
+ var win=winSnap||[window.pageXOffset||window.scrollX||0,window.pageYOffset||window.scrollY||0];
143
+ winSnap=undefined;
138
144
  try{
139
- window.sessionStorage.setItem(SCROLL_STORE+scrollToken,(window.pageXOffset||window.scrollX||0)+','+(window.pageYOffset||window.scrollY||0));
145
+ window.sessionStorage.setItem(SCROLL_STORE+scrollToken,win[0]+','+win[1]);
140
146
  // Taken as the swap began when there is one: by the time the history
141
147
  // is written the outgoing page's scrollers have been replaced.
142
148
  var inner=innerSnap!==undefined?innerSnap:innerScroll(activeRoot());
@@ -147,16 +153,76 @@ function routerSource() {
147
153
  }
148
154
  var pendingInner=null;
149
155
  var innerSnap;
156
+ var winSnap;
157
+ function snapScroll(){
158
+ innerSnap=innerScroll(activeRoot());
159
+ winSnap=[window.pageXOffset||window.scrollX||0,window.pageYOffset||window.scrollY||0];
160
+ }
150
161
  function readInner(token){
151
162
  try{return JSON.parse(window.sessionStorage.getItem(SCROLL_STORE+token+':i')||'null')}catch(e){return null}
152
163
  }
153
164
  // One place decides where a swap leaves the viewport, so the fragment path
154
165
  // and the whole-document path cannot drift apart.
166
+ // A page put back by Back is often shorter when it is swapped in than when
167
+ // it was left: its lists are drawn from data that is still on its way (or
168
+ // from a store a moment later), so scrollTo clamps to the little there is
169
+ // and the place is lost -- Back always landed at the top of a long page.
170
+ // For a moment after, hold the position: try again each time the page
171
+ // changes size. The person scrolling, a key, a tap, the page scrolling
172
+ // itself, the next navigation or four seconds (a page whose data is slow
173
+ // to come) end the hold.
174
+ var scrollHold=null;
175
+ var HOLD_ENDERS=['wheel','touchstart','keydown','pointerdown'];
176
+ function releaseScrollHold(){
177
+ if(!scrollHold)return;
178
+ var h=scrollHold;scrollHold=null;
179
+ if(h.ro)h.ro.disconnect();
180
+ clearTimeout(h.timer);
181
+ // The window and root the hold was made on: by the time its timer fires
182
+ // the document may be another one.
183
+ if(h.onScroll)h.win.removeEventListener('scroll',h.onScroll);
184
+ if(h.anchor!==null&&h.root)h.root.style.overflowAnchor=h.anchor;
185
+ HOLD_ENDERS.forEach(function(n){h.win.removeEventListener(n,h.stop,true)});
186
+ }
187
+ function holdScroll(x,y){
188
+ releaseScrollHold();
189
+ var reached=function(){return Math.abs((window.pageYOffset||window.scrollY||0)-y)<2};
190
+ if(typeof ResizeObserver!=='function')return;
191
+ var h={stop:releaseScrollHold,ro:null,timer:0,onScroll:null,anchor:null,win:window,root:document.documentElement};
192
+ // Scroll anchoring off while held: content filling in above the place
193
+ // would otherwise carry the window down with it (Chrome keeps what is
194
+ // on screen in view), and Back landed thousands of pixels past it.
195
+ var rootStyle=document.documentElement&&document.documentElement.style;
196
+ if(rootStyle){h.anchor=rootStyle.overflowAnchor||'';rootStyle.overflowAnchor='none'}
197
+ h.ro=new ResizeObserver(function(){
198
+ // Held for the whole window even once reached: a page that is tall
199
+ // enough when it is put back is often still drawing, above the place
200
+ // as much as below it.
201
+ if(scrollHold!==h||reached())return;
202
+ window.scrollTo({left:x,top:y,behavior:'instant'});
203
+ });
204
+ h.ro.observe(document.body||document.documentElement);
205
+ h.timer=setTimeout(releaseScrollHold,4000);
206
+ HOLD_ENDERS.forEach(function(n){window.addEventListener(n,h.stop,{capture:true,passive:true})});
207
+ // Moved by something else (a scrollbar dragged, the page scrolling to
208
+ // what it wants to show): theirs wins. Only the clamp to a page still
209
+ // too short, at its very end, is the hold's own business.
210
+ h.onScroll=function(){
211
+ var at=window.pageYOffset||window.scrollY||0;
212
+ var end=Math.max(0,(document.documentElement.scrollHeight||0)-(window.innerHeight||0));
213
+ if(Math.abs(at-y)<2||(at<y&&at>=end-1))return;
214
+ releaseScrollHold();
215
+ };
216
+ window.addEventListener('scroll',h.onScroll,{passive:true});
217
+ scrollHold=h;
218
+ }
155
219
  function applyScroll(hash){
220
+ releaseScrollHold();
156
221
  if(pendingScroll){
157
222
  var at=pendingScroll;
158
223
  pendingScroll=null;
159
224
  window.scrollTo({left:at[0],top:at[1],behavior:'instant'});
225
+ holdScroll(at[0],at[1]);
160
226
  // Again once the page has bound: a scroller's content is often drawn
161
227
  // by a :for that has not run yet, and a box with nothing in it cannot
162
228
  // be scrolled anywhere.
@@ -185,7 +251,7 @@ function routerSource() {
185
251
  if(known){
186
252
  var saved=readScroll(scrollToken);
187
253
  if(saved&&(saved[0]||saved[1])){
188
- var restoreAt=function(){window.scrollTo({left:saved[0],top:saved[1],behavior:'instant'})};
254
+ var restoreAt=function(){window.scrollTo({left:saved[0],top:saved[1],behavior:'instant'});holdScroll(saved[0],saved[1])};
189
255
  if(document.readyState==='complete')restoreAt();
190
256
  else window.addEventListener('load',restoreAt,{once:true});
191
257
  }
@@ -388,8 +454,9 @@ function routerSource() {
388
454
  var progDelay=null;
389
455
  function startProgress(){
390
456
  if(!o.progress||!progEl)return;
391
- if(progTimer){clearInterval(progTimer);progTimer=null}
392
- if(progDelay)clearTimeout(progDelay);
457
+ // Already counting for this tap (it waited on a prefetch, then went on
458
+ // to swap): keep the bar where it is rather than start it again.
459
+ if(progTimer||progDelay)return;
393
460
  progDelay=setTimeout(function(){
394
461
  progDelay=null;
395
462
  setProgress(8);
@@ -1092,6 +1159,8 @@ function routerSource() {
1092
1159
  return fetch(url,init);
1093
1160
  }
1094
1161
  function navigate(url,pushState,force){
1162
+ releaseScrollHold();
1163
+ winSnap=undefined;
1095
1164
  var ctx=navCtxNext||{};
1096
1165
  navCtxNext=null;
1097
1166
  if(pushState&&typeof pushState==='object'){if(pushState.instant)instantNext=true;pushState=pushState.replace?'replace':true}
@@ -1128,6 +1197,14 @@ function routerSource() {
1128
1197
  var awaitedKey=cacheKey(url);
1129
1198
  if(o.cache&&!force&&!cache[awaitedKey]&&prefetching[awaitedKey]&&prefetching[awaitedKey].then){
1130
1199
  awaitingKey=awaitedKey;
1200
+ // Answer the tap now, not when the prefetch lands. The page it asked
1201
+ // for is on the way, but until then nothing on screen said so: no
1202
+ // loading class, no progress bar. On a server a second or two away
1203
+ // (the hover started the fetch a moment before the click) a link read
1204
+ // as dead, and was clicked again, and again (hq.training's Calendar).
1205
+ // The navigation this hands over to keeps both going and ends them.
1206
+ document.body.classList.add(o.loadingClass);
1207
+ startProgress();
1131
1208
  return prefetching[awaitedKey].then(function(){
1132
1209
  if(awaitingKey!==awaitedKey)return false;
1133
1210
  awaitingKey='';
@@ -1187,9 +1264,15 @@ function routerSource() {
1187
1264
  return swapWith(html,targetPath);
1188
1265
  }).catch(failed).finally(done);
1189
1266
  }
1190
- pendingContainerAttrs=attrsCache[targetPath]||'';
1191
- pendingLayoutDecl=null;
1192
- return Promise.resolve(swapWith(cache[targetPath],targetPath)).then(function(swapped){
1267
+ var cached=cache[targetPath];
1268
+ var swapCached=function(){
1269
+ if(stale())return false;
1270
+ pendingContainerAttrs=attrsCache[targetPath]||'';
1271
+ pendingLayoutDecl=null;
1272
+ return swapWith(cached,targetPath);
1273
+ };
1274
+ var loading=beforeSwap(cached,url);
1275
+ return (loading?loading.then(function(ok){if(!ok){if(!stale())recoverUnbound(url,ctx);return false}return swapCached()}):Promise.resolve(swapCached())).then(function(swapped){
1193
1276
  if(swapped===false)return false;
1194
1277
  applyTitle(titleCache[targetPath]);
1195
1278
  // Shown from the cache at once; asked again behind it, so a page that
@@ -1253,6 +1336,14 @@ else {
1253
1336
  if(!result||stale())return false;
1254
1337
  if(result.isFragment)result.html=fragmentMarker(result.runtime)+result.html;
1255
1338
  if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
1339
+ var loading=beforeSwap(result.html,url);
1340
+ return loading?loading.then(function(ok){
1341
+ if(ok||stale())return result;
1342
+ recoverUnbound(url,ctx);
1343
+ return null;
1344
+ }):result;
1345
+ }).then(function(result){
1346
+ if(!result||stale())return false;
1256
1347
  pendingContainerAttrs=result.isFragment?(result.containerAttrs||''):'';
1257
1348
  pendingLayoutDecl=result.isFragment?null:{layout:result.layout||'',group:result.layoutGroup||''};
1258
1349
  return Promise.resolve(swapWith(result.html,targetPath)).then(function(swapped){
@@ -1785,7 +1876,7 @@ else {
1785
1876
  var useScreens=screensOn();
1786
1877
  function startFragSwap(){
1787
1878
  if(ctx.id&&ctx.id!==navSeq)return Promise.resolve(false);
1788
- if(pushState!==false)innerSnap=innerScroll(activeRoot());
1879
+ if(pushState!==false)snapScroll();
1789
1880
  if(useScreens)settleAnimation();
1790
1881
  else if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
1791
1882
  return new Promise(function(resolve,reject){
@@ -2254,7 +2345,7 @@ else {
2254
2345
 
2255
2346
  function startSwap(){
2256
2347
  if(ctx.id&&ctx.id!==navSeq)return Promise.resolve(false);
2257
- if(pushState!==false)innerSnap=innerScroll(activeRoot());
2348
+ if(pushState!==false)snapScroll();
2258
2349
  setDirection(ctx.direction);
2259
2350
  // A whole document is written into the container as it is, so retained
2260
2351
  // screens go first: they would otherwise outlive the page they belong to.
@@ -3027,7 +3118,7 @@ else {
3027
3118
  sightObserver.unobserve(en.target);
3028
3119
  if(sightCount>=o.prefetchVisibleMax)return;
3029
3120
  sightCount++;
3030
- prefetchLink(en.target);
3121
+ prefetchLink(en.target,false,true);
3031
3122
  });
3032
3123
  });
3033
3124
  sightCount=0;
@@ -3340,14 +3431,24 @@ else {
3340
3431
  // prefetch has finished waits for that answer instead of asking again.
3341
3432
  // 'fresh' refetches a page that is cached, for an entry restored from an
3342
3433
  // earlier launch that is shown at once and replaced behind it.
3343
- function prefetchLink(link,fresh){
3434
+ // 'glance' is a link that merely came into view (prefetchVisible): its
3435
+ // page is fetched, but its module bundles are left for an intent (a hover,
3436
+ // a touch) or the tap itself, which waits for them (beforeSwap). Each page
3437
+ // links bundles of its own, hq.training's ~4.7MB of script apiece, and a
3438
+ // screen with a dozen links in view used to download and evaluate every
3439
+ // one of them on arrival, competing with the page being read.
3440
+ function prefetchLink(link,fresh,glance){
3344
3441
  // Same opt-out set as the click path. Without it, hovering a link the
3345
3442
  // router is not allowed to claim still fired a real GET at it \u2014 a
3346
3443
  // logout or OAuth URL was requested on hover alone.
3347
3444
  if(isRouterExcluded(link))return;
3348
3445
  var href=withCurrentLocale(link.getAttribute('href'));
3349
3446
  var key=cacheKey(href);
3350
- if((cache[key]&&!fresh)||prefetching[key])return;
3447
+ if(cache[key]&&!fresh){if(!glance)loadModuleRegistries(cache[key],href);return}
3448
+ if(prefetching[key]){
3449
+ if(!glance)prefetching[key].then(function(){if(cache[key])loadModuleRegistries(cache[key],href)});
3450
+ return;
3451
+ }
3351
3452
  var eager=link.getAttribute('data-stx-prefetch')==='eager';
3352
3453
  var wantsFragment=shouldUseFragmentResponse();
3353
3454
  prefetching[key]=fetch(href,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
@@ -3358,7 +3459,7 @@ else {
3358
3459
  if(eager)keepPage(key,result);
3359
3460
  }
3360
3461
  if(result){
3361
- loadModuleRegistries(result.html,href);
3462
+ if(!glance)loadModuleRegistries(result.html,href);
3362
3463
  // Its stylesheet too, so the tap never waits on cssLoadTimeout for a
3363
3464
  // sheet it could have had all along. Added unapplied (media=print),
3364
3465
  // as a navigation adds it; the swap makes it live.
@@ -3411,14 +3512,41 @@ else {
3411
3512
  // registers modules, once, so loading it early changes nothing but timing,
3412
3513
  // and the tap then finds every script it needs already here: the swap runs
3413
3514
  // in one task and the first visit to a tab paints whole, like the second.
3515
+ //
3516
+ // The composables bundle too (also stx's own /_stx/modules.<hash>.js): it
3517
+ // only registers what is not registered yet. Answers a promise for the
3518
+ // files still on their way, or null when there are none.
3414
3519
  function loadModuleRegistries(html,base){
3520
+ var waits=[];
3415
3521
  html.replace(new RegExp('<scr'+'ipt\\\\b([^>]*)>','gi'),function(m,attrs){
3416
- if(!/(?:^|\\s)data-stx-modules(?:[\\s=]|$)/i.test(attrs))return m;
3417
3522
  var srcMatch=attrs.match(/\\bsrc\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
3418
3523
  var src=srcMatch&&(srcMatch[1]||srcMatch[2]||srcMatch[3]);
3419
- if(src)loadExternalScript(src,base);
3524
+ if(!src)return m;
3525
+ if(!/(?:^|\\s)data-stx-modules(?:[\\s=]|$)/i.test(attrs)&&!isModuleBundle(src))return m;
3526
+ var pending=loadExternalScript(src,base);
3527
+ if(pending)waits.push(pending);
3420
3528
  return m;
3421
3529
  });
3530
+ return waits.length?Promise.all(waits):null;
3531
+ }
3532
+ // A page swapped in before its module bundles have loaded is drawn bare
3533
+ // (everything behind :if and x-cloak) and bound a network round trip
3534
+ // later: the main area went blank for a moment on every page the router
3535
+ // had not prefetched -- Back to the page the app opened on, a link it never
3536
+ // hovered. Waiting for them first keeps the outgoing page up, under the
3537
+ // progress bar, and lets the swap, its scripts and the first paint happen
3538
+ // in one task. Capped, so a bundle that hangs costs a delay, not the page.
3539
+ // Null when there is nothing to wait for, so a cached page still swaps in
3540
+ // the task of the tap that asked for it.
3541
+ // Answers false when a bundle failed: that page cannot bind, and is
3542
+ // recovered (recoverUnbound) rather than swapped in bare.
3543
+ function beforeSwap(html,base){
3544
+ var waits=loadModuleRegistries(html,base);
3545
+ if(!waits)return null;
3546
+ return Promise.race([
3547
+ waits.then(function(results){return results.indexOf(false)===-1}),
3548
+ new Promise(function(resolve){setTimeout(function(){resolve(true)},4000)}),
3549
+ ]);
3422
3550
  }
3423
3551
 
3424
3552
  // Links marked data-stx-prefetch="eager", fetched once the page is idle, so
@@ -3449,6 +3577,25 @@ else {
3449
3577
  else setTimeout(run,100);
3450
3578
  }
3451
3579
 
3580
+ // The page the app was opened on came as a whole document, so it is the
3581
+ // one page the cache never holds: Back to it went to the server and left
3582
+ // the page being read on screen under the new address until it answered
3583
+ // (a second or more on a distant server). Once the page is idle its
3584
+ // fragment is fetched, the same request a link to it would make, so Back
3585
+ // is a swap like every other.
3586
+ function keepOpeningPage(){
3587
+ if(!o.cache||!shouldUseFragmentResponse())return;
3588
+ var c=navigator.connection;
3589
+ if(c&&(c.saveData||/2g/.test(c.effectiveType||'')))return;
3590
+ var url=location.pathname+location.search;
3591
+ var key=cacheKey(url);
3592
+ // Late by design, so it can find the page gone (a test's document torn
3593
+ // down, a full navigation begun): never let that throw from a timer.
3594
+ var run=function(){try{if(!cache[key]&&!prefetching[key])revalidate(key,url)}catch(e){}};
3595
+ if(window.requestIdleCallback)window.requestIdleCallback(run,{timeout:3000});
3596
+ else setTimeout(run,1500);
3597
+ }
3598
+
3452
3599
  if(o.prefetch){
3453
3600
  // A mouse hovers before it clicks. A finger does not hover, but it
3454
3601
  // touches down a tap's length before the click: start the fetch there.
@@ -3736,6 +3883,8 @@ else {
3736
3883
  restoreKeptPages();
3737
3884
  prefetchEager();
3738
3885
  observeLinks();
3886
+ // The runtime's first stx:load has usually gone by when this runs.
3887
+ if(o.prefetch&&o.keepOpening!==false)keepOpeningPage();
3739
3888
  }
3740
3889
 
3741
3890
  if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stx-router",
3
3
  "type": "module",
4
- "version": "0.2.527",
4
+ "version": "0.2.528",
5
5
  "description": "File-based router for STX — .stx template discovery, nested layouts, typed route params, middleware, and client-side SPA navigation.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",