Menu
Public repository / fee5b6c2cde89fa6bf8dc51068af7336b56a88b8
AkurAI-Framework / site/frontend/boundaries.js
Latest change 4c854a168e94887cd3c9ba5ec2150c94c7bda235 - feat(batch8-c): add loading and error boundaries for SPA nav and async data by Ólafur Búi Ólafsson
// native ES module — no bundler, no dependencies.
// Loading and error boundaries for SPA navigation and async data fetching.
// Provides:
// 1. A top-of-page progress bar for SPA navigation
// 2. Error boundary UI for fetch failures with Retry action
// 3. A reusable withBoundary(el, asyncFn, {loading, error}) helper
//
// PROGRESSIVE ENHANCEMENT: if this script is absent, pages still work via
// server-side fallback. Errors from data fetches just become silent — the
// boundary helps surface them gracefully.
(function () {
"use strict";
// Guard: init once, even if the module is evaluated twice.
if (window.SpaNavBoundary) return;
// Bail on ancient browsers.
if (typeof fetch === "undefined" || typeof CustomEvent === "undefined") {
return;
}
var PREFIX = "[boundaries]";
// ── Progress bar (top-of-page) ────────────────────────────────────────
// Render a progress bar element; initially hidden with opacity 0.
function createProgressBar() {
var bar = document.createElement("div");
bar.className = "spa-nav-progress";
bar.setAttribute("aria-hidden", "true");
bar.style.cssText =
"position:fixed;top:0;left:0;height:3px;z-index:1000;" +
"background:linear-gradient(90deg,var(--accent),var(--accent-2));" +
"width:0%;opacity:0;transition:opacity 0.3s ease;" +
"box-shadow:0 0 10px rgba(91, 140, 255, 0.5);";
document.body.insertBefore(bar, document.body.firstChild);
return bar;
}
var progressBar = null;
var navInProgress = false;
function startNavProgress() {
if (!progressBar) progressBar = createProgressBar();
navInProgress = true;
progressBar.style.opacity = "1";
progressBar.style.width = "30%";
console.log(PREFIX + " navigation start");
}
function endNavProgress() {
if (!progressBar || !navInProgress) return;
navInProgress = false;
progressBar.style.width = "100%";
// Fade out and reset after a brief delay.
setTimeout(function () {
progressBar.style.opacity = "0";
progressBar.style.width = "0%";
}, 200);
console.log(PREFIX + " navigation end");
}
// ── SPA router hook ─────────────────────────────────────────────────────
// Listen to spa.js's link click interception. We dispatch our own
// spa:navigating event before the router handles it, so boundaries can
// show loading UI.
function onLinkClick(event) {
// Only intercept actual left-clicks on <a> elements.
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
var anchor = null;
var node = event.target;
while (node && node !== document) {
if (node.tagName === "A" && node.hasAttribute("href")) {
anchor = node;
break;
}
node = node.parentNode;
}
if (!anchor) return;
// Check if spa.js would handle this link (same-origin, no explicit opt-outs).
var href = anchor.getAttribute("href");
if (!href) return;
// Skip modified clicks, new-tab opens, etc.
if (event.defaultPrevented) return;
if (anchor.target && anchor.target !== "" && anchor.target !== "_self") return;
if (anchor.hasAttribute("download")) return;
if (anchor.hasAttribute("data-no-spa")) return;
var rel = (anchor.getAttribute("rel") || "").toLowerCase();
if (rel.split(/\s+/).indexOf("external") !== -1) return;
// Check same-origin.
var url;
try {
url = new URL(href, document.baseURI);
} catch (e) {
return;
}
if (url.origin !== window.location.origin) return;
if (url.protocol !== "http:" && url.protocol !== "https:") return;
// Check for hash-only / no-op links.
var current = window.location;
if (url.hash && url.pathname === current.pathname && url.search === current.search) return;
if (url.href === current.href) return;
// spa.js will handle this. Dispatch our event to trigger loading UI.
startNavProgress();
}
// ── Async data boundary helper ──────────────────────────────────────────
/**
* Wrap an async data fetch in a loading/error boundary.
*
* Usage:
* const el = document.getElementById("content");
* const loader = () => fetch("/api/data").then(r => r.text());
* withBoundary(el, loader, {
* loading: true, // show a skeleton/spinner while loading
* error: true // show error UI with Retry button
* });
*
* @param {HTMLElement} el - the container to render into
* @param {Function} asyncFn - async function that returns content (HTML string or element)
* @param {Object} opts - { loading, error }
*/
function withBoundary(el, asyncFn, opts) {
opts = opts || {};
var showLoading = opts.loading !== false;
var showError = opts.error !== false;
if (!el || typeof asyncFn !== "function") {
console.error(PREFIX + " withBoundary: el and asyncFn required");
return;
}
var isRetry = false; // Track if this is a retry to avoid infinite loops.
function showLoadingUI() {
if (!showLoading) return;
el.innerHTML =
'<div class="boundary-loading" style="padding:2rem;text-align:center;">' +
'<div class="spinner spinner-md" style="margin:0 auto;display:inline-block;" aria-busy="true"></div>' +
'<p class="muted small" style="margin-top:1rem;">Loading…</p>' +
"</div>";
el.setAttribute("aria-busy", "true");
console.log(PREFIX + " showing loading state");
}
function showErrorUI(error, retryFn) {
if (!showError) {
el.innerHTML =
'<div class="boundary-error" style="padding:2rem;text-align:center;">' +
"<p>An error occurred.</p>" +
"</div>";
return;
}
var errorMsg = error && error.message ? error.message : "Unknown error";
var html =
'<div class="boundary-error" style="padding:2rem;">' +
'<div class="banner banner-danger" style="margin-bottom:1.5rem;">' +
'<span class="banner-icon" aria-hidden="true">✕</span>' +
'<div class="banner-text">' +
"<strong>Failed to load</strong>" +
"<p style='margin:0.5rem 0 0;font-size:0.9rem;'>" +
errorMsg +
"</p>" +
"</div>" +
"</div>" +
'<div style="text-align:center;">' +
'<button class="btn btn-primary boundary-retry-btn">Retry</button>' +
"</div>" +
"</div>";
el.innerHTML = html;
el.removeAttribute("aria-busy");
var retryBtn = el.querySelector(".boundary-retry-btn");
if (retryBtn && typeof retryFn === "function") {
retryBtn.addEventListener("click", retryFn);
}
console.log(PREFIX + " showing error UI: " + errorMsg);
}
function render(content) {
el.removeAttribute("aria-busy");
if (typeof content === "string") {
el.innerHTML = content;
} else if (content instanceof HTMLElement) {
el.innerHTML = "";
el.appendChild(content);
} else {
el.textContent = String(content);
}
console.log(PREFIX + " content rendered");
}
function load() {
showLoadingUI();
Promise.resolve()
.then(function () {
return asyncFn();
})
.then(function (result) {
render(result);
isRetry = false;
})
.catch(function (err) {
console.error(PREFIX + " fetch error:", err);
if (!isRetry) {
isRetry = true;
showErrorUI(err, function retryClick() {
load(); // Re-run the boundary.
});
} else {
// Avoid infinite retry loops; just show error without button.
showErrorUI(err, null);
}
});
}
load();
}
// ── Init ────────────────────────────────────────────────────────────────
function init() {
// Hook into link clicks to trigger nav progress.
document.addEventListener("click", onLinkClick, true); // Capture phase
// Listen for spa:navigated to end progress bar.
document.addEventListener("spa:navigated", endNavProgress);
console.log(PREFIX + " initialized (SPA nav boundaries + withBoundary helper)");
}
// Guard on document.readyState to ensure DOM is ready.
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Export public API.
window.SpaNavBoundary = {
withBoundary: withBoundary,
startNavProgress: startNavProgress,
endNavProgress: endNavProgress
};
})();