stx-router 0.2.445 → 0.2.449

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 +85 -25
  2. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -850,15 +850,28 @@ function routerSource() {
850
850
  if(key)loadedExternalScripts[key]=1;
851
851
  return key;
852
852
  }
853
- // Returns a promise, or null when there is nothing to wait for \u2014 an already
854
- // loaded file, or an unparseable src. Resolves rather than rejects on error:
855
- // one 404 must not stop the rest of the page hydrating, matching what the
856
- // head-script path already does by sending failures to execScripts too.
853
+ // Returns a promise of whether the file loaded, or null when there is
854
+ // nothing to wait for \u2014 an already loaded file, or an unparseable src.
855
+ // Resolves rather than rejects on error: one 404 must not stop the rest of
856
+ // the page hydrating; the caller decides what a failure means.
857
+ //
858
+ // A file still on its way is not a loaded one. A prefetch starts loading
859
+ // the page's module registry (loadModuleRegistries), and this used to count
860
+ // it loaded the moment it was requested, so a tap that beat it swapped the
861
+ // page in and ran its setup ahead of the registry it imports from: "is not
862
+ // registered on this page", every binding dead. Locally a file loads in a
863
+ // millisecond and the tap never wins; on a phone far from the server it
864
+ // does. The tap now waits on the same request.
865
+ //
866
+ // And a file that failed is forgotten, so a fresh copy of the page that
867
+ // links it again (recoverUnbound) asks for it again rather than finding it
868
+ // "loaded" and running without it until the next reload.
869
+ var pendingExternalScripts={};
857
870
  function loadExternalScript(src,base){
858
871
  var key=externalScriptKey(src,base);
859
872
  if(!key||loadedExternalScripts[key])return null;
860
- loadedExternalScripts[key]=1;
861
- return new Promise(function(resolve){
873
+ if(pendingExternalScripts[key])return pendingExternalScripts[key];
874
+ return pendingExternalScripts[key]=new Promise(function(resolve){
862
875
  var el=ce('script');
863
876
  el.src=key;
864
877
  // Deliberately NOT data-stx-page: both swap paths clear those on every
@@ -869,11 +882,31 @@ function routerSource() {
869
882
  // In document order: a fragment's composables bundle reads the module
870
883
  // registry loaded beside it, and an inserted script is async by default.
871
884
  el.async=false;
872
- el.onload=function(){resolve()};
873
- el.onerror=function(){log('[router] container script failed:',key);resolve()};
885
+ el.onload=function(){delete pendingExternalScripts[key];loadedExternalScripts[key]=1;resolve(true)};
886
+ el.onerror=function(){
887
+ log('[router] container script failed:',key);
888
+ delete pendingExternalScripts[key];
889
+ if(el.parentNode)el.parentNode.removeChild(el);
890
+ resolve(false);
891
+ };
874
892
  dhead().appendChild(el);
875
893
  });
876
894
  }
895
+ // A page's module registry, stores or composables in serve mode: stx's own
896
+ // file, named by its content (registerServeModuleBundle). Without it the
897
+ // page's setup cannot run at all, unlike an app's own script, whose
898
+ // failure the rest of the page survives.
899
+ var MODULE_BUNDLE=/\\/_stx\\/modules\\.[0-9a-f]{16}\\.js(?:[?#]|$)/;
900
+ function isModuleBundle(src){return MODULE_BUNDLE.test(src||'')}
901
+ // Waits for the files a swap loads, then runs its scripts -- or, when one
902
+ // of them was a module bundle that did not load, recovers the page instead
903
+ // of running a setup that can only throw.
904
+ function afterExternalScripts(pending,run,url,ctx){
905
+ if(!pending.length){run();return}
906
+ var failed=false;
907
+ Promise.all(pending.map(function(p){return p.promise.then(function(ok){if(ok===false&&isModuleBundle(p.src))failed=true})}))
908
+ .then(function(){if(failed)recoverUnbound(url,ctx);else run()},run);
909
+ }
877
910
 
878
911
  // Record scripts from the initial page load
879
912
  qsa('script').forEach(function(s){
@@ -997,9 +1030,13 @@ function routerSource() {
997
1030
  location.href=away;
998
1031
  return true;
999
1032
  }
1000
- function routerFetch(url,headers){
1033
+ // fresh: past every cache, the offline worker's included (it hands a
1034
+ // no-store request straight to the network). For recoverUnbound, whose
1035
+ // whole point is that the kept copy is the broken one.
1036
+ function routerFetch(url,headers,fresh){
1001
1037
  var init={headers:headers};
1002
1038
  if(navAbort)init.signal=navAbort.signal;
1039
+ if(fresh)init.cache='no-store';
1003
1040
  return fetch(url,init);
1004
1041
  }
1005
1042
  function navigate(url,pushState,force){
@@ -1115,7 +1152,7 @@ else {
1115
1152
  // the page content. Custom app-shell containers need full documents so
1116
1153
  // the router does not inject a <main> fragment into the wrong element.
1117
1154
  var wantsFragment=shouldUseFragmentResponse();
1118
- return routerFetch(url,wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}).then(function(r){
1155
+ return routerFetch(url,wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'},ctx.fresh).then(function(r){
1119
1156
  if(stale())return null;
1120
1157
  if(leaveOrigin(r))return null;
1121
1158
  if(!r.ok)throw httpError(r.status);
@@ -1149,7 +1186,7 @@ else {
1149
1186
  // Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
1150
1187
  if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
1151
1188
  log('[router] layout change \u2014 fetching full page for document swap');
1152
- return routerFetch(url,{'Accept':'text/html'}).then(function(fullRes){
1189
+ return routerFetch(url,{'Accept':'text/html'},ctx.fresh).then(function(fullRes){
1153
1190
  log('[router] full page fetched:',fullRes.status,'ok:',fullRes.ok);
1154
1191
  if(leaveOrigin(fullRes))return null;
1155
1192
  if(!fullRes.ok)throw httpError(fullRes.status);
@@ -1198,6 +1235,33 @@ else {
1198
1235
  pendingCtx=ctx;
1199
1236
  return Promise.resolve(swap(html,cacheKey(url),pushState,hash)).then(function(){return false});
1200
1237
  }
1238
+ // \u2500\u2500 A page whose module bundle did not load \u2500\u2500
1239
+ // Serve mode links a page's registry, stores and composables as
1240
+ // /_stx/modules.<hash>.js, answered from what the server process has
1241
+ // rendered. Its HTML can outlive that process: the router's cache, a
1242
+ // prefetch, the offline worker's kept copy. After a deploy (a new release
1243
+ // directory, a new process that has never rendered the page) the hash is
1244
+ // answered 404, and the page was swapped in with nothing bound -- its
1245
+ // {{ }} empty, its :for lists empty, its :if buttons gone -- until a
1246
+ // revalidation happened to replace the copy (hq.training, a coach's
1247
+ // /coaches/athletes).
1248
+ //
1249
+ // So that copy is dropped and the page fetched again past every cache:
1250
+ // the process answering renders it, which registers the bundle it links,
1251
+ // and a newer build is caught by the skew check and loaded whole (#1772).
1252
+ // Once only. A fresh copy whose bundle fails too is loaded as a document,
1253
+ // where the browser and the server settle it without the router.
1254
+ //
1255
+ // Not a page whose own setup throws: that throws again on a fresh copy, so
1256
+ // retrying would only add a round trip and a reload to every visit.
1257
+ function recoverUnbound(url,ctx){
1258
+ if(ctx&&ctx.id&&ctx.id!==navSeq)return;
1259
+ evictCache(cacheKey(url));
1260
+ if(ctx&&ctx.fresh){log('[router] module bundle failed again \u2014 full navigation to:',url);location.href=url;return}
1261
+ log('[router] module bundle failed \u2014 fetching',url,'again past the caches');
1262
+ navCtxNext={direction:'replace',fresh:true};
1263
+ navigate(url,'replace',true);
1264
+ }
1201
1265
  function retryShown(){var root=activeRoot();var el=root&&root.querySelector?root.querySelector('[data-stx-retry]'):null;return el?el.getAttribute('data-stx-retry'):''}
1202
1266
  document.addEventListener('click',function(e){
1203
1267
  var b=e.target&&e.target.closest?e.target.closest('[data-stx-retry-button]'):null;
@@ -1632,10 +1696,9 @@ else {
1632
1696
  var fragPending=[];
1633
1697
  fragExternalScripts.forEach(function(src){
1634
1698
  var pending=loadExternalScript(src,url);
1635
- if(pending)fragPending.push(pending);
1699
+ if(pending)fragPending.push({src:src,promise:pending});
1636
1700
  });
1637
- if(fragPending.length)Promise.all(fragPending).then(runFragScripts).catch(runFragScripts);
1638
- else runFragScripts();
1701
+ afterExternalScripts(fragPending,runFragScripts,url+(hash||''),ctx);
1639
1702
  }
1640
1703
  // With screens retained, the outgoing page is kept (or disposed once it
1641
1704
  // has animated away) by screenBegin rather than cleaned up here.
@@ -1900,13 +1963,15 @@ else {
1900
1963
  var src=new URL(s.getAttribute('src'),docBase).href;
1901
1964
  if(loadedSrcs[src])return;
1902
1965
  loadedSrcs[src]=1;
1903
- extPromises.push(new Promise(function(resolve,reject){
1966
+ extPromises.push({src:src,promise:new Promise(function(resolve){
1904
1967
  var ns=ce('script');
1905
1968
  ns.src=src;
1906
- ns.onload=resolve;
1907
- ns.onerror=reject;
1969
+ ns.onload=function(){resolve(true)};
1970
+ // Settled either way, as before (a rejection ran the scripts at
1971
+ // once); afterExternalScripts tells a failed module bundle apart.
1972
+ ns.onerror=function(){if(ns.parentNode)ns.parentNode.removeChild(ns);resolve(false)};
1908
1973
  dhead().appendChild(ns);
1909
- }));
1974
+ })});
1910
1975
  });
1911
1976
 
1912
1977
  // Before the inline scripts, and through the same barrier: an inline
@@ -1914,7 +1979,7 @@ else {
1914
1979
  // so running it first would fail exactly the way this bug did.
1915
1980
  routedExternalScripts.forEach(function(src){
1916
1981
  var pending=loadExternalScript(src,docBase);
1917
- if(pending)extPromises.push(pending);
1982
+ if(pending)extPromises.push({src:src,promise:pending});
1918
1983
  });
1919
1984
 
1920
1985
  // \u2500\u2500 Script re-execution \u2500\u2500
@@ -2089,12 +2154,7 @@ else {
2089
2154
  window.dispatchEvent(new Event('stx:load'));
2090
2155
  }
2091
2156
 
2092
- if(extPromises.length>0){
2093
- Promise.all(extPromises).then(execScripts).catch(execScripts);
2094
- }
2095
- else {
2096
- execScripts();
2097
- }
2157
+ afterExternalScripts(extPromises,execScripts,url+(hash||''),ctx);
2098
2158
  }
2099
2159
 
2100
2160
  function startSwap(){
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stx-router",
3
3
  "type": "module",
4
- "version": "0.2.445",
4
+ "version": "0.2.449",
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",