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).