2.10x · htmx 2.0.10Two features, both reasonable, one collision#
This site has htmx’s hx-boost on the body: internal navigation swaps page content in place instead of doing full page loads, which keeps transitions smooth. Separately, the Blowfish theme is smart about heavy libraries: mermaid, KaTeX, and Chart.js bundles are only included on pages that use them, injected into the <head> of exactly those pages.
Each feature is sensible alone. Together they have a hole in the middle:
hx-boost replaces the body and updates the title; it does not merge the incoming head. So when you navigate from a page without diagrams to a page with them, the one script tag that would have loaded the mermaid bundle is in the part of the document that gets thrown away. My re-render code even had a guard, if (typeof mermaid !== 'undefined'), which is exactly the kind of guard that turns a loud failure into a silent one. It skipped, politely, every time.
Direct loads worked because the full document parses normally. Which is why months of my own testing missed it: I test pages by URL. Readers arrive by link.
The fix: steal the bundle URL from the response you already have#
The swapped-in page’s HTML passes through htmx’s hands, head included, before the body is extracted. The fix reads the bundle URL out of that response and injects the script once:
function ensureVendorThenRender(evt, srcPattern, isReady, render) {
if (isReady()) { render(); return; }
var xhr = evt && evt.detail && evt.detail.xhr;
var html = xhr && xhr.responseText;
var match = html && html.match(srcPattern);
if (!match || injectedVendors[match[0]]) return;
injectedVendors[match[0]] = true;
var s = document.createElement("script");
s.src = match[0];
s.onload = render;
document.head.appendChild(s);
}Called from the htmx:afterSettle handler with a pattern like /\/js\/mermaid\.bundle\.[^"']+\.js/, once per library. Fingerprinted filenames come along for free since the pattern matches whatever hash the current build produced.
Charts needed a second layer. The theme’s chart shortcode wraps its new Chart(...) call in a DOMContentLoaded listener, and after an htmx swap that event is ancient history; the listener registers and waits forever. So the chart path re-inserts each inline chart script with addEventListener temporarily patched to invoke DOMContentLoaded callbacks immediately, plus a Chart.getChart() check so nothing draws twice. It is not elegant. It is honest about the constraint: the shortcode’s markup is the theme’s, and the fix has to meet it where it is.
If you want the pattern’s shape as a takeaway: conditionally loaded assets and partial navigation are individually fine and jointly broken, and neither feature’s documentation will warn you about the other. Any site combining per-page bundles with hx-boost, Turbo, or SPA-style routing has this class of bug available to it.
The tests were their own debugging story#
I added three Playwright regression tests, each walking the failing path on purpose: land on a listing page without the library, click through to a post with it, assert the rendered artifact exists (pre.mermaid svg, .katex, a live Chart.getChart() instance). Two traps surfaced on the way, and they cost more time than the fix itself.
First, the clicks kept landing on the wrong page. Blowfish’s article cards stretch the post link across the whole card and stack tag links on top of it, and Playwright’s coordinate-based click aimed at the card’s center, which happened to be a tag chip. The katex test spent several runs waiting for math on /tags/georgia-tech/. The cure is locator.dispatchEvent('click'), which delivers the event to the exact anchor regardless of what floats above it, plus a waitForURL assertion so a wrong destination fails fast with a readable error instead of a timeout.
Second, my shell habit was eating the exit code. npx playwright test | tail reports tail’s status, which is always zero, and a failing suite sailed through my terminal looking green twice before I noticed. Redirect to a file and check the real exit code. CI was never fooled, because CI does not pipe; only my eyeballs were.
The affected pages, for the record, included every post in my ML math series and the diagram in the hooks post. All fixed by the same three functions, all guarded by the same three tests.
Lessons#
- Per-page asset loading plus body-swap navigation is a bug class, not a bug. If your head is conditional and your navigation is partial, budget for the collision.
- A
typeof x !== 'undefined'guard converts a crash you would notice into a skip you will not. Guards need a fallback path, not just an exit. - Test the click path, not only the URL path. Direct loads exercise the document pipeline; in-site navigation exercises a completely different one.
- In tests, dispatch events to the element you mean. Coordinate clicks trust the visual stack, and the visual stack lies on layered layouts.
- Never pipe a test runner’s output when you care about its exit code.
References#
- htmx: hx-boost attribute
- Chart.js: Chart.getChart API
- Playwright: dispatchEvent
- The fix and tests:
layouts/partials/extend-footer.htmlandtests/site-enhancements.spec.tsin this site’s repo
