release: WRNexusJS 0.2.31
This commit is contained in:
+375
-81
@@ -4,127 +4,421 @@
|
||||
* Progressive enhancement over normal links: intercepts same-origin `<a>`
|
||||
* clicks, fetches the target page's HTML, swaps the `#app` container in place,
|
||||
* updates history/title/scroll, ensures any framework runtimes the new page
|
||||
* needs are present, and re-hydrates. Anything unexpected (cross-origin,
|
||||
* modified click, non-HTML response, missing `#app`) falls back to a full
|
||||
* browser navigation, so behaviour degrades safely.
|
||||
* needs are present, and re-hydrates.
|
||||
*
|
||||
* Data "loaders": pages load their data on the server (SSR `api` bindings), so
|
||||
* the fetched HTML already contains fresh data — no separate client loader is
|
||||
* needed. Client-side (`csr`) bindings and reactive scopes re-hydrate after the
|
||||
* swap. Programmatic navigation is exposed as `window.__wrnexusNavigate(url)`.
|
||||
* Before replacing the current page, component lifecycle behaviors are
|
||||
* explicitly disposed. This ensures `unmount` hooks and watcher cleanups run
|
||||
* before the old DOM is removed.
|
||||
*
|
||||
* Programmatic navigation is exposed as:
|
||||
*
|
||||
* window.__wrnexusNavigate(url)
|
||||
*/
|
||||
|
||||
export const NAV_RUNTIME = String.raw`
|
||||
(function () {
|
||||
if (!window.history || !history.pushState || !window.fetch || !window.DOMParser) return;
|
||||
if (window.__wrnexusNavInstalled) return;
|
||||
if (
|
||||
!window.history ||
|
||||
!history.pushState ||
|
||||
!window.fetch ||
|
||||
!window.DOMParser
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__wrnexusNavInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__wrnexusNavInstalled = true;
|
||||
|
||||
var APP_ID = "app";
|
||||
var inFlight = null;
|
||||
|
||||
function pathOf(src) { return String(src).split("?")[0]; }
|
||||
function pathOf(src) {
|
||||
return String(src).split("?")[0];
|
||||
}
|
||||
|
||||
function isLocalLink(anchor) {
|
||||
if (
|
||||
!anchor ||
|
||||
anchor.hasAttribute("download") ||
|
||||
anchor.hasAttribute("data-no-nav")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (anchor.target && anchor.target !== "_self") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (anchor.origin !== location.origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var href = anchor.getAttribute("href");
|
||||
|
||||
if (!href || href.charAt(0) === "#") {
|
||||
return false;
|
||||
}
|
||||
|
||||
var rel = (
|
||||
anchor.getAttribute("rel") || ""
|
||||
).toLowerCase();
|
||||
|
||||
function isLocalLink(a) {
|
||||
if (!a || a.hasAttribute("download") || a.hasAttribute("data-no-nav")) return false;
|
||||
if (a.target && a.target !== "_self") return false;
|
||||
if (a.origin !== location.origin) return false;
|
||||
var href = a.getAttribute("href");
|
||||
if (!href || href.charAt(0) === "#") return false;
|
||||
var rel = (a.getAttribute("rel") || "").toLowerCase();
|
||||
return rel.indexOf("external") === -1;
|
||||
}
|
||||
|
||||
function loadedScriptPaths() {
|
||||
var set = {};
|
||||
document.querySelectorAll("script[src]").forEach(function (s) {
|
||||
var src = s.getAttribute("src");
|
||||
if (src) set[pathOf(src)] = true;
|
||||
});
|
||||
return set;
|
||||
var loaded = {};
|
||||
|
||||
document
|
||||
.querySelectorAll("script[src]")
|
||||
.forEach(function (script) {
|
||||
var src = script.getAttribute("src");
|
||||
|
||||
if (src) {
|
||||
loaded[pathOf(src)] = true;
|
||||
}
|
||||
});
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// Append any /__wrnexus/* runtime the incoming page declares but the current
|
||||
// document has not loaded yet. Fresh scripts self-initialise on load.
|
||||
/**
|
||||
* Append framework runtimes declared by the incoming document but not
|
||||
* currently loaded.
|
||||
*/
|
||||
function ensureScripts(doc) {
|
||||
var loaded = loadedScriptPaths();
|
||||
doc.querySelectorAll("script[src]").forEach(function (s) {
|
||||
var src = s.getAttribute("src");
|
||||
if (!src || loaded[pathOf(src)]) return;
|
||||
loaded[pathOf(src)] = true;
|
||||
var el = document.createElement("script");
|
||||
el.src = src;
|
||||
el.async = false; // preserve execution order (e.g. schemas.js before validate.js)
|
||||
document.body.appendChild(el);
|
||||
});
|
||||
|
||||
doc
|
||||
.querySelectorAll("script[src]")
|
||||
.forEach(function (script) {
|
||||
var src = script.getAttribute("src");
|
||||
|
||||
if (!src || loaded[pathOf(src)]) {
|
||||
return;
|
||||
}
|
||||
|
||||
loaded[pathOf(src)] = true;
|
||||
|
||||
var element =
|
||||
document.createElement("script");
|
||||
|
||||
element.src = src;
|
||||
element.async = false;
|
||||
|
||||
document.body.appendChild(element);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-hydrate already-loaded runtimes against the swapped-in DOM. Every
|
||||
// entrypoint is idempotent, so this is safe even when a fresh script also
|
||||
// self-initialises the same nodes.
|
||||
function rehydrate() {
|
||||
try { if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document); } catch (e) {}
|
||||
try { if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document); } catch (e) {}
|
||||
try { if (window.__wireValidate) window.__wireValidate.init(document); } catch (e) {}
|
||||
try { if (window.wireTheme) window.wireTheme.bind(document); } catch (e) {}
|
||||
/**
|
||||
* Run explicit component cleanup before removing the existing page.
|
||||
*
|
||||
* The reactive runtime also observes removed DOM nodes, but explicit
|
||||
* disposal here guarantees that unmount hooks run before replacement.
|
||||
*/
|
||||
function dispose(root) {
|
||||
try {
|
||||
if (
|
||||
typeof window.__wrnexusDisposeBehaviors ===
|
||||
"function"
|
||||
) {
|
||||
window.__wrnexusDisposeBehaviors(root);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] failed to dispose page behaviors",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-hydrate already-loaded runtimes against newly inserted DOM.
|
||||
*
|
||||
* All framework hydration functions must remain idempotent.
|
||||
*/
|
||||
function rehydrate(root) {
|
||||
try {
|
||||
if (
|
||||
typeof window.__wrnexusHydrateScopes ===
|
||||
"function"
|
||||
) {
|
||||
window.__wrnexusHydrateScopes(root);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] failed to hydrate reactive scopes",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof window.__wrnexusHydrateCsrFetches ===
|
||||
"function"
|
||||
) {
|
||||
window.__wrnexusHydrateCsrFetches(root);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] failed to hydrate CSR bindings",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
window.__wireValidate &&
|
||||
typeof window.__wireValidate.init ===
|
||||
"function"
|
||||
) {
|
||||
window.__wireValidate.init(document);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] failed to hydrate validation",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
window.wireTheme &&
|
||||
typeof window.wireTheme.bind ===
|
||||
"function"
|
||||
) {
|
||||
window.wireTheme.bind(document);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] failed to hydrate theme bindings",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchNavigationEvent(url) {
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("wrnexus:navigated", {
|
||||
detail: {
|
||||
url: url,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (_) {
|
||||
// CustomEvent may not be available in very old browsers.
|
||||
}
|
||||
}
|
||||
|
||||
function hardNavigate(url) {
|
||||
location.href = url;
|
||||
}
|
||||
|
||||
function render(html, url, isPop) {
|
||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||
var to = doc.getElementById(APP_ID);
|
||||
var from = document.getElementById(APP_ID);
|
||||
if (!to || !from) { location.href = url; return; } // structure mismatch → hard nav
|
||||
if (doc.title) document.title = doc.title;
|
||||
// Swap #app by importing nodes — NOT innerHTML — so it works under a strict
|
||||
// Trusted-Types CSP (require-trusted-types-for 'script').
|
||||
var imported = [];
|
||||
for (var i = 0; i < to.childNodes.length; i++) imported.push(document.importNode(to.childNodes[i], true));
|
||||
from.replaceChildren.apply(from, imported);
|
||||
ensureScripts(doc);
|
||||
rehydrate();
|
||||
if (!isPop) { history.pushState({ wrnexusNav: true }, "", url); window.scrollTo(0, 0); }
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:navigated", { detail: { url: url } }));
|
||||
} catch (e) {}
|
||||
}
|
||||
var doc;
|
||||
|
||||
var inFlight = null;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(
|
||||
html,
|
||||
"text/html",
|
||||
);
|
||||
} catch (_) {
|
||||
hardNavigate(url);
|
||||
return;
|
||||
}
|
||||
|
||||
var incomingApp =
|
||||
doc.getElementById(APP_ID);
|
||||
|
||||
var currentApp =
|
||||
document.getElementById(APP_ID);
|
||||
|
||||
if (!incomingApp || !currentApp) {
|
||||
hardNavigate(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (doc.title) {
|
||||
document.title = doc.title;
|
||||
}
|
||||
|
||||
var importedNodes = [];
|
||||
|
||||
for (
|
||||
var index = 0;
|
||||
index < incomingApp.childNodes.length;
|
||||
index++
|
||||
) {
|
||||
importedNodes.push(
|
||||
document.importNode(
|
||||
incomingApp.childNodes[index],
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Run unmount hooks and watcher cleanup while the current DOM is still
|
||||
* connected. This is important for components that remove window or
|
||||
* document listeners during lifecycle.unmount.
|
||||
*/
|
||||
dispose(currentApp);
|
||||
|
||||
try {
|
||||
/*
|
||||
* Avoid innerHTML so navigation works with strict Trusted Types and CSP
|
||||
* configurations.
|
||||
*/
|
||||
currentApp.replaceChildren.apply(
|
||||
currentApp,
|
||||
importedNodes,
|
||||
);
|
||||
} catch (_) {
|
||||
hardNavigate(url);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Load any runtime required only by the incoming page. Fresh scripts
|
||||
* initialise themselves when loaded.
|
||||
*/
|
||||
ensureScripts(doc);
|
||||
|
||||
/*
|
||||
* Hydrate runtimes that are already available. Hydration is scoped to
|
||||
* #app instead of the entire document for less DOM traversal.
|
||||
*/
|
||||
rehydrate(currentApp);
|
||||
|
||||
if (!isPop) {
|
||||
history.pushState(
|
||||
{
|
||||
wrnexusNav: true,
|
||||
},
|
||||
"",
|
||||
url,
|
||||
);
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
dispatchNavigationEvent(url);
|
||||
}
|
||||
|
||||
function navigate(url, isPop) {
|
||||
var token = {};
|
||||
|
||||
inFlight = token;
|
||||
fetch(url, { headers: { "x-wrnexus-nav": "1", accept: "text/html" }, credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (inFlight !== token) return null; // superseded by a newer navigation
|
||||
if (r.redirected && r.url) url = r.url; // follow server redirects (e.g. auth)
|
||||
var ct = r.headers.get("content-type") || "";
|
||||
if (ct.indexOf("text/html") === -1) { location.href = url; return null; }
|
||||
return r.text().then(function (t) {
|
||||
if (inFlight === token) render(t, url, isPop);
|
||||
|
||||
fetch(url, {
|
||||
headers: {
|
||||
"x-wrnexus-nav": "1",
|
||||
accept: "text/html",
|
||||
},
|
||||
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (response) {
|
||||
if (inFlight !== token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.redirected && response.url) {
|
||||
url = response.url;
|
||||
}
|
||||
|
||||
var contentType =
|
||||
response.headers.get("content-type") ||
|
||||
"";
|
||||
|
||||
if (
|
||||
contentType.indexOf("text/html") === -1
|
||||
) {
|
||||
hardNavigate(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentType =
|
||||
response.headers.get("content-type") ||
|
||||
"";
|
||||
|
||||
if (
|
||||
contentType.indexOf("text/html") === -1
|
||||
) {
|
||||
hardNavigate(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.text().then(function (text) {
|
||||
if (inFlight !== token) {
|
||||
return;
|
||||
}
|
||||
|
||||
render(text, url, isPop);
|
||||
});
|
||||
})
|
||||
.catch(function () { location.href = url; });
|
||||
.catch(function () {
|
||||
if (inFlight === token) {
|
||||
hardNavigate(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var a = e.target && e.target.closest ? e.target.closest("a") : null;
|
||||
if (!isLocalLink(a)) return;
|
||||
if (a.href === location.href) { e.preventDefault(); return; }
|
||||
e.preventDefault();
|
||||
navigate(a.href, false);
|
||||
function (event) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
var anchor =
|
||||
event.target &&
|
||||
event.target.closest
|
||||
? event.target.closest("a")
|
||||
: null;
|
||||
|
||||
if (!isLocalLink(anchor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.href === location.href) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
navigate(anchor.href, false);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
window.addEventListener("popstate", function () {
|
||||
navigate(location.href, true);
|
||||
});
|
||||
window.addEventListener(
|
||||
"popstate",
|
||||
function () {
|
||||
navigate(location.href, true);
|
||||
},
|
||||
);
|
||||
|
||||
// Programmatic navigation for forms/actions and app code.
|
||||
/**
|
||||
* Programmatic navigation for forms, actions and application code.
|
||||
*/
|
||||
window.__wrnexusNavigate = function (url) {
|
||||
navigate(new URL(url, location.href).href, false);
|
||||
navigate(
|
||||
new URL(url, location.href).href,
|
||||
false,
|
||||
);
|
||||
};
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
Reference in New Issue
Block a user