Skip to content

improve hx-head title and event handling - #4092

Open
MichaelWest22 wants to merge 7 commits into
bigskysoftware:four-devfrom
MichaelWest22:hx-head-append2
Open

MichaelWest22 wants to merge 7 commits into
bigskysoftware:four-devfrom
MichaelWest22:hx-head-append2

Conversation

@MichaelWest22

@MichaelWest22 MichaelWest22 commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Title is a singleton and does not need the same append behavior like the other head attributes.
hx-head extension can right now append duplicate titles so instead of trying to handle title in hx-head we can just skip over title handling and allow core htmx title handling to happen naturally. The only exception is we still need to handle removing of title in merge mode that core does not do. This also lets ignoreTitle swap modifier work if used naturally.

Note that ignoreTitle impacts if it should not update the title in swap but it does not impact hx-heads default behavior of removing missing head elements in full body merge mode but it is not a realistic need to use ignoreTitle to replace the whole body and head with merge head mode and expect ignoreTitle to preserve the old title when hx-head is trying to make the head match the new page state. So I have not tried to make ignoreTitle somehow preserve the existing title in head.

Also have added a new htmx.config.head.clearTitle config which defualts to false but if set true can control the way title is cleared. In full body merge mode we remove any head records like title if it was missing on the new page. During append style partial page updates the existing title is not removed as you are only partially updating the head with additions. But there could be cases where you are doing partial page replacement with append and expect the new page to have no title so I've added this clearTitle config that if set make append mode clear the title if not set as well.

Also it was reported that hx-head fires duplicate events for remove which I have also resolved

Corresponding issue:
#4070
#4088

Testing

Added tests

Checklist

  • I have read the contribution guidelines
  • I have targeted this PR against the correct branch (master for website changes, dev for
    source changes)
  • This is either a bugfix, a documentation update, or a new feature that has been explicitly
    approved via an issue
  • I ran the test suite locally (npm run test) and verified that it succeeded

@scrhartley

This comment was marked as resolved.

@scrhartley

This comment was marked as resolved.

@MichaelWest22

Copy link
Copy Markdown
Collaborator Author

updated those

@scrhartley

scrhartley commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

I've added one last comment to issue #4070, so you can decide if you want to handle it in this PR or not.

I've created #4095 - Issues with head extension defer behavior for defer issues I've found.

@MichaelWest22

Copy link
Copy Markdown
Collaborator Author

added module defer handling as this seems like a minor change. Had to do manual testing for this as no way to do automated testing for this change

@mhalikosen

mhalikosen commented Sep 24, 2026 •

Copy link
Copy Markdown

Which order should hx-head guarantee for module scripts that arrive with new content? With this PR they run after htmx:after:process, so an htmx.onLoad callback they register misses the content they came with. On 4.0.0 they run before the swap, so code that queries the DOM at the module's top level misses it (#4095).

The docs point the other way on both counts. The hx-head page says "<script> runs before swap; <script defer> runs after", and a module script without defer is a <script>. The main docs call htmx.onLoad() "the cleanest way" to initialise content htmx inserts.

Minimal repro, three static files served from one folder. htmx.js is 4.0.0; hx-head.js is either 4.0.0 or this PR's src/ext/hx-head.js.

index.html

<!doctype html>
<html>
<head>
  <script src="htmx.js"></script>
  <script src="hx-head.js"></script>
</head>
<body>
  <button hx-get="results.html" hx-target="#results" hx-select="#results" hx-swap="outerHTML">Load rows</button>
  <div id="results">No rows yet.</div>
</body>
</html>

results.html, the response, which brings the module its content needs:

<!doctype html>
<html>
<head>
  <script type="module" src="row.js"></script>
</head>
<body>
  <div id="results">
    <button class="row-button">Click me</button>
  </div>
</body>
</html>

row.js

htmx.onLoad((root) => {
  for (const button of root.querySelectorAll(".row-button")) {
    button.addEventListener("click", () => {
      button.textContent = "Clicked";
    });
  }
});

Click "Load rows", then "Click me". Click "Load rows" again, then "Click me". Results in Chrome:

row.js hx-head 4.0.0 hx-head from this PR
htmx.onLoad(...) as above works on both loads first load dead, second works
document.querySelectorAll(".row-button") at top level dead on both loads first load works, second dead
htmx.onLoad(...) followed by htmx.process(document.body) works on both loads works on both loads

