Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions src/ext/hx-head.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
if (headTag) {

let added = []
let toRemove = []
let removed = []
let preserved = []
let nodesToAppend = []
Expand All @@ -65,12 +66,15 @@
}

// put all new head elements into a Map, by their outerHTML
// title is excluded - core htmx handles document.title updates
let srcToNewHeadNodes = new Map()
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild)
if (newHeadChild.tagName !== "TITLE") {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild)
}
}

let newTitle = newHeadTag.querySelector("title")
let responseHasTitle = !!newHeadTag.querySelector("title")

// determine merge strategy
let mergeStrategy = api.attributeValue(newHeadTag, "hx-head") || defaultMergeStrategy
Expand All @@ -85,22 +89,23 @@
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt)
toRemove.push(currentHeadElt)
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML)
preserved.push(currentHeadElt)
}
} else {
if (mergeStrategy !== "append" || (currentHeadElt.tagName === "TITLE" && newTitle)) {
// if this is a merge, or a title being replaced, remove it
if (api.triggerHtmxEvent(document.body, "htmx:head:before:remove", {headElement: currentHeadElt}) !== false) {
removed.push(currentHeadElt)
}
let isTitle = currentHeadElt.tagName === "TITLE"
let shouldRemoveTitle = isTitle && (mergeStrategy !== "append" || (!responseHasTitle && htmx.config.head?.clearTitle))
if ((!isTitle && mergeStrategy !== "append") || shouldRemoveTitle) {
// in merge mode: remove non-title elements not in new content, and title if present
// in append mode: only remove title if response has no title and clearTitle is set
toRemove.push(currentHeadElt)
} else if (isReAppended) {
// append mode: only re-eval marked elements
removed.push(currentHeadElt)
toRemove.push(currentHeadElt)
nodesToAppend.push(currentHeadElt)
}
}
Expand Down Expand Up @@ -132,9 +137,10 @@

// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
for (const removedElement of toRemove) {
if (api.triggerHtmxEvent(document.body, "htmx:head:before:remove", {headElement: removedElement}) !== false) {
currentHead.removeChild(removedElement)
removed.push(removedElement)
}
}

Expand Down
151 changes: 151 additions & 0 deletions test/tests/ext/hx-head.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,157 @@ describe('hx-head extension', function() {
assert.isBelow(titles.length, titleCountBefore, 'title should be removed in merge mode when new head has no title');
});

it('fires htmx:head:before:remove exactly once per removed element', async function() {
let meta = document.createElement('meta');
meta.setAttribute('name', 'hx-head-test-4088');
meta.setAttribute('content', 'remove-me');
addToHead(meta);

let removeCount = 0;
let onRemove = (e) => { if (e.detail.headElement === meta) removeCount++; };
document.body.addEventListener('htmx:head:before:remove', onRemove);

mockResponse('GET', '/page', `<html><head hx-head="merge"></head><body><div>page</div></body></html>`);
let div = createProcessedHTML('<div hx-get="/page" hx-swap="innerHTML">click</div>');
div.click();
await afterMerge();

document.body.removeEventListener('htmx:head:before:remove', onRemove);

assert.equal(removeCount, 1, 'htmx:head:before:remove should fire exactly once per element');
});

it('fires htmx:head:before:remove exactly once for hx-preserve elements being removed', async function() {
let meta = document.createElement('meta');
meta.setAttribute('name', 'hx-head-test-4088-preserve');
meta.setAttribute('content', 'remove-me');
addToHead(meta);

let removeCount = 0;
let onRemove = (e) => { if (e.detail.headElement === meta) removeCount++; };
document.body.addEventListener('htmx:head:before:remove', onRemove);

// response has no matching meta, merge mode removes it
mockResponse('GET', '/page', `<html><head hx-head="merge"></head><body><div>page</div></body></html>`);
let div = createProcessedHTML('<div hx-get="/page" hx-swap="innerHTML">click</div>');
div.click();
await afterMerge();

document.body.removeEventListener('htmx:head:before:remove', onRemove);

assert.equal(removeCount, 1, 'htmx:head:before:remove should fire exactly once even with hx-preserve elements present');
});

it('cancelling htmx:head:before:remove prevents removal and excludes element from after:merge removed array', async function() {
let meta = document.createElement('meta');
meta.setAttribute('name', 'hx-head-test-4088-cancel');
meta.setAttribute('content', 'keep-me');
addToHead(meta);

let removedInEvent = null;
let onRemove = (e) => { e.preventDefault(); };
let onAfter = (e) => { removedInEvent = e.detail.removed; };
document.body.addEventListener('htmx:head:before:remove', onRemove);
document.body.addEventListener('htmx:head:after:merge', onAfter, {once: true});

mockResponse('GET', '/page', `<html><head hx-head="merge"></head><body><div>page</div></body></html>`);
let div = createProcessedHTML('<div hx-get="/page" hx-swap="innerHTML">click</div>');
div.click();
await afterMerge();

document.body.removeEventListener('htmx:head:before:remove', onRemove);

assert.isNotNull(document.head.querySelector('meta[name="hx-head-test-4088-cancel"]'), 'element should remain when removal is cancelled');
assert.isFalse(removedInEvent.includes(meta), 'cancelled element should not appear in after:merge removed array');
});

it('does not duplicate title when navigating back and forth in append mode', async function() {
let title = document.createElement('title');
title.textContent = 'Page 1';
addToHead(title);

mockResponse('GET', '/page2', headResponse('<title>Page 2</title>', '<div>page 2</div>'));
let div = createProcessedHTML('<div hx-get="/page2" hx-swap="innerHTML">click</div>');
div.click();
await afterMerge();

mockResponse('GET', '/page1', headResponse('<title>Page 1</title>', '<div>page 1</div>'));
let div2 = createProcessedHTML('<div hx-get="/page1" hx-swap="innerHTML">click</div>');
div2.click();
await afterMerge();

let titles = document.head.querySelectorAll('title');
assert.equal(titles.length, 1, 'should have exactly one title after round-trip navigation in append mode');
});

it('removes existing title in append mode when response has a title', async function() {
let title = document.createElement('title');
title.textContent = 'Old Title';
addToHead(title);

mockResponse('GET', '/page', headResponse('<title>New Title</title>', '<div>content</div>'));
let div = createProcessedHTML('<div hx-get="/page" hx-swap="innerHTML">click</div>');
div.click();
await afterMerge();

let titles = document.head.querySelectorAll('title');
assert.equal(titles.length, 1, 'should have exactly one title');
assert.equal(document.title, 'New Title', 'title should be updated');
});

it('preserves existing title in append mode when response has no title and clearTitle is not set', async function() {
let title = document.createElement('title');
title.textContent = 'Keep Me';
addToHead(title);

mockResponse('GET', '/page', headResponse('<meta name="hx-head-test-4070-notitle" content="yes">', '<div>content</div>'));
let div = createProcessedHTML('<div hx-get="/page" hx-swap="innerHTML">click</div>');
div.click();
await afterMerge();

let added = document.head.querySelector('meta[name="hx-head-test-4070-notitle"]');
if (added) addedHeadElts.push(added);

assert.equal(document.head.querySelectorAll('title').length, 1, 'title should be preserved');
assert.equal(document.title, 'Keep Me', 'title text should be unchanged');
});

it('does not add title element to head when ignoreTitle:true is set', async function() {
assert.equal(document.head.querySelectorAll('title').length, 0, 'no title should exist before the request');

mockResponse('GET', '/page', headResponse('<title>New Title</title><meta name="hx-head-test-ignoretitle" content="yes">', '<div>content</div>'));
let div = createProcessedHTML('<div hx-get="/page" hx-swap="innerHTML ignoreTitle:true">click</div>');
div.click();
await afterMerge();

let added = document.head.querySelector('meta[name="hx-head-test-ignoretitle"]');
if (added) addedHeadElts.push(added);

assert.equal(document.head.querySelectorAll('title').length, 0, 'hx-head should not add a title element when ignoreTitle is set');
});

it('removes title in append mode when response has no title and clearTitle is set', async function() {
let title = document.createElement('title');
title.textContent = 'Remove Me';
addToHead(title);

let origClearTitle = htmx.config.head?.clearTitle;
htmx.config.head = htmx.config.head || {};
htmx.config.head.clearTitle = true;

mockResponse('GET', '/page', headResponse('<meta name="hx-head-test-4070-cleartitle" content="yes">', '<div>content</div>'));
let div = createProcessedHTML('<div hx-get="/page" hx-swap="innerHTML">click</div>');
div.click();
await afterMerge();

htmx.config.head.clearTitle = origClearTitle;

let added = document.head.querySelector('meta[name="hx-head-test-4070-cleartitle"]');
if (added) addedHeadElts.push(added);

assert.equal(document.head.querySelectorAll('title').length, 0, 'title should be removed when clearTitle is set and response has no title');
});

it('adds stylesheets to head', async function() {
mockResponse('GET', '/page', headResponse('<link rel="stylesheet" href="/test-styles.css">', '<div>swapped content</div>'));

Expand Down
24 changes: 24 additions & 0 deletions www/src/content/extensions/04-hx-head.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,30 @@ document.addEventListener('htmx:head:after:merge', event => {
})
```

## Configuration

### `htmx.config.head.clearTitle`

By default, in append mode the extension preserves the existing `<title>` element when the response head has no title. Set `clearTitle` to remove it:

```js
htmx.config.head = { clearTitle: true }
```

Or via meta tag:

```html
<meta name="htmx-config" content='{ "head": { "clearTitle": true } }'>
```

| Response has title | Mode | `clearTitle` | Result |
|---|---|---|---|
| yes | `merge` | — | Title removed and replaced |
| yes | `append` | — | Title removed and replaced |
| no | `merge` | — | Title removed |
| no | `append` | `false` (default) | Title preserved |
| no | `append` | `true` | Title removed |

## Migration

### Beta to RC1
Expand Down
Loading