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:
2026-09-30 19:29:08 +02:00
co-authored by Claude Opus 5.5
parent fa90b3baee
commit aa90a04f91
17 changed files with 461 additions and 117 deletions
+11 -4
View File
@@ -200,13 +200,20 @@
// leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return;
probeStatus().then(function () {
updateFooterIcon();
wireFileLinks();
wireRevealButton();
wireFileRevealButtons();
wireFileLinks(); // delegated to document: once
applyToPage();
// Boosted navigation swaps in a new page without reloading this
// script: re-apply to its footer icon and reveal buttons.
document.addEventListener('page:swapped', applyToPage);
});
}
function applyToPage() {
updateFooterIcon();
wireRevealButton();
wireFileRevealButtons();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
+6
View File
@@ -2,6 +2,11 @@
// reloads the page with that chart's query parameter updated. Plain GET
// navigation — each view is distinct and bookmarkable, so no
// postReplace/history handling is needed.
// Boosted navigation re-runs this script on every visit; the listeners
// are delegated to document, so register them once.
if (!window.fitnessWired) {
window.fitnessWired = true;
document.addEventListener('change', function (e) {
var url = new URL(window.location.href);
var sel = e.target.closest('[data-fitness-range]');
@@ -72,3 +77,4 @@ document.addEventListener('pointermove', function (e) {
tip.style.left = left + 'px';
tip.style.top = top + 'px';
});
}
+115 -5
View File
@@ -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'));
});
})();
+61 -39
View File
@@ -17,10 +17,14 @@
<script src="{{asset "search-suggest.js"}}" defer></script>
<script src="{{asset "tree-picker.js"}}"></script>
<script src="{{asset "companion.js"}}" defer></script>
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
<script src="{{asset "page/actions.js"}}"></script>
<script src="{{asset "search/actions.js"}}"></script>{{end}}
</head>
<body>
{{/* Outside the editor, links to pages are boosted: htmx swaps .shell
(plus title and page actions, see "partial") instead of loading a new
document. hx.js decides which links count as pages. */}}
<body{{if not .EditMode}} hx-boost="true"{{end}}>
<header>
<nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
@@ -36,49 +40,67 @@
<span class="dropdown actions">
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
<div class="dropdown-menu align-right actions-menu">
<div class="actions-special">
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
</div>
{{template "headerActions" . }}
{{template "page-actions" .}}
</div>
</span>
{{end}}
</div>
</header>
<div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar col">
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
<script>
// Keep the rail's scroll position across page loads (before
// first paint, so it doesn't jump), then reveal the active row.
(function () {
var el = document.currentScript.previousElementSibling;
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
var active = el.querySelector(".tree-row.is-active");
if (active) active.scrollIntoView({ block: "nearest" });
addEventListener("pagehide", function () {
try { sessionStorage.setItem("tree-scroll", el.scrollTop); } catch (e) {}
});
})();
</script>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
</div>
<div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div>
{{block "extras" .}}{{end}}
<div id="modal-host"></div>
</body>
</html>
{{end}}
{{/* The page-specific header actions; the partial swaps them out-of-band
(the attribute is inert in a full page). */}}
{{define "page-actions"}}<div id="page-actions" class="page-actions" hx-swap-oob="true">
<div class="actions-special">
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
</div>
{{template "headerActions" .}}
</div>{{end}}
{{define "shell"}}
{{if not .EditMode}}<aside class="tree-sidebar col">
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
<script>
// Full page loads: keep the rail's scroll position (before first
// paint, so it doesn't jump), then reveal the active row. Boosted
// navigation keeps it itself (hx.js); this script re-runs then and
// bails out.
(function () {
if (document.readyState !== "loading") return;
var el = document.currentScript.previousElementSibling;
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
var active = el.querySelector(".tree-row.is-active");
if (active) active.scrollIntoView({ block: "nearest" });
addEventListener("pagehide", function () {
var cur = document.querySelector(".tree-scroll");
try { sessionStorage.setItem("tree-scroll", cur ? cur.scrollTop : 0); } catch (e) {}
});
})();
</script>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
{{end}}
{{/* Response to boosted navigation and history restores (hx.js swaps it into
.shell): the title, the page actions out-of-band, and the shell's content. */}}
{{define "partial"}}<title>{{.Title}}</title>
{{template "page-actions" .}}
{{template "shell" .}}{{end}}
+6 -2
View File
@@ -181,13 +181,17 @@
// is open (e.g. the merge step replacing the move dialog); focus returns
// to where it was before the first one opened.
var hostPrevFocus = null;
// History restores swap without event details; they never target the host.
function isHost(e) {
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
}
document.addEventListener('htmx:beforeSwap', function (e) {
if (e.detail.target.id !== 'modal-host') return;
if (!isHost(e)) return;
hostPrevFocus = current ? detach(true) : document.activeElement;
});
document.addEventListener('htmx:afterSwap', function (e) {
if (!isHost(e)) return;
var host = e.detail.target;
if (host.id !== 'modal-host') return;
var backdrop = host.querySelector('.modal-backdrop');
if (!backdrop) return;
show(backdrop, host, hostPrevFocus);
+20 -14
View File
@@ -1,15 +1,21 @@
document.addEventListener("DOMContentLoaded", function () {
var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return;
// Runs on DOMContentLoaded for a full page load, and right away when this
// script arrives with a boosted navigation (the document is loaded then).
(function () {
function run() {
var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return;
var item = document.createElement("button");
item.type = "button";
item.className = "btn btn-block";
item.title = "Contents";
item.textContent = "CONTENTS";
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside);
});
specials.appendChild(item);
});
var item = document.createElement("button");
item.type = "button";
item.className = "btn btn-block";
item.title = "Contents";
item.textContent = "CONTENTS";
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside);
});
specials.appendChild(item);
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
-2
View File
@@ -1,5 +1,3 @@
{{define "headScripts"}}<script src="{{asset "page/actions.js"}}"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
+33 -27
View File
@@ -1,32 +1,38 @@
document.addEventListener("DOMContentLoaded", function () {
var content = document.querySelector("main");
if (!content) return;
// Runs on DOMContentLoaded for a full page load, and right away when this
// script arrives with a boosted navigation (the document is loaded then).
(function () {
function run() {
var content = document.querySelector("main");
if (!content) return;
var headings = content.querySelectorAll("h2, h3, h4");
if (headings.length < 2) return;
var headings = content.querySelectorAll("h2, h3, h4");
if (headings.length < 2) return;
var nav = document.createElement("nav");
nav.className = "toc panel";
var nav = document.createElement("nav");
nav.className = "toc panel";
var header = document.createElement("div");
header.className = "panel-header";
header.textContent = "Contents";
nav.appendChild(header);
var header = document.createElement("div");
header.className = "panel-header";
header.textContent = "Contents";
nav.appendChild(header);
var list = document.createElement("ul");
headings.forEach(function (h) {
if (!h.id) return;
var li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase();
var a = document.createElement("a");
a.href = "#" + h.id;
var clone = h.cloneNode(true);
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim();
li.appendChild(a);
list.appendChild(li);
});
nav.appendChild(list);
var list = document.createElement("ul");
headings.forEach(function (h) {
if (!h.id) return;
var li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase();
var a = document.createElement("a");
a.href = "#" + h.id;
var clone = h.cloneNode(true);
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim();
li.appendChild(a);
list.appendChild(li);
});
nav.appendChild(list);
content.insertBefore(nav, content.firstChild);
});
content.insertBefore(nav, content.firstChild);
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
+10 -5
View File
@@ -45,17 +45,22 @@
}
// Reading mode: keep the latest scroll fraction recorded (throttled to one
// write per frame) so it is ready the moment the editor opens.
// write per frame) so it is ready the moment the editor opens. Boosted
// navigation replaces .center and changes the page without a reload, so
// listen on document and resolve both when writing.
var pending = false;
center.addEventListener('scroll', function () {
if (pending) return;
document.addEventListener('scroll', function (e) {
if (pending || !e.target.classList || !e.target.classList.contains('center')) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
center = document.querySelector('.center');
if (!center) return;
var m = maxScroll();
try {
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
sessionStorage.setItem('scrollsync:' + location.pathname,
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
} catch (e) {}
});
}, { passive: true });
}, { capture: true, passive: true });
})();
+21 -12
View File
@@ -52,19 +52,19 @@ function createSearchPage(name) {
});
}
// The "create page" link on results pages; delegated, since boosted
// navigation swaps the results in without a new DOMContentLoaded.
document.addEventListener('click', function (e) {
var createLink = e.target.closest && e.target.closest('[data-create-page]');
if (!createLink) return;
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
// Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM order,
// so the natural tab sequence is input → first result → next, …
document.addEventListener('DOMContentLoaded', function () {
var createLink = document.querySelector('[data-create-page]');
if (createLink) {
createLink.addEventListener('click', function (e) {
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
}
// Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM
// order, so the natural tab sequence is input → first result → next, …
var input = document.querySelector('.search-input');
if (input && input.value) {
input.focus();
@@ -72,3 +72,12 @@ document.addEventListener('DOMContentLoaded', function () {
try { input.setSelectionRange(end, end); } catch (e) {}
}
});
// The header (and its search box) survives boosted navigation: show the
// current page's query, as a full load would.
document.addEventListener('page:swapped', function () {
var input = document.querySelector('.search-input');
if (input && document.activeElement !== input) {
input.value = new URLSearchParams(location.search).get('q') || '';
}
});
-2
View File
@@ -1,5 +1,3 @@
{{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
{{define "searchQuery"}}{{.Query}}{{end}}
{{define "content"}}
+2
View File
@@ -425,6 +425,8 @@ main > h2 {
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.actions-special > .btn {text-align: left;}
/* Wrapper for the out-of-band swapped page actions; lays out as if absent. */
.page-actions { display: contents; }
/* Header actions carry an icon and a label: the mobile dropdown shows the
label, the inline desktop bar (see responsive block) the icon. */
.btn-icon { display: none; }