stx-router 0.2.249 → 0.2.251
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 +109 -3
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -380,10 +380,68 @@ export function getRouterScript() {
|
|
|
380
380
|
function fragmentMarker(declared){
|
|
381
381
|
return '<!--stx-fragment'+(declared==='true'?' rt=1':declared==='false'?' rt=0':'')+'-->';
|
|
382
382
|
}
|
|
383
|
+
// \u2500\u2500 External scripts inside the routed container \u2500\u2500
|
|
384
|
+
//
|
|
385
|
+
// A page whose x-data factory lives in its own file \u2014 a
|
|
386
|
+
// <script src="dashboard-xdata.js"> sitting inside [data-stx-content] \u2014 lost that
|
|
387
|
+
// file on every SPA navigation. Both swap paths dropped it: the fragment
|
|
388
|
+
// path's regex sees an empty body and returns '', and the full-document path
|
|
389
|
+
// removed it outright. The inline scripts beside it were re-executed with
|
|
390
|
+
// care; the external ones were deleted.
|
|
391
|
+
//
|
|
392
|
+
// The result was a screen that rendered on a cold load and came back empty
|
|
393
|
+
// after a click, reporting "dashboardXData is not defined" with every binding
|
|
394
|
+
// in the container left unevaluated \u2014 because nothing had defined it since
|
|
395
|
+
// the file was thrown away.
|
|
396
|
+
//
|
|
397
|
+
// Loaded once per URL, never re-run. What these files define is a global that
|
|
398
|
+
// outlives the navigation, so a second execution buys nothing and costs
|
|
399
|
+
// whatever side effects the file has: a mount helper firing again on every
|
|
400
|
+
// click through the same page is its own bug.
|
|
401
|
+
var loadedExternalScripts={};
|
|
402
|
+
// The base is resolved first because callers pass what they have: the
|
|
403
|
+
// fragment path knows only the path it navigated to ('/reports/disk'), and
|
|
404
|
+
// new URL(src, '/reports/disk') throws \u2014 a base must be absolute. Silently
|
|
405
|
+
// returning '' there meant the file was never requested, which is the bug
|
|
406
|
+
// this whole block exists to fix, reintroduced one level down.
|
|
407
|
+
function externalScriptKey(src,base){
|
|
408
|
+
try{
|
|
409
|
+
var absoluteBase=base?new URL(base,location.href).href:location.href;
|
|
410
|
+
return new URL(src,absoluteBase).href;
|
|
411
|
+
}catch(e){return ''}
|
|
412
|
+
}
|
|
413
|
+
function rememberExternalScript(src,base){
|
|
414
|
+
var key=externalScriptKey(src,base);
|
|
415
|
+
if(key)loadedExternalScripts[key]=1;
|
|
416
|
+
return key;
|
|
417
|
+
}
|
|
418
|
+
// Returns a promise, or null when there is nothing to wait for \u2014 an already
|
|
419
|
+
// loaded file, or an unparseable src. Resolves rather than rejects on error:
|
|
420
|
+
// one 404 must not stop the rest of the page hydrating, matching what the
|
|
421
|
+
// head-script path already does by sending failures to execScripts too.
|
|
422
|
+
function loadExternalScript(src,base){
|
|
423
|
+
var key=externalScriptKey(src,base);
|
|
424
|
+
if(!key||loadedExternalScripts[key])return null;
|
|
425
|
+
loadedExternalScripts[key]=1;
|
|
426
|
+
return new Promise(function(resolve){
|
|
427
|
+
var el=document.createElement('script');
|
|
428
|
+
el.src=key;
|
|
429
|
+
// Deliberately NOT data-stx-page: both swap paths clear those on every
|
|
430
|
+
// navigation, which would sweep this away and \u2014 since it is never loaded
|
|
431
|
+
// twice \u2014 leave the document without it. These are loaded once and stay,
|
|
432
|
+
// the same as the external head scripts beside them.
|
|
433
|
+
el.setAttribute('data-stx-external','');
|
|
434
|
+
el.onload=function(){resolve()};
|
|
435
|
+
el.onerror=function(){log('[router] container script failed:',key);resolve()};
|
|
436
|
+
document.head.appendChild(el);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
383
440
|
// Record scripts from the initial page load
|
|
384
441
|
document.querySelectorAll('script').forEach(function(s){
|
|
385
442
|
var text=s.textContent||'';
|
|
386
|
-
if(
|
|
443
|
+
if(s.hasAttribute('src'))rememberExternalScript(s.getAttribute('src'));
|
|
444
|
+
else if(text.trim())executedScriptHashes[hashScript(text)]=1;
|
|
387
445
|
});
|
|
388
446
|
function cacheKey(url){
|
|
389
447
|
// Base is the CURRENT document, not the origin root: a relative key like
|
|
@@ -687,6 +745,7 @@ else {
|
|
|
687
745
|
var fragStyles=[];
|
|
688
746
|
var fragCrosswindHrefs=[];
|
|
689
747
|
var fragCrosswindCSS=null;
|
|
748
|
+
var fragExternalScripts=[];
|
|
690
749
|
var cleanFrag=html.replace(new RegExp('<scr'+'ipt\\\\b([^>]*)>([\\\\s\\\\S]*?)<\\\\/scr'+'ipt>','gi'),function(m,attrs,code){
|
|
691
750
|
// A data block is not code, and everything below this line assumes it
|
|
692
751
|
// is: the body gets stashed in fragScripts, re-emitted as a pending
|
|
@@ -704,6 +763,12 @@ else {
|
|
|
704
763
|
var typeMatch=attrs.match(/\\btype\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
|
|
705
764
|
var scriptType=((typeMatch&&(typeMatch[1]||typeMatch[2]||typeMatch[3]))||'').trim().toLowerCase();
|
|
706
765
|
if(scriptType&&scriptType!=='text/javascript'&&scriptType!=='application/javascript'&&scriptType!=='module')return m;
|
|
766
|
+
// An external script has no body, so every check below reads it as
|
|
767
|
+
// nothing to run and the fragment loses it. Queue it instead; the
|
|
768
|
+
// file is where the page's factory usually lives.
|
|
769
|
+
var srcMatch=attrs.match(/\\bsrc\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
|
|
770
|
+
var fragSrc=srcMatch&&(srcMatch[1]||srcMatch[2]||srcMatch[3]);
|
|
771
|
+
if(fragSrc){fragExternalScripts.push(fragSrc);return ''}
|
|
707
772
|
if(code&&code.trim()&&!isSignalsRuntimeScript({hasAttribute:function(name){return name==='data-stx-runtime'&&attrs.indexOf('data-stx-runtime')!==-1}},code)){
|
|
708
773
|
var slot='fragment-'+(++fragScriptId);
|
|
709
774
|
var scoped=/(?:^|\\s)data-stx-scoped(?:\\s|=|$)/i.test(attrs);
|
|
@@ -791,6 +856,7 @@ else {
|
|
|
791
856
|
log('[router] frag scripts:', fragScripts.length);
|
|
792
857
|
document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
|
|
793
858
|
fragScripts.sort(function(a,b){return (a.setupName?1:0)-(b.setupName?1:0)});
|
|
859
|
+
function runFragScripts(){
|
|
794
860
|
fragScripts.forEach(function(entry){
|
|
795
861
|
var code=entry.text;
|
|
796
862
|
log('[router] exec script len:', code.length, 'has __stx_setup:', code.indexOf('__stx_setup')>-1);
|
|
@@ -859,6 +925,20 @@ else {
|
|
|
859
925
|
log('[router] scripts done. _latestSetup:', !!(window.stx&&window.stx._latestSetup));
|
|
860
926
|
// THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
|
|
861
927
|
window.dispatchEvent(new Event('stx:load'));
|
|
928
|
+
}
|
|
929
|
+
// The container's own files first, for the same reason as the
|
|
930
|
+
// full-document path: the inline scripts call into them. Only awaited
|
|
931
|
+
// when something actually has to load, so a fragment with no external
|
|
932
|
+
// scripts \u2014 every fragment before this change \u2014 still runs its inline
|
|
933
|
+
// scripts in the same synchronous turn and nothing about its timing
|
|
934
|
+
// moves.
|
|
935
|
+
var fragPending=[];
|
|
936
|
+
fragExternalScripts.forEach(function(src){
|
|
937
|
+
var pending=loadExternalScript(src,url);
|
|
938
|
+
if(pending)fragPending.push(pending);
|
|
939
|
+
});
|
|
940
|
+
if(fragPending.length)Promise.all(fragPending).then(runFragScripts).catch(runFragScripts);
|
|
941
|
+
else runFragScripts();
|
|
862
942
|
}
|
|
863
943
|
return new Promise(function(resolve,reject){
|
|
864
944
|
function completeFragSwap(){
|
|
@@ -1015,12 +1095,15 @@ else {
|
|
|
1015
1095
|
// scripts as placeholders so re-execution keeps document.currentScript
|
|
1016
1096
|
// beside the component template it owns.
|
|
1017
1097
|
var routedBodyScripts=[];
|
|
1098
|
+
var routedExternalScripts=[];
|
|
1018
1099
|
var routedBodyScriptId=0;
|
|
1019
1100
|
function prepareRoutedBodyScripts(root){
|
|
1020
1101
|
if(!root)return;
|
|
1021
1102
|
root.querySelectorAll('script').forEach(function(s){
|
|
1022
1103
|
var text=s.textContent||'';
|
|
1023
|
-
|
|
1104
|
+
// Not discarded: queued against docBase, so a relative src resolves
|
|
1105
|
+
// against the page being navigated to rather than the current URL.
|
|
1106
|
+
if(s.hasAttribute('src')){routedExternalScripts.push(s.getAttribute('src'));s.remove();return}
|
|
1024
1107
|
var type=(s.getAttribute('type')||'').trim().toLowerCase();
|
|
1025
1108
|
var executable=!type||type==='text/javascript'||type==='application/javascript'||type==='module';
|
|
1026
1109
|
if(!executable||!text.trim())return;
|
|
@@ -1084,6 +1167,14 @@ else {
|
|
|
1084
1167
|
}));
|
|
1085
1168
|
});
|
|
1086
1169
|
|
|
1170
|
+
// Before the inline scripts, and through the same barrier: an inline
|
|
1171
|
+
// script's whole reason for existing is often to call into one of these,
|
|
1172
|
+
// so running it first would fail exactly the way this bug did.
|
|
1173
|
+
routedExternalScripts.forEach(function(src){
|
|
1174
|
+
var pending=loadExternalScript(src,docBase);
|
|
1175
|
+
if(pending)extPromises.push(pending);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1087
1178
|
// \u2500\u2500 Script re-execution \u2500\u2500
|
|
1088
1179
|
// Remove previously injected page scripts
|
|
1089
1180
|
document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
|
|
@@ -1329,6 +1420,21 @@ else {
|
|
|
1329
1420
|
// mode where every page is part of the same SPA shell.
|
|
1330
1421
|
document.addEventListener('click',function(e){
|
|
1331
1422
|
if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0)return;
|
|
1423
|
+
// A handler on the page has already decided this click is not a
|
|
1424
|
+
// navigation, so the router must not perform one - the same contract the
|
|
1425
|
+
// browser honours for a plain anchor.
|
|
1426
|
+
//
|
|
1427
|
+
// This listener used to run in the CAPTURE phase, which broke that twice
|
|
1428
|
+
// over: it saw the click before the anchor did, so defaultPrevented was
|
|
1429
|
+
// necessarily false by the time it was read here, and the stopPropagation
|
|
1430
|
+
// below then stopped the event ever reaching the element - so a page's own
|
|
1431
|
+
// click handler on an intercepted link never ran AT ALL. Progressive
|
|
1432
|
+
// enhancement on an anchor was impossible; the only way out was to swap it
|
|
1433
|
+
// for a button in script (stacksjs/stacks#2393).
|
|
1434
|
+
//
|
|
1435
|
+
// Bubbling instead puts the page's handlers first, which is where a
|
|
1436
|
+
// decision about the page's own markup belongs.
|
|
1437
|
+
if(e.defaultPrevented)return;
|
|
1332
1438
|
if(!e.target||!e.target.closest)return;
|
|
1333
1439
|
var link=e.target.closest('[data-stx-link]');
|
|
1334
1440
|
// A marked link still has to clear the opt-out set. It used to skip it
|
|
@@ -1346,7 +1452,7 @@ else {
|
|
|
1346
1452
|
e.stopPropagation();
|
|
1347
1453
|
log('[router] navigating to:',href);
|
|
1348
1454
|
navigate(withCurrentLocale(href));
|
|
1349
|
-
}
|
|
1455
|
+
});
|
|
1350
1456
|
|
|
1351
1457
|
// \u2500\u2500 Form submission \u2500\u2500
|
|
1352
1458
|
// A submit IS a navigation, and the router only ever watched clicks. So any
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stx-router",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.251",
|
|
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",
|