stx-router 0.2.151 → 0.2.153

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 +95 -15
  2. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -21,6 +21,28 @@ export function getRouterScript() {
21
21
  var containerSel=o.container;
22
22
  var debug=!!o.debug;
23
23
  function log(){if(debug&&typeof console!=='undefined'&&console.log)console.log.apply(console,arguments)}
24
+
25
+ // \u2500\u2500 Build skew (stacksjs/stx#1772) \u2500\u2500
26
+ // The build that rendered THIS document, and therefore the build the runtime
27
+ // executing right now came from. Under bun --watch a save restarts the server,
28
+ // so the next navigation can fetch a fragment produced by a newer build; when
29
+ // the scoped-script or binding format drifted between the two, the old runtime
30
+ // cannot hydrate the new fragment \u2014 literal moustaches, dead bindings, stale
31
+ // canvas. Sporadic, and never on a clean boot.
32
+ //
33
+ // Conservative on purpose: act only when BOTH ids are known. A missing id is
34
+ // "no information", never "mismatch", so statically hosted output (no headers)
35
+ // and older servers behave exactly as before.
36
+ var buildMeta=document.querySelector('meta[name="stx-build"]');
37
+ var loadedBuild=buildMeta?(buildMeta.getAttribute('content')||''):'';
38
+ window.__stxBuild=loadedBuild;
39
+ function isBuildSkew(incoming){
40
+ return !!(loadedBuild&&incoming&&incoming!==loadedBuild);
41
+ }
42
+ function reloadForSkew(url,incoming){
43
+ log('[router] build skew: page is',loadedBuild,'server is',incoming,'\u2014 full navigation');
44
+ location.href=url;
45
+ }
24
46
  function runViewTransition(callback){
25
47
  if(!o.viewTransitions||!document.startViewTransition)return false;
26
48
  try{
@@ -216,6 +238,27 @@ export function getRouterScript() {
216
238
  // Layout-level scripts (theme, nav setup) execute on initial page load and
217
239
  // should NOT re-execute when navigating to another page with the same layout.
218
240
  var executedScriptHashes={};
241
+
242
+ // \u2500\u2500 Re-execution contract (stacksjs/stx#1773) \u2500\u2500
243
+ // Whether a generated script may run again after an SPA swap used to be
244
+ // SNIFFED from its source: does it start with '(' or ';', or mention
245
+ // window.stx.mount. That is a property of the emitted shape, not of intent,
246
+ // so every change to how scripts are wrapped was one edit away from silently
247
+ // reclassifying them \u2014 and the symptom of getting it wrong is a component
248
+ // that renders completely dead (literal moustaches, stuck :show) only on a
249
+ // REVISIT, because nav-away already disposed its scope.
250
+ //
251
+ // Emitters now declare it: data-stx-run="always" on self-contained scope
252
+ // IIFEs and mount() wrappers, which own their scope and whose registration
253
+ // _cleanupContainer removed on the way out. Anything unstamped falls back to
254
+ // the old sniff, so a page rendered by an older server behaves exactly as
255
+ // before.
256
+ function runsAlways(declared, code){
257
+ if(declared==='always')return true;
258
+ if(declared==='once')return false;
259
+ var head=code.trimStart();
260
+ return head.charAt(0)==='('||head.charAt(0)===';'||code.indexOf('window.stx.mount')>-1;
261
+ }
219
262
  function hashScript(code){
220
263
  var h=0;for(var i=0;i<code.length;i++){h=((h<<5)-h)+code.charCodeAt(i);h|=0}
221
264
  return h;
@@ -364,6 +407,10 @@ else {
364
407
  var wantsFragment=shouldUseFragmentResponse();
365
408
  return fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
366
409
  if(!r.ok)throw new Error(r.status);
410
+ // Before anything is parsed or swapped: a fragment from a different
411
+ // build must not be handed to this page's runtime (#1772).
412
+ var incomingBuild=r.headers.get('X-STX-Build')||'';
413
+ if(isBuildSkew(incomingBuild)){reloadForSkew(url,incomingBuild);return null}
367
414
  var isFragment=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
368
415
  var newLayout=r.headers.get('X-STX-Layout')||'';
369
416
  var newGroup=r.headers.get('X-STX-Layout-Group')||'';
@@ -433,12 +480,15 @@ else {
433
480
  if(code&&code.trim()&&!isSignalsRuntimeScript({hasAttribute:function(name){return name==='data-stx-runtime'&&attrs.indexOf('data-stx-runtime')!==-1}},code)){
434
481
  var slot='fragment-'+(++fragScriptId);
435
482
  var scoped=/(?:^|\\s)data-stx-scoped(?:\\s|=|$)/i.test(attrs);
436
- fragScripts.push({text:code,slot:slot,setupName:generatedSetupName(code),scoped:scoped});
483
+ var runDecl=(attrs.match(/data-stx-run\\s*=\\s*["']?(always|once)["']?/i)||[])[1];
484
+ fragScripts.push({text:code,slot:slot,setupName:generatedSetupName(code),scoped:scoped,run:runDecl?runDecl.toLowerCase():''});
437
485
  // Retain scoped setup code in its inert placeholder. A placeholder
438
486
  // inside template.content is unreachable through document, so the
439
487
  // repeated component runtime must execute it for each clone.
440
488
  return '<scr'+'ipt type="application/stx-pending" data-stx-route-script="'+slot+'"'
441
- +(scoped?' data-stx-scoped':'')+'>'+code+'<\\/scr'+'ipt>';
489
+ +(scoped?' data-stx-scoped':'')
490
+ +(runDecl?' data-stx-run="'+runDecl.toLowerCase()+'"':'')
491
+ +'>'+code+'<\\/scr'+'ipt>';
442
492
  }
443
493
  return '';
444
494
  });
@@ -541,17 +591,14 @@ else {
541
591
  // re-execute because each page has its own setup.
542
592
  var h=hashScript(code);
543
593
  var isSetup=code.indexOf('__stx_setup_')!==-1;
544
- // Compute isAlreadyScoped first so the dedup check can exempt
545
- // self-contained IIFE scripts. data-stx-scoped scope IIFEs
546
- // (the (function(){ ... })() shape) must re-execute on every
547
- // navigation. They own their own scope and won't collide
548
- // with prior runs, AND _cleanupContainer deleted their
549
- // entry from window.stx._scopes on the way out. Without
550
- // this exemption, navigating away and back leaves the page
551
- // leaf with no scope registered: bindIf/@event never re-bind
552
- // and all :if branches stay visible at once.
553
- // Mirrors the full-doc swap path's runAlways escape below.
554
- var isAlreadyScoped=code.trimStart().charAt(0)==='('||code.trimStart().charAt(0)===';'||code.indexOf('window.stx.mount')>-1;
594
+ // Self-contained scope scripts must re-execute on every navigation:
595
+ // they own their scope, won't collide with prior runs, and
596
+ // _cleanupContainer deleted their entry from window.stx._scopes on
597
+ // the way out. Skipping one leaves the page leaf with no scope
598
+ // registered \u2014 bindIf/@event never re-bind and every :if branch
599
+ // stays visible at once. Declared by the emitter via data-stx-run,
600
+ // sniffed only as a fallback (#1773).
601
+ var isAlreadyScoped=runsAlways(entry.run,code);
555
602
  if(!isSetup&&!isAlreadyScoped&&executedScriptHashes[h]){
556
603
  log('[router] skipping already-executed script (hash dedup)');
557
604
  return;
@@ -590,6 +637,11 @@ else {
590
637
  // Full document mode: parse with DOMParser and extract container content
591
638
  var parser=new DOMParser();
592
639
  var doc=parser.parseFromString(html,'text/html');
640
+ // Full documents carry the build id as a meta rather than a header, and
641
+ // this path also serves cached HTML, so check here too (#1772).
642
+ var docBuildMeta=doc.querySelector('meta[name="stx-build"]');
643
+ var docBuild=docBuildMeta?(docBuildMeta.getAttribute('content')||''):'';
644
+ if(isBuildSkew(docBuild)){reloadForSkew(url,docBuild);return Promise.resolve(false)}
593
645
  var newContent=doc.querySelector(containerSel)||doc.querySelector('[data-stx-content]')||doc.querySelector('main');
594
646
  if(!newContent){location.href=url;return Promise.resolve(false)}
595
647
 
@@ -703,7 +755,7 @@ else {
703
755
  var slot='document-'+(++routedBodyScriptId);
704
756
  var setupName=generatedSetupName(text);
705
757
  if(setupName)incomingSetupName=setupName;
706
- routedBodyScripts.push({text:text,runAlways:true,slot:slot,setupName:setupName});
758
+ routedBodyScripts.push({text:text,runAlways:true,slot:slot,setupName:setupName,run:s.getAttribute('data-stx-run')||''});
707
759
  s.textContent='';
708
760
  s.setAttribute('type','application/stx-pending');
709
761
  s.setAttribute('data-stx-route-script',slot);
@@ -831,6 +883,34 @@ else {
831
883
  document.documentElement.lang=doc.documentElement.lang;
832
884
  }
833
885
 
886
+ // \u2500\u2500 Reconcile <html> attributes (stacksjs/stx#1798) \u2500\u2500
887
+ // A layout that scopes its design tokens to the root element
888
+ // (html.marketing { --bg: \u2026 }) needs that class to LEAVE when you
889
+ // navigate to a layout that doesn't want it \u2014 otherwise both token sets
890
+ // match at once and the second layout paints with the first one's
891
+ // palette. Head stylesheets are additive across a swap, so the class is
892
+ // the only thing disambiguating them.
893
+ //
894
+ // Only what stx wrote is touched, per the markers emitted by
895
+ // document-shell.ts. Diffing the whole element against the incoming
896
+ // document would strip the color-mode boot's dark class and
897
+ // data-reduced-motion, which exist only on the live page.
898
+ if(doc.documentElement){
899
+ var curRoot=document.documentElement,incRoot=doc.documentElement;
900
+ var tokens=function(el,attr){var v=el.getAttribute(attr);return v?v.split(/\\s+/).filter(Boolean):[]};
901
+ var prevCls=tokens(curRoot,'data-stx-html-class'),nextCls=tokens(incRoot,'data-stx-html-class');
902
+ prevCls.forEach(function(c){if(nextCls.indexOf(c)===-1)curRoot.classList.remove(c)});
903
+ nextCls.forEach(function(c){curRoot.classList.add(c)});
904
+ if(nextCls.length)curRoot.setAttribute('data-stx-html-class',nextCls.join(' '));
905
+ else curRoot.removeAttribute('data-stx-html-class');
906
+
907
+ var prevNames=tokens(curRoot,'data-stx-html-attrs'),nextNames=tokens(incRoot,'data-stx-html-attrs');
908
+ prevNames.forEach(function(n){if(nextNames.indexOf(n)===-1)curRoot.removeAttribute(n)});
909
+ nextNames.forEach(function(n){var v=incRoot.getAttribute(n);if(v!==null)curRoot.setAttribute(n,v)});
910
+ if(nextNames.length)curRoot.setAttribute('data-stx-html-attrs',nextNames.join(' '));
911
+ else curRoot.removeAttribute('data-stx-html-attrs');
912
+ }
913
+
834
914
  window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
835
915
 
836
916
  // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
@@ -860,7 +940,7 @@ else {
860
940
  // Skip the wrap for module scripts \u2014 they get their own scope from
861
941
  // ESM, and top-level 'import' is illegal inside a block, which
862
942
  // would throw SyntaxError before the script ever runs.
863
- var alreadyScoped=text.trimStart().charAt(0)==='('||text.trimStart().charAt(0)===';'||text.indexOf('window.stx.mount')>-1;
943
+ var alreadyScoped=runsAlways(typeof entry==='string'?'':entry.run,text);
864
944
  ns.textContent=(hasImport||alreadyScoped)?text:'{'+text+'}';
865
945
  ns.setAttribute('data-stx-page','');
866
946
  var placeholder=entry.slot?document.querySelector('script[data-stx-route-script="'+entry.slot+'"]'):null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stx-router",
3
3
  "type": "module",
4
- "version": "0.2.151",
4
+ "version": "0.2.153",
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",