stx-router 0.2.385 → 0.2.387

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/dist/client.js CHANGED
@@ -203,6 +203,83 @@ function routerSource() {
203
203
  }
204
204
  }
205
205
 
206
+ // \u2500\u2500 Instant navigation: one frame, already filled \u2500\u2500
207
+ // A tab swap used to paint the new screen before its scripts had run: a page
208
+ // script with an import is a module, and a module runs after the task that
209
+ // inserted it, so for a few frames the screen showed its bare markup and then
210
+ // filled in, on every visit. Each inserted page script now reports when it
211
+ // has run, and an instant navigation holds the old frame (a View Transition
212
+ // with no animation) until they all have, capped so it can never hang.
213
+ var scriptsPending=0;
214
+ var hydrateWaiters=[];
215
+ window.__stxScriptRan=function(){
216
+ if(scriptsPending>0)scriptsPending--;
217
+ if(!scriptsPending){var w=hydrateWaiters;hydrateWaiters=[];w.forEach(function(f){f()})}
218
+ };
219
+ function markRan(code){scriptsPending++;return code+String.fromCharCode(10)+';window.__stxScriptRan&&window.__stxScriptRan();'}
220
+ function whenHydrated(capMs){
221
+ return new Promise(function(resolve){
222
+ var done=false;
223
+ function finish(){if(done)return;done=true;setTimeout(resolve,0)}
224
+ if(!scriptsPending){done=true;resolve();return}
225
+ hydrateWaiters.push(finish);
226
+ setTimeout(finish,capMs);
227
+ });
228
+ }
229
+ // Whether a fragment's scripts all run inside the swap's own task: none is
230
+ // a module, and every file it names is already loaded. Then an instant
231
+ // navigation needs no held frame at all: swapped, run and bound before the
232
+ // browser next paints, it shows the new screen whole in the first frame.
233
+ function swapRunsInOneTask(html,base){
234
+ var sync=true;
235
+ html.replace(new RegExp('<scr'+'ipt\\\\b([^>]*)>([\\\\s\\\\S]*?)<\\\\/scr'+'ipt>','gi'),function(m,attrs,code){
236
+ if(!sync)return m;
237
+ var typeMatch=attrs.match(/\\btype\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
238
+ var scriptType=((typeMatch&&(typeMatch[1]||typeMatch[2]||typeMatch[3]))||'').trim().toLowerCase();
239
+ if(scriptType==='module'){sync=false;return m}
240
+ if(scriptType&&scriptType!=='text/javascript'&&scriptType!=='application/javascript')return m;
241
+ var srcMatch=attrs.match(/\\bsrc\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
242
+ var src=srcMatch&&(srcMatch[1]||srcMatch[2]||srcMatch[3]);
243
+ if(src){
244
+ if(!isSharedClientScriptAttrs(attrs)){var key=externalScriptKey(src,base);if(key&&!loadedExternalScripts[key])sync=false}
245
+ return m;
246
+ }
247
+ if(code&&hasStaticImport(code))sync=false;
248
+ return m;
249
+ });
250
+ return sync;
251
+ }
252
+ function runInOneTask(complete){
253
+ // The runtime binds on stx:load after a debounce; bind now, in this task.
254
+ function flush(){if(window.stx&&typeof window.stx._flushLoad==='function')window.stx._flushLoad()}
255
+ window.addEventListener('stx:load',flush,{once:true});
256
+ try{complete()}finally{window.removeEventListener('stx:load',flush)}
257
+ }
258
+ function runInstantSwap(complete){
259
+ if(!document.startViewTransition)return false;
260
+ var root=document.documentElement;
261
+ root.classList.add('stx-instant');
262
+ var clear=function(){root.classList.remove('stx-instant')};
263
+ try{
264
+ var transition=document.startViewTransition(function(){
265
+ scriptsPending=0;
266
+ // The runtime binds the page on stx:load after a short debounce; run
267
+ // it now, so the frame this reveals is bound rather than showing its
268
+ // {{ }} for a frame before they fill in.
269
+ var loaded=new Promise(function(resolve){window.addEventListener('stx:load',function(){if(window.stx&&typeof window.stx._flushLoad==='function')window.stx._flushLoad();resolve()},{once:true});setTimeout(resolve,300)});
270
+ complete();
271
+ return loaded.then(function(){return whenHydrated(300)});
272
+ });
273
+ if(transition&&transition.finished&&transition.finished.then)transition.finished.then(clear,clear);else clear();
274
+ if(transition&&transition.ready&&transition.ready.catch)transition.ready.catch(function(){});
275
+ return true;
276
+ }catch(err){
277
+ clear();
278
+ log('[router] instant transition unavailable:',err&&err.message?err.message:err);
279
+ return false;
280
+ }
281
+ }
282
+
206
283
  // \u2500\u2500 Progress bar (0\u2192100% at top of viewport) \u2500\u2500
207
284
  // Native loading indicator baked into the router. Starts when navigation
208
285
  // begins, trickles up to ~90% during fetch, snaps to 100% + fades on
@@ -800,9 +877,10 @@ function routerSource() {
800
877
  function tokenState(){var state={};state[SCROLL_TOKEN]=scrollToken;return state}
801
878
 
802
879
  // Second arg accepts the legacy pushState boolean OR an options object
803
- // { replace }. Callers inside this file still pass the boolean.
880
+ // { replace, instant }. Callers inside this file still pass the boolean.
881
+ // instant is what data-stx-transition="none" is to a link: no cross-fade.
804
882
  function navigate(url,pushState,force){
805
- if(pushState&&typeof pushState==='object')pushState=pushState.replace?'replace':true;
883
+ if(pushState&&typeof pushState==='object'){if(pushState.instant)instantNext=true;pushState=pushState.replace?'replace':true}
806
884
  // Lang-picker passes force=true with an already-localized path (/en/...).
807
885
  // Re-localizing would map it back to the *current* locale and no-op.
808
886
  if(!force) url=withCurrentLocale(url);
@@ -1328,7 +1406,7 @@ else {
1328
1406
  if(hasImport){
1329
1407
  ns.type='module';
1330
1408
  }
1331
- ns.textContent=(hasImport||isAlreadyScoped)?code:'{'+code+'}';
1409
+ ns.textContent=markRan((hasImport||isAlreadyScoped)?code:'{'+code+'}');
1332
1410
  ns.setAttribute('data-stx-page','');
1333
1411
  if(placeholder&&placeholder.parentNode){
1334
1412
  ns.setAttribute('data-stx-positioned','');
@@ -1369,7 +1447,9 @@ else {
1369
1447
  resolve(true);
1370
1448
  }catch(err){reject(err)}
1371
1449
  }
1372
- if(runViewTransition(completeFragSwap)){}
1450
+ if(instantNav&&swapRunsInOneTask(html,url)){runInOneTask(completeFragSwap)}
1451
+ else if(instantNav&&runInstantSwap(completeFragSwap)){}
1452
+ else if(runViewTransition(completeFragSwap)){}
1373
1453
  else if(instantNav){completeFragSwap()}
1374
1454
  else{currentContent.style.transition='opacity 0.12s ease-out';currentContent.style.opacity='0';setTimeout(function(){completeFragSwap();currentContent.style.opacity='1';setTimeout(function(){currentContent.style.transition=''},150)},120)}
1375
1455
  });
@@ -1785,7 +1865,7 @@ else {
1785
1865
  // ESM, and top-level 'import' is illegal inside a block, which
1786
1866
  // would throw SyntaxError before the script ever runs.
1787
1867
  var alreadyScoped=runsAlways(typeof entry==='string'?'':entry.run,text);
1788
- ns.textContent=(hasImport||alreadyScoped)?text:'{'+text+republishTopLevel(text)+'}';
1868
+ ns.textContent=markRan((hasImport||alreadyScoped)?text:'{'+text+republishTopLevel(text)+'}');
1789
1869
  ns.setAttribute('data-stx-page','');
1790
1870
  var placeholder=entry.slot?qs('script[data-stx-route-script="'+entry.slot+'"]'):null;
1791
1871
  if(placeholder&&placeholder.parentNode){
@@ -1819,7 +1899,9 @@ else {
1819
1899
  resolve(true);
1820
1900
  }catch(err){reject(err)}
1821
1901
  }
1822
- if(runViewTransition(completeSwap)){
1902
+ if(instantNav&&runInstantSwap(completeSwap)){
1903
+ }
1904
+ else if(runViewTransition(completeSwap)){
1823
1905
  }
1824
1906
  else if(instantNav){
1825
1907
  // A tab: swap at once, no fade either way.
@@ -2114,9 +2196,25 @@ else {
2114
2196
  return readPrefetchResponse(r,wantsFragment);
2115
2197
  }).then(function(result){
2116
2198
  if(result&&o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
2199
+ if(result)loadModuleRegistries(result.html,href);
2117
2200
  }).catch(function(){}).finally(function(){delete prefetching[key]});
2118
2201
  }
2119
2202
 
2203
+ // A prefetched page's module registry (the file its imports are bundled
2204
+ // into, one per page) is loaded with the prefetch, not on the tap. It only
2205
+ // registers modules, once, so loading it early changes nothing but timing,
2206
+ // and the tap then finds every script it needs already here: the swap runs
2207
+ // in one task and the first visit to a tab paints whole, like the second.
2208
+ function loadModuleRegistries(html,base){
2209
+ html.replace(new RegExp('<scr'+'ipt\\\\b([^>]*)>','gi'),function(m,attrs){
2210
+ if(!/(?:^|\\s)data-stx-modules(?:[\\s=]|$)/i.test(attrs))return m;
2211
+ var srcMatch=attrs.match(/\\bsrc\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
2212
+ var src=srcMatch&&(srcMatch[1]||srcMatch[2]||srcMatch[3]);
2213
+ if(src)loadExternalScript(src,base);
2214
+ return m;
2215
+ });
2216
+ }
2217
+
2120
2218
  // Links marked data-stx-prefetch="eager", fetched once the page is idle, so
2121
2219
  // even their first tap is served from the cache: the way a phone's tab bar
2122
2220
  // switches instantly. Not on a connection that asked to save data.
@@ -2311,7 +2409,7 @@ else {
2311
2409
  // The container the router focuses after a navigation is not a
2312
2410
  // control, so it draws no focus ring: Safari drew one around the whole
2313
2411
  // page, a blue line along its bottom edge on every screen.
2314
- var css='[data-stx-route-focus]:focus{outline:none}.stx-navigating{cursor:wait}.stx-navigating a,.stx-navigating button{pointer-events:none}#stx-router-progress{position:fixed;top:0;left:0;right:0;height:'+ph+';background:'+pc+';box-shadow:0 0 8px '+pc+',0 0 4px '+pc+';transform:scaleX(0);transform-origin:left;transition:transform .18s ease-out,opacity .26s ease;opacity:0;pointer-events:none;z-index:999999}';
2412
+ var css='[data-stx-route-focus]:focus{outline:none}.stx-navigating{cursor:wait}.stx-navigating a,.stx-navigating button{pointer-events:none}#stx-router-progress{position:fixed;top:0;left:0;right:0;height:'+ph+';background:'+pc+';box-shadow:0 0 8px '+pc+',0 0 4px '+pc+';transform:scaleX(0);transform-origin:left;transition:transform .18s ease-out,opacity .26s ease;opacity:0;pointer-events:none;z-index:999999}html.stx-instant::view-transition-group(*),html.stx-instant::view-transition-old(*),html.stx-instant::view-transition-new(*){animation:none!important}';
2315
2413
  if(o.viewTransitions&&'startViewTransition' in document){
2316
2414
  var dur=(o.viewTransitionDuration||220)+'ms';
2317
2415
  var ease=o.viewTransitionEasing||'cubic-bezier(0.16, 1, 0.3, 1)';
package/dist/types.d.ts CHANGED
@@ -71,6 +71,7 @@ export declare interface RouteMiddlewareDefinition {
71
71
  }
72
72
  export declare interface NavigateToOptions {
73
73
  replace?: boolean
74
+ instant?: boolean
74
75
  external?: boolean
75
76
  redirectCode?: 301 | 302 | 303 | 307 | 308
76
77
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stx-router",
3
3
  "type": "module",
4
- "version": "0.2.385",
4
+ "version": "0.2.387",
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",