Boost page navigation with htmx
Links and GET forms to wiki pages swap the next page into .shell instead of loading a new document: the server answers boosted requests and history restores with a partial (title, page actions out-of-band, shell content). The header stays, browsers without cross-document view transitions (Firefox) no longer flash between pages, and the swap runs as a same-document view transition. Files, the editor, internal endpoints and POST forms still navigate normally. Back/Forward refetch pages (no snapshots that could predate a move or delete); the tree rail keeps its scroll position and the content column returns to where it was. Mutation redirects swap their result in the same way. Page scripts that assumed a fresh document now also run after a swap (toc, contents, companion, scroll-sync, search actions) or register once (fitness). The e2e suite now also runs in Firefox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+115
-5
@@ -6,25 +6,135 @@
|
||||
// so settle in the same tick.
|
||||
htmx.config.defaultSettleDelay = 0;
|
||||
|
||||
function boosting() {
|
||||
return document.body.getAttribute('hx-boost') === 'true';
|
||||
}
|
||||
|
||||
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
|
||||
// target into the current history entry rather than pushing a new one, so
|
||||
// Back skips the stale pre-mutation page. replaceState + reload rather than
|
||||
// location.replace: a target differing only by #fragment would otherwise
|
||||
// not re-fetch.
|
||||
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
|
||||
// navigation; elsewhere replaceState + reload (not location.replace: a
|
||||
// target differing only by #fragment would otherwise not re-fetch).
|
||||
htmx.location = {
|
||||
set href(target) {
|
||||
if (boosting()) {
|
||||
if (window.closeModal) closeModal();
|
||||
htmx.ajax('GET', target, {
|
||||
target: '.shell',
|
||||
swap: 'innerHTML transition:true',
|
||||
headers: { 'HX-Boosted': 'true' },
|
||||
replace: target
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(null, '', target);
|
||||
window.location.reload();
|
||||
},
|
||||
reload: function () { window.location.reload(); }
|
||||
};
|
||||
|
||||
// Failed requests answer with a plain-text reason; show it.
|
||||
// Failed requests answer with a plain-text reason; show it. A failed
|
||||
// boosted navigation falls back to loading the page normally instead.
|
||||
function fallBack(e) {
|
||||
if (!e.detail.boosted) return false;
|
||||
window.location.href = e.detail.pathInfo.finalRequestPath || e.detail.pathInfo.requestPath;
|
||||
return true;
|
||||
}
|
||||
document.addEventListener('htmx:responseError', function (e) {
|
||||
if (fallBack(e)) return;
|
||||
var xhr = e.detail.xhr;
|
||||
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
|
||||
});
|
||||
document.addEventListener('htmx:sendError', function () {
|
||||
document.addEventListener('htmx:sendError', function (e) {
|
||||
if (fallBack(e)) return;
|
||||
alert('Network error');
|
||||
});
|
||||
|
||||
// --- Boosted navigation ---------------------------------------------
|
||||
// Outside the editor, links and GET forms to wiki pages load the next page
|
||||
// into .shell instead of replacing the document: the server answers with
|
||||
// the "partial" (title, page actions out-of-band, shell content). The
|
||||
// header stays put, nothing flashes in browsers without cross-document
|
||||
// view transitions, and the swap runs as a same-document view transition.
|
||||
// Back/Forward refetch the partial (hx-history="false" on .shell: no
|
||||
// snapshots that could show a page from before a move or delete).
|
||||
htmx.config.scrollIntoViewOnBoost = false;
|
||||
|
||||
// Only wiki pages are boosted: same origin, a slash-terminated path, no
|
||||
// query except a search (?q=). Files, the editor (?edit), internal
|
||||
// endpoints (/_…) and POST forms navigate normally.
|
||||
function isPageURL(url) {
|
||||
if (url.origin !== location.origin) return false;
|
||||
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
|
||||
var ok = true;
|
||||
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
|
||||
return ok;
|
||||
}
|
||||
|
||||
// htmx decides per element whether it is boosted, right after this event.
|
||||
document.addEventListener('htmx:beforeProcessNode', function (e) {
|
||||
var elt = e.target;
|
||||
if (elt.hasAttribute('hx-boost')) return;
|
||||
var page = false;
|
||||
if (elt.tagName === 'A') {
|
||||
page = elt.hasAttribute('href') && !elt.hasAttribute('download') && isPageURL(new URL(elt.href));
|
||||
} else if (elt.tagName === 'FORM') {
|
||||
page = (elt.getAttribute('method') || 'get').toLowerCase() === 'get' && isPageURL(new URL(elt.action));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (!page) elt.setAttribute('hx-boost', 'false');
|
||||
});
|
||||
|
||||
function isShell(elt) {
|
||||
return elt && elt.classList && elt.classList.contains('shell');
|
||||
}
|
||||
|
||||
// Scroll positions: the tree rail keeps its position across navigation;
|
||||
// the content column (.center) starts at the top, or where it was when
|
||||
// coming back via Back/Forward.
|
||||
var treeTop = 0;
|
||||
var restoring = false;
|
||||
var centerTops = {};
|
||||
function pageKey() {
|
||||
return location.pathname + location.search;
|
||||
}
|
||||
function rememberTree() {
|
||||
var tree = document.querySelector('.tree-scroll');
|
||||
treeTop = tree ? tree.scrollTop : 0;
|
||||
}
|
||||
document.addEventListener('scroll', function (e) {
|
||||
var t = e.target;
|
||||
if (t.classList && t.classList.contains('center')) centerTops[pageKey()] = t.scrollTop;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
||||
if (e.detail.boosted) {
|
||||
e.detail.target = document.querySelector('.shell');
|
||||
e.detail.swapOverride = 'innerHTML transition:true';
|
||||
}
|
||||
if (isShell(e.detail.target)) rememberTree();
|
||||
});
|
||||
document.addEventListener('htmx:historyCacheMissLoad', function () {
|
||||
restoring = true;
|
||||
rememberTree();
|
||||
});
|
||||
|
||||
// After a new page is in .shell: restore scroll positions, then tell page
|
||||
// scripts (companion.js, …) to apply themselves to the new content.
|
||||
document.addEventListener('htmx:afterSettle', function (e) {
|
||||
if (!isShell(e.target)) return;
|
||||
var tree = document.querySelector('.tree-scroll');
|
||||
if (tree) {
|
||||
tree.scrollTop = treeTop;
|
||||
var active = tree.querySelector('.tree-row.is-active');
|
||||
if (active) active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
if (restoring) {
|
||||
restoring = false;
|
||||
var center = document.querySelector('.center');
|
||||
if (center) center.scrollTop = centerTops[pageKey()] || 0;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('page:swapped'));
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user