Skip to main content

VIEW_TRANSITIONS_JS

Constant VIEW_TRANSITIONS_JS 

Source
pub const VIEW_TRANSITIONS_JS: &str = r#"// SSG View Transitions client — issue #547
// Same-origin nav interception + lazy hydration coordination.
(() => {
  const NS = 'ssg-transitions';
  if (window[NS]) return; // idempotent
  window[NS] = true;

  const supportsVT = typeof document.startViewTransition === 'function';

  // --- Same-origin click interception -----------------------------------
  function shouldIntercept(ev, link) {
    if (ev.defaultPrevented) return false;
    if (ev.button !== 0) return false;
    if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return false;
    if (!link || !link.href) return false;
    if (link.target && link.target !== '_self') return false;
    if (link.hasAttribute('download')) return false;
    if (link.dataset && link.dataset.noTransition !== undefined) return false;
    const url = new URL(link.href, location.href);
    if (url.origin !== location.origin) return false; // AC3
    if (url.pathname === location.pathname && url.search === location.search) {
      return false; // pure-hash nav — let the browser handle it
    }
    return true;
  }

  async function fetchPage(url) {
    const res = await fetch(url, { credentials: 'same-origin' });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const text = await res.text();
    return new DOMParser().parseFromString(text, 'text/html');
  }

  function swap(doc) {
    // Swap <main> and update <title>. Header/footer stay in place
    // (they're named as persistent transition roots via CSS).
    const nextMain = doc.querySelector('main');
    const curMain = document.querySelector('main');
    if (nextMain && curMain) {
      // Tell outgoing islands to detach. Web Components' own
      // disconnectedCallback also runs after replaceWith, but firing
      // an explicit event lets userland clean up too (AC5).
      curMain.querySelectorAll('ssg-island').forEach((el) => {
        try { el.dispatchEvent(new CustomEvent('ssg:detach')); } catch (e) {}
        if (typeof el.detach === 'function') {
          try { el.detach(); } catch (e) {}
        }
      });
      curMain.replaceWith(nextMain);
    }
    if (doc.title) document.title = doc.title;

    // Re-fire DOMContentLoaded-equivalent so other listeners
    // (analytics, lazy-load shims) can rebind on the new page.
    document.dispatchEvent(
      new CustomEvent('ssg:after-swap', { detail: { url: location.href } })
    );
  }

  async function navigate(url, push) {
    try {
      const doc = await fetchPage(url);
      const run = () => swap(doc);
      if (supportsVT) {
        // startViewTransition returns a ViewTransition handle.
        // Awaiting `.finished` lets us catch animation errors.
        const t = document.startViewTransition(run);
        if (t && t.finished) {
          t.finished.catch(() => {}); // swallow user-aborted cancels
        }
      } else {
        run(); // AC2 — graceful fallback (no animation)
      }
      if (push) history.pushState({ ssgvt: 1 }, '', url);
      // Scroll to top for new navigations (mirrors browser default).
      window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
    } catch (err) {
      // Network / parse failure: fall back to a hard navigation so
      // the user still gets to the destination.
      location.href = url;
    }
  }

  document.addEventListener('click', (ev) => {
    const link = ev.target && ev.target.closest && ev.target.closest('a[href]');
    if (!shouldIntercept(ev, link)) return;
    ev.preventDefault();
    navigate(link.href, true);
  });

  window.addEventListener('popstate', (ev) => {
    // Only handle popstates we originated — avoid stealing native
    // anchor-only navigations the browser still owns.
    if (ev.state && ev.state.ssgvt) navigate(location.href, false);
  });

  // --- HMR coordination (dev only) --------------------------------------
  // The livereload client (src/server/livereload.rs) consults this
  // flag before calling location.reload(). When transitions are
  // enabled, structural reloads are wrapped in startViewTransition so
  // the cross-fade is smooth even for full reloads (AC7).
  window.__ssgTransitionsReload = function (reload) {
    if (supportsVT) {
      try {
        document.startViewTransition(() => reload());
        return;
      } catch (e) {}
    }
    reload();
  };
})();
"#;
Expand description

The injected client script. Kept ≤ 5 KB — verified by a unit test.

Intentionally hand-written (not minified) for readability and auditability. Browser support note: View Transitions API is chromium-stable since 111 and Safari-stable since 18 (2024-09). Firefox: in nightly behind a flag as of 2026-06 — falls back to full-page navigation gracefully (AC2).