hx-head: a merge that removes a stylesheet another merge is waiting for leaves that swap hanging
When two responses merge the head at the same time, the later one can remove a <link rel="stylesheet"> that the earlier one appended and is still waiting for. A link removed while it loads fires neither load nor error, so appendNode's promise never settles, response.text() never resolves, and the earlier request never swaps: no htmx:after:request, no error, the page stays where it was.
The common way to hit it: a page polls itself with hx-select, its head says hx-head="merge", and the user follows a boosted link to a page whose stylesheet is not cached yet. The poll answers while that stylesheet loads and removes it.
Reproduction with htmx.org 4.0.0 (dist/htmx.js and dist/ext/hx-head.js next to the pages) in Chrome 154. node server.mjs . 3000 serves the folder and delays /slow.css by 3 seconds.
server.mjs
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
const [root, port] = process.argv.slice(2);
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" };
const delays = { "/slow.css": 3000 };
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
const delay = url.searchParams.has("slow") ? 2000 : (delays[url.pathname] ?? 0);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
const body = await readFile(join(root, url.pathname === "/" ? "index.html" : url.pathname));
res.writeHead(200, { "content-type": types[extname(url.pathname)] ?? "text/html", "cache-control": "no-store" });
res.end(body);
} catch {
res.writeHead(404).end();
}
}).listen(Number(port));index.html
<!doctype html>
<html>
<head hx-head="merge">
<script src="/htmx.js"></script>
<script src="/hx-head.js"></script>
<title>Home</title>
</head>
<body hx-boost:inherited="true">
<a href="/next.html">Next</a>
<div id="clock" hx-get="/" hx-trigger="every 1s" hx-select="#clock" hx-swap="outerHTML">polling</div>
</body>
</html>next.html
<!doctype html>
<html>
<head hx-head="merge">
<script src="/htmx.js"></script>
<script src="/hx-head.js"></script>
<link rel="stylesheet" href="/slow.css">
<title>Next</title>
</head>
<body>
<h1>Next page</h1>
</body>
</html>slow.css
h1 { color: green; }Open / and click Next.
Expected: the Next page after about 3 seconds. Actual: Home stays for good. Events, in ms since the page loaded:
1750 click Next
1752 htmx:before:request /next.html
3750 htmx:before:request /
3760 htmx:head:before:remove /slow.css
3760 htmx:after:request /
8750 still Home, no htmx:after:request for /next.html, no errorRemoving #clock before the click makes the same click show the Next page after 3.8 seconds, heading green.
appendNode awaits load or error for stylesheets and blocking scripts (#3686), and mergeHead removes every current head node that is not in the new head, including nodes another merge appended and is still awaiting. I have reproduced it only with hx-head="merge" on a partial swap; with the default append a partial swap removes nothing.
Two fixes, and I would do both:
- Cap the wait. Turbo resolves its stylesheet wait after 2 seconds at most (
waitForLoad(element, timeoutInMilliseconds = 2000), since hotwired/turbo#614), and Swup's head plugin after 3 (timeout: 3000). With a cap, no stylesheet, removed or never answering, can hold a swap for good. - Do not remove what a merge still awaits. Keep the nodes that any merge appended and still awaits in one set shared by all merges, skip them when another merge removes nodes, and drop them from the set on
loadorerror. The cap alone would show the page without its stylesheet; this keeps it.
The workaround we run until then covers the second:
const loading = new WeakSet();
const track = (node) => {
const external =
(node instanceof HTMLLinkElement && node.href !== "") ||
(node instanceof HTMLScriptElement && node.src !== "");
if (!external) return;
loading.add(node);
const settled = () => loading.delete(node);
node.addEventListener("load", settled, { once: true });
node.addEventListener("error", settled, { once: true });
};
new MutationObserver((records) => {
for (const record of records) record.addedNodes.forEach(track);
}).observe(document.head, { childList: true });
document.addEventListener("htmx:head:before:remove", (event) => {
if (loading.has(event.detail.headElement)) event.preventDefault();
});Edited: the first version suggested only the second fix; the cap is what Turbo and Swup do.
Source: bigskysoftware/htmx