AkurAI Build
Menu

Public repository / master

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
  };
})();