Standardize history restore functions to use proper htmx swap functions (#3306)

* Improve history support and events

* Improve history event overrides

* Improve history support and events

* Improve history event overrides

* Update Documentation of new event changes

* Add event testing for updated events

* update event doco and rename to historyElt to be consistent

* Improve history support and events

* Improve history event overrides

* Update Documentation of new event changes

* Add event testing for updated events

* update event doco and rename to historyElt to be consistent

* Fix loc coverage test coverage

* Standardize history restore functions to use proper htmx swap functions

* Add test for hx-history-elt attribute

* Fix broken merge conflict resolution
This commit is contained in:
MichaelWest22 2025-06-17 10:53:57 +12:00 committed by GitHub
parent 3c1ac71573
commit 508e332544
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 278 additions and 37 deletions

View File

@ -1901,7 +1901,11 @@ var htmx = (function() {
} else {
let fragment = makeFragment(content)
settleInfo.title = fragment.title
settleInfo.title = swapOptions.title || fragment.title
if (swapOptions.historyRequest) {
// @ts-ignore fragment can be a parentNode Element
fragment = fragment.querySelector('[hx-history-elt],[data-hx-history-elt]') || fragment
}
// select-oob swaps
if (swapOptions.selectOOB) {
@ -3271,8 +3275,8 @@ var htmx = (function() {
*/
function loadHistoryFromServer(path) {
const request = new XMLHttpRequest()
const details = { path, xhr: request }
triggerEvent(getDocument().body, 'htmx:historyCacheMiss', details)
const swapSpec = { swapStyle: 'innerHTML', swapDelay: 0, settleDelay: 0 }
const details = { path, xhr: request, historyElt: getHistoryElement(), swapSpec }
request.open('GET', path, true)
if (htmx.config.historyRestoreAsHxRequest) {
request.setRequestHeader('HX-Request', 'true')
@ -3281,25 +3285,21 @@ var htmx = (function() {
request.setRequestHeader('HX-Current-URL', location.href)
request.onload = function() {
if (this.status >= 200 && this.status < 400) {
details.response = this.response
triggerEvent(getDocument().body, 'htmx:historyCacheMissLoad', details)
const fragment = makeFragment(this.response)
/** @type ParentNode */
const content = fragment.querySelector('[hx-history-elt],[data-hx-history-elt]') || fragment
const historyElement = getHistoryElement()
const settleInfo = makeSettleInfo(historyElement)
handleTitle(fragment.title)
handlePreservedElements(fragment)
swapInnerHTML(historyElement, content, settleInfo)
restorePreservedElements()
settleImmediately(settleInfo.tasks)
currentPathForHistory = path
triggerEvent(getDocument().body, 'htmx:historyRestore', { path, cacheMiss: true, serverResponse: this.response })
swap(details.historyElt, details.response, swapSpec, {
contextElement: details.historyElt,
historyRequest: true
})
currentPathForHistory = details.path
triggerEvent(getDocument().body, 'htmx:historyRestore', { path, cacheMiss: true, serverResponse: details.response })
} else {
triggerErrorEvent(getDocument().body, 'htmx:historyCacheMissLoadError', details)
}
}
request.send()
if (triggerEvent(getDocument().body, 'htmx:historyCacheMiss', details)) {
request.send() // only send request if event not prevented
}
}
/**
@ -3310,19 +3310,16 @@ var htmx = (function() {
path = path || location.pathname + location.search
const cached = getCachedHistory(path)
if (cached) {
const fragment = makeFragment(cached.content)
const historyElement = getHistoryElement()
const settleInfo = makeSettleInfo(historyElement)
handleTitle(cached.title)
handlePreservedElements(fragment)
swapInnerHTML(historyElement, fragment, settleInfo)
restorePreservedElements()
settleImmediately(settleInfo.tasks)
getWindow().setTimeout(function() {
window.scrollTo(0, cached.scroll)
}, 0) // next 'tick', so browser has time to render layout
currentPathForHistory = path
triggerEvent(getDocument().body, 'htmx:historyRestore', { path, item: cached })
const swapSpec = { swapStyle: 'innerHTML', swapDelay: 0, settleDelay: 0, scroll: cached.scroll }
const details = { path, item: cached, historyElt: getHistoryElement(), swapSpec }
if (triggerEvent(getDocument().body, 'htmx:historyCacheHit', details)) {
swap(details.historyElt, cached.content, swapSpec, {
contextElement: details.historyElt,
title: cached.title
})
currentPathForHistory = details.path
triggerEvent(getDocument().body, 'htmx:historyRestore', details)
}
} else {
if (htmx.config.refreshOnHistoryMiss) {
// @ts-ignore: optional parameter in reload() function throws error
@ -3837,6 +3834,11 @@ var htmx = (function() {
target = target || last
target.scrollTop = target.scrollHeight
}
if (typeof swapSpec.scroll === 'number') {
getWindow().setTimeout(function() {
window.scrollTo(0, /** @type number */ (swapSpec.scroll))
}, 0) // next 'tick', so browser has time to render layout
}
}
if (swapSpec.show) {
var target = null
@ -5121,6 +5123,8 @@ var htmx = (function() {
* @property {swapCallback} [afterSwapCallback]
* @property {swapCallback} [afterSettleCallback]
* @property {swapCallback} [beforeSwapCallback]
* @property {string} [title]
* @property {boolean} [historyRequest]
*/
/**
@ -5139,7 +5143,7 @@ var htmx = (function() {
* @property {boolean} [transition]
* @property {boolean} [ignoreTitle]
* @property {string} [head]
* @property {'top' | 'bottom'} [scroll]
* @property {'top' | 'bottom' | number } [scroll]
* @property {string} [scrollTarget]
* @property {string} [show]
* @property {string} [showTarget]
@ -5184,7 +5188,8 @@ var htmx = (function() {
* @property {'true'} [HX-History-Restore-Request]
*/
/** @typedef HtmxAjaxHelperContext
/**
* @typedef HtmxAjaxHelperContext
* @property {Element|string} [source]
* @property {Event} [event]
* @property {HtmxAjaxHandler} [handler]

View File

@ -0,0 +1,38 @@
describe('hx-history attribute', function() {
var HTMX_HISTORY_CACHE_NAME = 'htmx-history-cache'
beforeEach(function() {
this.server = makeServer()
clearWorkArea()
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
})
afterEach(function() {
this.server.restore()
clearWorkArea()
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
})
it('content of hx-history-elt is used during history replacment', function() {
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
byId('d1').click()
this.server.respond()
var workArea = getWorkArea()
workArea.textContent.should.equal('test1')
byId('d2').click()
this.server.respond()
workArea.textContent.should.equal('test2')
this.server.respondWith('GET', '/test1', '<div>content outside of hx-history-elt not included</div><div id="work-area" hx-history-elt><div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test3</div></div>')
// clear cache so it makes a full page request on history restore
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
htmx._('restoreHistory')('/test1')
this.server.respond()
getWorkArea().textContent.should.equal('test3')
})
})

View File

@ -1,4 +1,5 @@
describe('Core htmx Events', function() {
var HTMX_HISTORY_CACHE_NAME = 'htmx-history-cache'
beforeEach(function() {
this.server = makeServer()
clearWorkArea()
@ -739,6 +740,152 @@ describe('Core htmx Events', function() {
}
})
it('preventDefault() in htmx:historyCacheMiss stops the history request', function() {
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
var handler = htmx.on('htmx:historyCacheMiss', function(evt) {
evt.preventDefault()
})
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
try {
byId('d1').click()
this.server.respond()
var workArea = getWorkArea()
workArea.textContent.should.equal('test1')
byId('d2').click()
this.server.respond()
workArea.textContent.should.equal('test2')
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME) // clear cache
htmx._('restoreHistory')('/test1')
this.server.respond()
getWorkArea().textContent.should.equal('test2')
} finally {
htmx.off('htmx:historyCacheMiss', handler)
}
})
it('htmx:historyCacheMissLoad event can update history swap', function() {
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
var handler = htmx.on('htmx:historyCacheMissLoad', function(evt) {
evt.detail.historyElt = byId('hist-re-target')
evt.detail.swapSpec.swapStyle = 'outerHTML'
evt.detail.response = '<div id="hist-re-target">Updated<div>'
evt.detail.path = '/test3'
})
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
make('<div id="hist-re-target"></div>')
try {
byId('d1').click()
this.server.respond()
var workArea = getWorkArea()
workArea.textContent.should.equal('test1')
byId('d2').click()
this.server.respond()
workArea.textContent.should.equal('test2')
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME) // clear cache
htmx._('restoreHistory')('/test1')
this.server.respond()
getWorkArea().textContent.should.equal('test2Updated')
byId('hist-re-target').textContent.should.equal('Updated')
htmx._('currentPathForHistory').should.equal('/test3')
} finally {
htmx.off('htmx:historyCacheMissLoad', handler)
}
})
it('htmx:historyCacheMiss event can set custom request headers', function() {
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
var handler = htmx.on('htmx:historyCacheMiss', function(evt) {
evt.detail.xhr.setRequestHeader('CustomHeader', 'true')
})
this.server.respondWith('GET', '/test1', function(xhr) {
should.equal(xhr.requestHeaders.CustomHeader, 'true')
xhr.respond(200, {}, '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
})
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
try {
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME) // clear cache
htmx._('restoreHistory')('/test1')
this.server.respond()
getWorkArea().textContent.should.equal('test1')
} finally {
htmx.off('htmx:historyCacheMiss', handler)
}
})
it('preventDefault() in htmx:historyCacheHit stops the history action', function() {
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
var handler = htmx.on('htmx:historyCacheHit', function(evt) {
evt.preventDefault()
})
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
try {
byId('d1').click()
this.server.respond()
var workArea = getWorkArea()
workArea.textContent.should.equal('test1')
byId('d2').click()
this.server.respond()
workArea.textContent.should.equal('test2')
htmx._('restoreHistory')('/test1')
getWorkArea().textContent.should.equal('test2')
} finally {
htmx.off('htmx:historyCacheHit', handler)
}
})
it('htmx:historyCacheHit event can update history swap', function() {
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
var handler = htmx.on('htmx:historyCacheHit', function(evt) {
evt.detail.historyElt = byId('hist-re-target')
evt.detail.swapSpec.swapStyle = 'outerHTML'
evt.detail.item.content = '<div id="hist-re-target">Updated<div>'
evt.detail.path = '/test3'
})
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
make('<div id="hist-re-target"></div>')
try {
byId('d1').click()
this.server.respond()
var workArea = getWorkArea()
workArea.textContent.should.equal('test1')
byId('d2').click()
this.server.respond()
workArea.textContent.should.equal('test2')
htmx._('restoreHistory')('/test1')
this.server.respond()
getWorkArea().textContent.should.equal('test2Updated')
byId('hist-re-target').textContent.should.equal('Updated')
htmx._('currentPathForHistory').should.equal('/test3')
} finally {
htmx.off('htmx:historyCacheHit', handler)
}
})
it('htmx:targetError should include the hx-target value', function() {
var target = null
var handler = htmx.on('htmx:targetError', function(evt) {

View File

@ -158,6 +158,13 @@ describe('Core htmx internals Tests', function() {
htmx.off('htmx:restored', handler)
})
it('scroll position is restored from history restore', function() {
make('<div style="height: 1000px;" hx-get="/test" hx-trigger="restored">Not Called</div>')
window.scrollTo(0, 50)
window.onpopstate({ state: { htmx: true } })
parseInt(window.scrollY).should.equal(50)
})
it('calling onpopstate with no htmx state not true calls original popstate', function() {
window.onpopstate({ state: { htmx: false } })
})
@ -190,4 +197,16 @@ describe('Core htmx internals Tests', function() {
document.querySelector('meta[name="htmx-config"]').remove()
should.equal(htmx._('getMetaConfig')(), null)
})
it('internalAPI settleImmediately completes settle tasks', function() {
// settleImmediately is no longer used internally and may no longer be needed at all
// as swapping without settleing does not seem via internalAPI
const fragment = htmx._('makeFragment')('<div>Content</div>')
const historyElement = htmx._('getHistoryElement')()
const settleInfo = htmx._('makeSettleInfo')(historyElement)
htmx._('swapInnerHTML')(historyElement, fragment, settleInfo)
historyElement.firstChild.className.should.equal('htmx-added')
htmx._('settleImmediately')(settleInfo.tasks)
historyElement.firstChild.className.should.equal('')
})
})

View File

@ -86,6 +86,7 @@
<script src="attributes/hx-get.js"></script>
<script src="attributes/hx-headers.js"></script>
<script src="attributes/hx-history.js"></script>
<script src="attributes/hx-history-elt.js"></script>
<script src="attributes/hx-include.js"></script>
<script src="attributes/hx-indicator.js"></script>
<script src="attributes/hx-disabled-elt.js"></script>

View File

@ -262,16 +262,36 @@ This event is triggered when an attempt to save the cache to `localStorage` fail
* `detail.cause` - the `Exception` that was thrown when attempting to save history to `localStorage`
### Event - `htmx:historyCacheHit` {#htmx:historyCacheHit}
This event is triggered when a cache hit occurs when restoring history
You can prevent the history restoration via `preventDefault()` to allow alternative restore handling.
You can also override the details of the history restoration request in this event if required
##### Details
* `detail.historyElt` - the history element or body that will get replaced
* `detail.item.content` - the content of the cache that will be swapped in
* `detail.item.title` - the page title to update from the cache
* `detail.path` - the path and query of the page being restored
* `detial.swapSpec` - the swapSpec to be used containing the defatul swapStyle='innerHTML'
### Event - `htmx:historyCacheMiss` {#htmx:historyCacheMiss}
This event is triggered when a cache miss occurs when restoring history
You can prevent the history restoration via `preventDefault()` to allow alternative restore handling.
You can also modify the xhr request or other details before it makes the the request to restore history
##### Details
* `detail.historyElt` - the history element or body that will get replaced
* `detail.xhr` - the `XMLHttpRequest` that will retrieve the remote content for restoration
* `detail.path` - the path and query of the page being restored
* `detial.swapSpec` - the swapSpec to be used containing the defatul swapStyle='innerHTML'
### Event - `htmx:historyCacheMissError` {#htmx:historyCacheMissError}
### Event - `htmx:historyCacheMissLoadError` {#htmx:historyCacheMissLoadError}
This event is triggered when a cache miss occurs and a response has been retrieved from the server
for the content to restore, but the response is an error (e.g. `404`)
@ -286,10 +306,15 @@ for the content to restore, but the response is an error (e.g. `404`)
This event is triggered when a cache miss occurs and a response has been retrieved successfully from the server
for the content to restore
You can modify the details before it makes the swap to restore the history
##### Details
* `detail.historyElt` - the history element or body that will get replaced
* `detail.xhr` - the `XMLHttpRequest`
* `detail.path` - the path and query of the page being restored
* `detail.response` - the response text that will be swapped in
* `detial.swapSpec` - the swapSpec to be used containing the defatul swapStyle='innerHTML'
### Event - `htmx:historyRestore` {#htmx:historyRestore}
@ -298,15 +323,20 @@ This event is triggered when htmx handles a history restoration action
##### Details
* `detail.path` - the path and query of the page being restored
* `detail.cacheMiss` - set `true` if restore was a cache miss
* `detail.serverResponse` - with cache miss has the response text replaced
* `detail.item` - with cache hit the cache details that was restored
### Event - `htmx:beforeHistorySave` {#htmx:beforeHistorySave}
This event is triggered before the content is saved in the history api.
This event is triggered before the content is saved in the history cache.
You can modify the contents of the historyElt to remove 3rd party javascript changes so a clean copy of the content can be backed up to the history cache
##### Details
* `detail.path` - the path and query of the page being restored
* `detail.historyElt` - the history element being restored into
* `detail.path` - the path and query of the page being saved
* `detail.historyElt` - the history element about to be saved
### Event - `htmx:load` {#htmx:load}

View File

@ -146,8 +146,9 @@ All other attributes available in htmx.
| [`htmx:configRequest`](@/events.md#htmx:configRequest) | triggered before the request, allows you to customize parameters, headers
| [`htmx:confirm`](@/events.md#htmx:confirm) | triggered after a trigger occurs on an element, allows you to cancel (or delay) issuing the AJAX request
| [`htmx:historyCacheError`](@/events.md#htmx:historyCacheError) | triggered on an error during cache writing
| [`htmx:historyCacheHit`](@/events.md#htmx:historyCacheHit) | triggered on a cache hit in the history subsystem
| [`htmx:historyCacheMiss`](@/events.md#htmx:historyCacheMiss) | triggered on a cache miss in the history subsystem
| [`htmx:historyCacheMissError`](@/events.md#htmx:historyCacheMissError) | triggered on a unsuccessful remote retrieval
| [`htmx:historyCacheMissLoadError`](@/events.md#htmx:historyCacheMissLoadError) | triggered on a unsuccessful remote retrieval
| [`htmx:historyCacheMissLoad`](@/events.md#htmx:historyCacheMissLoad) | triggered on a successful remote retrieval
| [`htmx:historyRestore`](@/events.md#htmx:historyRestore) | triggered when htmx handles a history restoration action
| [`htmx:beforeHistorySave`](@/events.md#htmx:beforeHistorySave) | triggered before content is saved to the history cache