Skip to content

Commit 508e332

Browse files
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
1 parent 3c1ac71 commit 508e332

7 files changed

Lines changed: 278 additions & 37 deletions

File tree

src/htmx.js

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,7 +1901,11 @@ var htmx = (function() {
19011901
} else {
19021902
let fragment = makeFragment(content)
19031903

1904-
settleInfo.title = fragment.title
1904+
settleInfo.title = swapOptions.title || fragment.title
1905+
if (swapOptions.historyRequest) {
1906+
// @ts-ignore fragment can be a parentNode Element
1907+
fragment = fragment.querySelector('[hx-history-elt],[data-hx-history-elt]') || fragment
1908+
}
19051909

19061910
// select-oob swaps
19071911
if (swapOptions.selectOOB) {
@@ -3271,8 +3275,8 @@ var htmx = (function() {
32713275
*/
32723276
function loadHistoryFromServer(path) {
32733277
const request = new XMLHttpRequest()
3274-
const details = { path, xhr: request }
3275-
triggerEvent(getDocument().body, 'htmx:historyCacheMiss', details)
3278+
const swapSpec = { swapStyle: 'innerHTML', swapDelay: 0, settleDelay: 0 }
3279+
const details = { path, xhr: request, historyElt: getHistoryElement(), swapSpec }
32763280
request.open('GET', path, true)
32773281
if (htmx.config.historyRestoreAsHxRequest) {
32783282
request.setRequestHeader('HX-Request', 'true')
@@ -3281,25 +3285,21 @@ var htmx = (function() {
32813285
request.setRequestHeader('HX-Current-URL', location.href)
32823286
request.onload = function() {
32833287
if (this.status >= 200 && this.status < 400) {
3288+
details.response = this.response
32843289
triggerEvent(getDocument().body, 'htmx:historyCacheMissLoad', details)
3285-
const fragment = makeFragment(this.response)
3286-
/** @type ParentNode */
3287-
const content = fragment.querySelector('[hx-history-elt],[data-hx-history-elt]') || fragment
3288-
const historyElement = getHistoryElement()
3289-
const settleInfo = makeSettleInfo(historyElement)
3290-
handleTitle(fragment.title)
3291-
3292-
handlePreservedElements(fragment)
3293-
swapInnerHTML(historyElement, content, settleInfo)
3294-
restorePreservedElements()
3295-
settleImmediately(settleInfo.tasks)
3296-
currentPathForHistory = path
3297-
triggerEvent(getDocument().body, 'htmx:historyRestore', { path, cacheMiss: true, serverResponse: this.response })
3290+
swap(details.historyElt, details.response, swapSpec, {
3291+
contextElement: details.historyElt,
3292+
historyRequest: true
3293+
})
3294+
currentPathForHistory = details.path
3295+
triggerEvent(getDocument().body, 'htmx:historyRestore', { path, cacheMiss: true, serverResponse: details.response })
32983296
} else {
32993297
triggerErrorEvent(getDocument().body, 'htmx:historyCacheMissLoadError', details)
33003298
}
33013299
}
3302-
request.send()
3300+
if (triggerEvent(getDocument().body, 'htmx:historyCacheMiss', details)) {
3301+
request.send() // only send request if event not prevented
3302+
}
33033303
}
33043304

33053305
/**
@@ -3310,19 +3310,16 @@ var htmx = (function() {
33103310
path = path || location.pathname + location.search
33113311
const cached = getCachedHistory(path)
33123312
if (cached) {
3313-
const fragment = makeFragment(cached.content)
3314-
const historyElement = getHistoryElement()
3315-
const settleInfo = makeSettleInfo(historyElement)
3316-
handleTitle(cached.title)
3317-
handlePreservedElements(fragment)
3318-
swapInnerHTML(historyElement, fragment, settleInfo)
3319-
restorePreservedElements()
3320-
settleImmediately(settleInfo.tasks)
3321-
getWindow().setTimeout(function() {
3322-
window.scrollTo(0, cached.scroll)
3323-
}, 0) // next 'tick', so browser has time to render layout
3324-
currentPathForHistory = path
3325-
triggerEvent(getDocument().body, 'htmx:historyRestore', { path, item: cached })
3313+
const swapSpec = { swapStyle: 'innerHTML', swapDelay: 0, settleDelay: 0, scroll: cached.scroll }
3314+
const details = { path, item: cached, historyElt: getHistoryElement(), swapSpec }
3315+
if (triggerEvent(getDocument().body, 'htmx:historyCacheHit', details)) {
3316+
swap(details.historyElt, cached.content, swapSpec, {
3317+
contextElement: details.historyElt,
3318+
title: cached.title
3319+
})
3320+
currentPathForHistory = details.path
3321+
triggerEvent(getDocument().body, 'htmx:historyRestore', details)
3322+
}
33263323
} else {
33273324
if (htmx.config.refreshOnHistoryMiss) {
33283325
// @ts-ignore: optional parameter in reload() function throws error
@@ -3837,6 +3834,11 @@ var htmx = (function() {
38373834
target = target || last
38383835
target.scrollTop = target.scrollHeight
38393836
}
3837+
if (typeof swapSpec.scroll === 'number') {
3838+
getWindow().setTimeout(function() {
3839+
window.scrollTo(0, /** @type number */ (swapSpec.scroll))
3840+
}, 0) // next 'tick', so browser has time to render layout
3841+
}
38403842
}
38413843
if (swapSpec.show) {
38423844
var target = null
@@ -5121,6 +5123,8 @@ var htmx = (function() {
51215123
* @property {swapCallback} [afterSwapCallback]
51225124
* @property {swapCallback} [afterSettleCallback]
51235125
* @property {swapCallback} [beforeSwapCallback]
5126+
* @property {string} [title]
5127+
* @property {boolean} [historyRequest]
51245128
*/
51255129

51265130
/**
@@ -5139,7 +5143,7 @@ var htmx = (function() {
51395143
* @property {boolean} [transition]
51405144
* @property {boolean} [ignoreTitle]
51415145
* @property {string} [head]
5142-
* @property {'top' | 'bottom'} [scroll]
5146+
* @property {'top' | 'bottom' | number } [scroll]
51435147
* @property {string} [scrollTarget]
51445148
* @property {string} [show]
51455149
* @property {string} [showTarget]
@@ -5184,7 +5188,8 @@ var htmx = (function() {
51845188
* @property {'true'} [HX-History-Restore-Request]
51855189
*/
51865190

5187-
/** @typedef HtmxAjaxHelperContext
5191+
/**
5192+
* @typedef HtmxAjaxHelperContext
51885193
* @property {Element|string} [source]
51895194
* @property {Event} [event]
51905195
* @property {HtmxAjaxHandler} [handler]

test/attributes/hx-history-elt.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
describe('hx-history attribute', function() {
2+
var HTMX_HISTORY_CACHE_NAME = 'htmx-history-cache'
3+
4+
beforeEach(function() {
5+
this.server = makeServer()
6+
clearWorkArea()
7+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
8+
})
9+
afterEach(function() {
10+
this.server.restore()
11+
clearWorkArea()
12+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
13+
})
14+
15+
it('content of hx-history-elt is used during history replacment', function() {
16+
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
17+
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
18+
19+
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
20+
21+
byId('d1').click()
22+
this.server.respond()
23+
var workArea = getWorkArea()
24+
workArea.textContent.should.equal('test1')
25+
26+
byId('d2').click()
27+
this.server.respond()
28+
workArea.textContent.should.equal('test2')
29+
30+
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>')
31+
// clear cache so it makes a full page request on history restore
32+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
33+
34+
htmx._('restoreHistory')('/test1')
35+
this.server.respond()
36+
getWorkArea().textContent.should.equal('test3')
37+
})
38+
})

test/core/events.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
describe('Core htmx Events', function() {
2+
var HTMX_HISTORY_CACHE_NAME = 'htmx-history-cache'
23
beforeEach(function() {
34
this.server = makeServer()
45
clearWorkArea()
@@ -739,6 +740,152 @@ describe('Core htmx Events', function() {
739740
}
740741
})
741742

743+
it('preventDefault() in htmx:historyCacheMiss stops the history request', function() {
744+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
745+
var handler = htmx.on('htmx:historyCacheMiss', function(evt) {
746+
evt.preventDefault()
747+
})
748+
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
749+
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
750+
751+
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
752+
753+
try {
754+
byId('d1').click()
755+
this.server.respond()
756+
var workArea = getWorkArea()
757+
workArea.textContent.should.equal('test1')
758+
759+
byId('d2').click()
760+
this.server.respond()
761+
workArea.textContent.should.equal('test2')
762+
763+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME) // clear cache
764+
htmx._('restoreHistory')('/test1')
765+
this.server.respond()
766+
getWorkArea().textContent.should.equal('test2')
767+
} finally {
768+
htmx.off('htmx:historyCacheMiss', handler)
769+
}
770+
})
771+
772+
it('htmx:historyCacheMissLoad event can update history swap', function() {
773+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
774+
var handler = htmx.on('htmx:historyCacheMissLoad', function(evt) {
775+
evt.detail.historyElt = byId('hist-re-target')
776+
evt.detail.swapSpec.swapStyle = 'outerHTML'
777+
evt.detail.response = '<div id="hist-re-target">Updated<div>'
778+
evt.detail.path = '/test3'
779+
})
780+
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
781+
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
782+
783+
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
784+
make('<div id="hist-re-target"></div>')
785+
786+
try {
787+
byId('d1').click()
788+
this.server.respond()
789+
var workArea = getWorkArea()
790+
workArea.textContent.should.equal('test1')
791+
792+
byId('d2').click()
793+
this.server.respond()
794+
workArea.textContent.should.equal('test2')
795+
796+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME) // clear cache
797+
htmx._('restoreHistory')('/test1')
798+
this.server.respond()
799+
getWorkArea().textContent.should.equal('test2Updated')
800+
byId('hist-re-target').textContent.should.equal('Updated')
801+
htmx._('currentPathForHistory').should.equal('/test3')
802+
} finally {
803+
htmx.off('htmx:historyCacheMissLoad', handler)
804+
}
805+
})
806+
807+
it('htmx:historyCacheMiss event can set custom request headers', function() {
808+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
809+
var handler = htmx.on('htmx:historyCacheMiss', function(evt) {
810+
evt.detail.xhr.setRequestHeader('CustomHeader', 'true')
811+
})
812+
this.server.respondWith('GET', '/test1', function(xhr) {
813+
should.equal(xhr.requestHeaders.CustomHeader, 'true')
814+
xhr.respond(200, {}, '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
815+
})
816+
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
817+
818+
try {
819+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME) // clear cache
820+
htmx._('restoreHistory')('/test1')
821+
this.server.respond()
822+
getWorkArea().textContent.should.equal('test1')
823+
} finally {
824+
htmx.off('htmx:historyCacheMiss', handler)
825+
}
826+
})
827+
828+
it('preventDefault() in htmx:historyCacheHit stops the history action', function() {
829+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
830+
var handler = htmx.on('htmx:historyCacheHit', function(evt) {
831+
evt.preventDefault()
832+
})
833+
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
834+
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
835+
836+
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
837+
838+
try {
839+
byId('d1').click()
840+
this.server.respond()
841+
var workArea = getWorkArea()
842+
workArea.textContent.should.equal('test1')
843+
844+
byId('d2').click()
845+
this.server.respond()
846+
workArea.textContent.should.equal('test2')
847+
848+
htmx._('restoreHistory')('/test1')
849+
getWorkArea().textContent.should.equal('test2')
850+
} finally {
851+
htmx.off('htmx:historyCacheHit', handler)
852+
}
853+
})
854+
855+
it('htmx:historyCacheHit event can update history swap', function() {
856+
localStorage.removeItem(HTMX_HISTORY_CACHE_NAME)
857+
var handler = htmx.on('htmx:historyCacheHit', function(evt) {
858+
evt.detail.historyElt = byId('hist-re-target')
859+
evt.detail.swapSpec.swapStyle = 'outerHTML'
860+
evt.detail.item.content = '<div id="hist-re-target">Updated<div>'
861+
evt.detail.path = '/test3'
862+
})
863+
this.server.respondWith('GET', '/test1', '<div id="d2" hx-push-url="true" hx-get="/test2" hx-swap="outerHTML settle:0">test1</div>')
864+
this.server.respondWith('GET', '/test2', '<div id="d3" hx-push-url="true" hx-get="/test3" hx-swap="outerHTML settle:0">test2</div>')
865+
866+
make('<div id="d1" hx-push-url="true" hx-get="/test1" hx-swap="outerHTML settle:0">init</div>')
867+
make('<div id="hist-re-target"></div>')
868+
869+
try {
870+
byId('d1').click()
871+
this.server.respond()
872+
var workArea = getWorkArea()
873+
workArea.textContent.should.equal('test1')
874+
875+
byId('d2').click()
876+
this.server.respond()
877+
workArea.textContent.should.equal('test2')
878+
879+
htmx._('restoreHistory')('/test1')
880+
this.server.respond()
881+
getWorkArea().textContent.should.equal('test2Updated')
882+
byId('hist-re-target').textContent.should.equal('Updated')
883+
htmx._('currentPathForHistory').should.equal('/test3')
884+
} finally {
885+
htmx.off('htmx:historyCacheHit', handler)
886+
}
887+
})
888+
742889
it('htmx:targetError should include the hx-target value', function() {
743890
var target = null
744891
var handler = htmx.on('htmx:targetError', function(evt) {

test/core/internals.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ describe('Core htmx internals Tests', function() {
158158
htmx.off('htmx:restored', handler)
159159
})
160160

161+
it('scroll position is restored from history restore', function() {
162+
make('<div style="height: 1000px;" hx-get="/test" hx-trigger="restored">Not Called</div>')
163+
window.scrollTo(0, 50)
164+
window.onpopstate({ state: { htmx: true } })
165+
parseInt(window.scrollY).should.equal(50)
166+
})
167+
161168
it('calling onpopstate with no htmx state not true calls original popstate', function() {
162169
window.onpopstate({ state: { htmx: false } })
163170
})
@@ -190,4 +197,16 @@ describe('Core htmx internals Tests', function() {
190197
document.querySelector('meta[name="htmx-config"]').remove()
191198
should.equal(htmx._('getMetaConfig')(), null)
192199
})
200+
201+
it('internalAPI settleImmediately completes settle tasks', function() {
202+
// settleImmediately is no longer used internally and may no longer be needed at all
203+
// as swapping without settleing does not seem via internalAPI
204+
const fragment = htmx._('makeFragment')('<div>Content</div>')
205+
const historyElement = htmx._('getHistoryElement')()
206+
const settleInfo = htmx._('makeSettleInfo')(historyElement)
207+
htmx._('swapInnerHTML')(historyElement, fragment, settleInfo)
208+
historyElement.firstChild.className.should.equal('htmx-added')
209+
htmx._('settleImmediately')(settleInfo.tasks)
210+
historyElement.firstChild.className.should.equal('')
211+
})
193212
})

test/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ <h2>Mocha Test Suite</h2>
8686
<script src="attributes/hx-get.js"></script>
8787
<script src="attributes/hx-headers.js"></script>
8888
<script src="attributes/hx-history.js"></script>
89+
<script src="attributes/hx-history-elt.js"></script>
8990
<script src="attributes/hx-include.js"></script>
9091
<script src="attributes/hx-indicator.js"></script>
9192
<script src="attributes/hx-disabled-elt.js"></script>

0 commit comments

Comments
 (0)