A module runs once per page, so the top-level style can only ever cover the first load. htmx.onLoad is the style that covers every later swap, and this PR breaks it for the content the module arrives with.

A full page load runs both styles correctly: module scripts run after parsing and before DOMContentLoaded, where htmx runs its first process(). hx-head cannot reproduce that order today, because insertContent calls process() right after settle and settle tasks are not awaited.

The options I see: a core step between insertion and process() that hx-head can await; keeping this PR, changing the hx-head docs to say module scripts run after the swap, and documenting htmx.onLoad(init); htmx.process(document.body) as the pattern for modules; or declaring one of the two init styles unsupported.

@mhalikosen

Copy link
Copy Markdown

Custom elements make the order irrelevant. customElements.define upgrades matching elements already in the DOM, and the browser calls connectedCallback for each one inserted later, so the module needs neither htmx.onLoad nor a top-level DOM query, and hx-head can run it before or after the swap.

The same repro with a custom element. results.html keeps its head; its body becomes:

<div id="results">
  <row-button><button>Click me</button></row-button>
</div>

row.js:

class RowButton extends HTMLElement {
  connectedCallback() {
    this.querySelector("button").addEventListener("click", clicked);
  }
}

function clicked(event) {
  event.currentTarget.textContent = "Clicked";
}

customElements.define("row-button", RowButton);
row.js hx-head 4.0.0 hx-head from this PR
custom element as above works on both loads works on both loads

It also works on a full page load of results.html. Naming this pattern in the hx-head docs would give modules shipped with swapped content one answer that holds whichever order hx-head settles on.

@MichaelWest22

Copy link
Copy Markdown
Collaborator Author

Yeah I kind of remember grappling with this issue when I first wrote this hx-head extension. There is no way to cover all situations 100% and i left model scripts in the awaited script set before then thinking it was safer. The issue is there is no way to load scripts later at the same timing as the browser does it on the first full page load and you can only do so much! scripts that hard code in a single search for content to enable on initial full page load near DomContentLoaded will only ever work for the initial page contents and are just by design not compatible with dynamically added content and never have been. It is kind of expected that things will break and for each 3rd party library you need to find how to tap into its initialization logic and trigger it again for newly added content.

For now I think its best to revert that last commit as this is probably a safer bet and while module scripts do kind of defer to after the page is parsed they actually apply before the page finishes fully loading.

web component custom element registrations do work really well with htmx and more people should learn to use them for their own custom behavior. If you keep to the light DOM only kind they are amazing. I personally think it is better to avoid the hx-head script management complexity and just define all the scripts you need up front for all the parts of a section of your project so that this consistent set can be loaded for all these pages and then partial replacements just work and you can do hard links to move to other project sections that need a very different set of script tags in all its pages. web components work great with this concept as they just work once you preload the script and swap in the custom tags.

@MichaelWest22 MichaelWest22 added the htmx 4 Issues specific to htmx version 4 label Sep 24, 2026
@mhalikosen

Copy link
Copy Markdown

@MichaelWest22 thanks for looking into this and for the revert.

For context, my stack is Hono + JSX + htmx 4, and I'm building a setup where each component's JS arrives as its own module, the way CSS modules work in current frameworks: the server renders the component's tag, and the <head> carries only the CSS and JS of the tags on the page. hx-head then brings in the modules of components that a later swap adds. I don't put all client scripts into one file because it grows with every component and slows the first load.

That split is what creates the problem. A component that was not on the first page can arrive later through htmx, so its script has to initialise both the content present at load and anything swapped in afterwards, which means htmx.onLoad. Your revert solves it (the module runs before the swap, so its onLoad sees the new content), and so do light-DOM custom elements (connectedCallback runs for every inserted element, whatever the order).

My own case is already solved with custom elements, so I have no stake in which order wins, and I don't want the PR to settle on one because of my comment alone. Go with whatever you consider the right contract for hx-head: running a module that arrives with new content before the swap, as the revert does, or after the content is inserted and before process() fires htmx:after:process for it. Anyone else loading scripts through hx-head: which of the two does your code depend on?

@scrhartley

Copy link
Copy Markdown
Contributor

Following the revert, I've adjusted #4095 and it now focuses on htmx:head:before:add not firing for defer scripts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

htmx 4 Issues specific to htmx version 4

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants