move delay and view transitions to inside swap function for api and history use (#3328)

* move delay and view transitions to inside swap function

* Fix indenting and add tests

* move delay and view transitions to inside swap function

* Fix indenting and add tests

* revert rollback of feat: handle 'unset'for HX-Reselect in swap function
This commit is contained in:
MichaelWest22 2025-06-17 10:31:07 +12:00 committed by GitHub
parent 7df5969664
commit 859708c379
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 197 additions and 201 deletions

View File

@ -1861,7 +1861,8 @@ var htmx = (function() {
}
/**
* Apply swapping class and then execute the swap with optional delay
* Implements complete swapping pipeline, including: delay, view transitions, focus and selection preservation,
* title updates, scroll, OOB swapping, normal swapping and settling
* @param {string|Element} target
* @param {string} content
* @param {HtmxSwapSpecification} swapSpec
@ -1871,159 +1872,176 @@ var htmx = (function() {
if (!swapOptions) {
swapOptions = {}
}
// optional transition API promise callbacks
let settleResolve = null
let settleReject = null
target = resolveTarget(target)
target.classList.add(htmx.config.swappingClass)
const localSwap = function() {
runSwap(target, content, swapSpec, swapOptions)
}
if (swapSpec?.swapDelay && swapSpec.swapDelay > 0) {
getWindow().setTimeout(localSwap, swapSpec.swapDelay)
} else {
localSwap()
}
}
let doSwap = function() {
maybeCall(swapOptions.beforeSwapCallback)
/**
* Implements complete swapping pipeline, including: focus and selection preservation,
* title updates, scroll, OOB swapping, normal swapping and settling
* @param {string|Element} target
* @param {string} content
* @param {HtmxSwapSpecification} swapSpec
* @param {SwapOptions} [swapOptions]
*/
function runSwap(target, content, swapSpec, swapOptions) {
if (!swapOptions) {
swapOptions = {}
}
target = resolveTarget(target)
const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument()
target = resolveTarget(target)
const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument()
// preserve focus and selection
const activeElt = document.activeElement
let selectionInfo = {}
selectionInfo = {
elt: activeElt,
// @ts-ignore
start: activeElt ? activeElt.selectionStart : null,
// @ts-ignore
end: activeElt ? activeElt.selectionEnd : null
}
const settleInfo = makeSettleInfo(target)
// For text content swaps, don't parse the response as HTML, just insert it
if (swapSpec.swapStyle === 'textContent') {
target.textContent = content
// Otherwise, make the fragment and process it
} else {
let fragment = makeFragment(content)
settleInfo.title = fragment.title
// select-oob swaps
if (swapOptions.selectOOB) {
const oobSelectValues = swapOptions.selectOOB.split(',')
for (let i = 0; i < oobSelectValues.length; i++) {
const oobSelectValue = oobSelectValues[i].split(':', 2)
let id = oobSelectValue[0].trim()
if (id.indexOf('#') === 0) {
id = id.substring(1)
}
const oobValue = oobSelectValue[1] || 'true'
const oobElement = fragment.querySelector('#' + id)
if (oobElement) {
oobSwap(oobValue, oobElement, settleInfo, rootNode)
}
}
}
// oob swaps
findAndSwapOobElements(fragment, settleInfo, rootNode)
forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) {
if (template.content && findAndSwapOobElements(template.content, settleInfo, rootNode)) {
// Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap
template.remove()
}
})
// normal swap
if (swapOptions.select) {
const newFragment = getDocument().createDocumentFragment()
forEach(fragment.querySelectorAll(swapOptions.select), function(node) {
newFragment.appendChild(node)
})
fragment = newFragment
}
handlePreservedElements(fragment)
swapWithStyle(swapSpec.swapStyle, swapOptions.contextElement, target, fragment, settleInfo)
restorePreservedElements()
}
// apply saved focus and selection information to swapped content
if (selectionInfo.elt &&
!bodyContains(selectionInfo.elt) &&
getRawAttribute(selectionInfo.elt, 'id')) {
const newActiveElt = document.getElementById(getRawAttribute(selectionInfo.elt, 'id'))
const focusOptions = { preventScroll: swapSpec.focusScroll !== undefined ? !swapSpec.focusScroll : !htmx.config.defaultFocusScroll }
if (newActiveElt) {
// preserve focus and selection
const activeElt = document.activeElement
let selectionInfo = {}
selectionInfo = {
elt: activeElt,
// @ts-ignore
if (selectionInfo.start && newActiveElt.setSelectionRange) {
try {
// @ts-ignore
newActiveElt.setSelectionRange(selectionInfo.start, selectionInfo.end)
} catch (e) {
// the setSelectionRange method is present on fields that don't support it, so just let this fail
start: activeElt ? activeElt.selectionStart : null,
// @ts-ignore
end: activeElt ? activeElt.selectionEnd : null
}
const settleInfo = makeSettleInfo(target)
// For text content swaps, don't parse the response as HTML, just insert it
if (swapSpec.swapStyle === 'textContent') {
target.textContent = content
// Otherwise, make the fragment and process it
} else {
let fragment = makeFragment(content)
settleInfo.title = fragment.title
// select-oob swaps
if (swapOptions.selectOOB) {
const oobSelectValues = swapOptions.selectOOB.split(',')
for (let i = 0; i < oobSelectValues.length; i++) {
const oobSelectValue = oobSelectValues[i].split(':', 2)
let id = oobSelectValue[0].trim()
if (id.indexOf('#') === 0) {
id = id.substring(1)
}
const oobValue = oobSelectValue[1] || 'true'
const oobElement = fragment.querySelector('#' + id)
if (oobElement) {
oobSwap(oobValue, oobElement, settleInfo, rootNode)
}
}
}
newActiveElt.focus(focusOptions)
// oob swaps
findAndSwapOobElements(fragment, settleInfo, rootNode)
forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) {
if (template.content && findAndSwapOobElements(template.content, settleInfo, rootNode)) {
// Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap
template.remove()
}
})
// normal swap
if (swapOptions.select) {
const newFragment = getDocument().createDocumentFragment()
forEach(fragment.querySelectorAll(swapOptions.select), function(node) {
newFragment.appendChild(node)
})
fragment = newFragment
}
handlePreservedElements(fragment)
swapWithStyle(swapSpec.swapStyle, swapOptions.contextElement, target, fragment, settleInfo)
restorePreservedElements()
}
}
target.classList.remove(htmx.config.swappingClass)
forEach(settleInfo.elts, function(elt) {
if (elt.classList) {
elt.classList.add(htmx.config.settlingClass)
// apply saved focus and selection information to swapped content
if (selectionInfo.elt &&
!bodyContains(selectionInfo.elt) &&
getRawAttribute(selectionInfo.elt, 'id')) {
const newActiveElt = document.getElementById(getRawAttribute(selectionInfo.elt, 'id'))
const focusOptions = { preventScroll: swapSpec.focusScroll !== undefined ? !swapSpec.focusScroll : !htmx.config.defaultFocusScroll }
if (newActiveElt) {
// @ts-ignore
if (selectionInfo.start && newActiveElt.setSelectionRange) {
try {
// @ts-ignore
newActiveElt.setSelectionRange(selectionInfo.start, selectionInfo.end)
} catch (e) {
// the setSelectionRange method is present on fields that don't support it, so just let this fail
}
}
newActiveElt.focus(focusOptions)
}
}
triggerEvent(elt, 'htmx:afterSwap', swapOptions.eventInfo)
})
if (swapOptions.afterSwapCallback) {
swapOptions.afterSwapCallback()
}
// merge in new title after swap but before settle
if (!swapSpec.ignoreTitle) {
handleTitle(settleInfo.title)
}
// settle
const doSettle = function() {
forEach(settleInfo.tasks, function(task) {
task.call()
})
target.classList.remove(htmx.config.swappingClass)
forEach(settleInfo.elts, function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass)
elt.classList.add(htmx.config.settlingClass)
}
triggerEvent(elt, 'htmx:afterSettle', swapOptions.eventInfo)
triggerEvent(elt, 'htmx:afterSwap', swapOptions.eventInfo)
})
maybeCall(swapOptions.afterSwapCallback)
if (swapOptions.anchor) {
const anchorTarget = asElement(resolveTarget('#' + swapOptions.anchor))
if (anchorTarget) {
anchorTarget.scrollIntoView({ block: 'start', behavior: 'auto' })
}
// merge in new title after swap but before settle
if (!swapSpec.ignoreTitle) {
handleTitle(settleInfo.title)
}
updateScrollState(settleInfo.elts, swapSpec)
if (swapOptions.afterSettleCallback) {
swapOptions.afterSettleCallback()
// settle
const doSettle = function() {
forEach(settleInfo.tasks, function(task) {
task.call()
})
forEach(settleInfo.elts, function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass)
}
triggerEvent(elt, 'htmx:afterSettle', swapOptions.eventInfo)
})
if (swapOptions.anchor) {
const anchorTarget = asElement(resolveTarget('#' + swapOptions.anchor))
if (anchorTarget) {
anchorTarget.scrollIntoView({ block: 'start', behavior: 'auto' })
}
}
updateScrollState(settleInfo.elts, swapSpec)
maybeCall(swapOptions.afterSettleCallback)
maybeCall(settleResolve)
}
if (swapSpec.settleDelay > 0) {
getWindow().setTimeout(doSettle, swapSpec.settleDelay)
} else {
doSettle()
}
}
let shouldTransition = htmx.config.globalViewTransitions
if (swapSpec.hasOwnProperty('transition')) {
shouldTransition = swapSpec.transition
}
const elt = swapOptions.contextElement || getDocument()
if (shouldTransition &&
triggerEvent(elt, 'htmx:beforeTransition', swapOptions.eventInfo) &&
typeof Promise !== 'undefined' &&
// @ts-ignore experimental feature atm
document.startViewTransition) {
const settlePromise = new Promise(function(_resolve, _reject) {
settleResolve = _resolve
settleReject = _reject
})
// wrap the original doSwap() in a call to startViewTransition()
const innerDoSwap = doSwap
doSwap = function() {
// @ts-ignore experimental feature atm
document.startViewTransition(function() {
innerDoSwap()
return settlePromise
})
}
}
if (swapSpec.settleDelay > 0) {
getWindow().setTimeout(doSettle, swapSpec.settleDelay)
} else {
doSettle()
try {
if (swapSpec?.swapDelay && swapSpec.swapDelay > 0) {
getWindow().setTimeout(doSwap, swapSpec.swapDelay)
} else {
doSwap()
}
} catch (e) {
triggerErrorEvent(elt, 'htmx:swapError', swapOptions.eventInfo)
maybeCall(settleReject)
throw e
}
}
@ -4851,9 +4869,7 @@ var htmx = (function() {
swapSpec.ignoreTitle = ignoreTitle
}
// optional transition API promise callbacks
let settleResolve = null
let settleReject = null
target.classList.add(htmx.config.swappingClass)
if (responseInfoSelect) {
selectOverride = responseInfoSelect
@ -4866,8 +4882,31 @@ var htmx = (function() {
const selectOOB = getClosestAttributeValue(elt, 'hx-select-oob')
const select = getClosestAttributeValue(elt, 'hx-select')
let doSwap = function() {
try {
swap(target, serverResponse, swapSpec, {
select: selectOverride === 'unset' ? null : selectOverride || select,
selectOOB,
eventInfo: responseInfo,
anchor: responseInfo.pathInfo.anchor,
contextElement: elt,
afterSwapCallback: function() {
if (hasHeader(xhr, /HX-Trigger-After-Swap:/i)) {
let finalElt = elt
if (!bodyContains(elt)) {
finalElt = getDocument().body
}
handleTriggerHeader(xhr, 'HX-Trigger-After-Swap', finalElt)
}
},
afterSettleCallback: function() {
if (hasHeader(xhr, /HX-Trigger-After-Settle:/i)) {
let finalElt = elt
if (!bodyContains(elt)) {
finalElt = getDocument().body
}
handleTriggerHeader(xhr, 'HX-Trigger-After-Settle', finalElt)
}
},
beforeSwapCallback: function() {
// if we need to save history, do so, before swapping so that relative resources have the correct base URL
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo))
@ -4879,65 +4918,8 @@ var htmx = (function() {
triggerEvent(getDocument().body, 'htmx:replacedInHistory', { path: historyUpdate.path })
}
}
swap(target, serverResponse, swapSpec, {
select: selectOverride === 'unset' ? null : selectOverride || select,
selectOOB,
eventInfo: responseInfo,
anchor: responseInfo.pathInfo.anchor,
contextElement: elt,
afterSwapCallback: function() {
if (hasHeader(xhr, /HX-Trigger-After-Swap:/i)) {
let finalElt = elt
if (!bodyContains(elt)) {
finalElt = getDocument().body
}
handleTriggerHeader(xhr, 'HX-Trigger-After-Swap', finalElt)
}
},
afterSettleCallback: function() {
if (hasHeader(xhr, /HX-Trigger-After-Settle:/i)) {
let finalElt = elt
if (!bodyContains(elt)) {
finalElt = getDocument().body
}
handleTriggerHeader(xhr, 'HX-Trigger-After-Settle', finalElt)
}
maybeCall(settleResolve)
}
})
} catch (e) {
triggerErrorEvent(elt, 'htmx:swapError', responseInfo)
maybeCall(settleReject)
throw e
}
}
let shouldTransition = htmx.config.globalViewTransitions
if (swapSpec.hasOwnProperty('transition')) {
shouldTransition = swapSpec.transition
}
if (shouldTransition &&
triggerEvent(elt, 'htmx:beforeTransition', responseInfo) &&
typeof Promise !== 'undefined' &&
// @ts-ignore experimental feature atm
document.startViewTransition) {
const settlePromise = new Promise(function(_resolve, _reject) {
settleResolve = _resolve
settleReject = _reject
})
// wrap the original doSwap() in a call to startViewTransition()
const innerDoSwap = doSwap
doSwap = function() {
// @ts-ignore experimental feature atm
document.startViewTransition(function() {
innerDoSwap()
return settlePromise
})
}
}
doSwap()
})
}
if (isError) {
triggerErrorEvent(elt, 'htmx:responseError', mergeObjects({ error: 'Response Status Error Code ' + xhr.status + ' from ' + responseInfo.pathInfo.requestPath }, responseInfo))
@ -5138,6 +5120,7 @@ var htmx = (function() {
* @property {Element} [contextElement]
* @property {swapCallback} [afterSwapCallback]
* @property {swapCallback} [afterSettleCallback]
* @property {swapCallback} [beforeSwapCallback]
*/
/**

View File

@ -318,10 +318,11 @@ describe('hx-swap attribute', function() {
)
div.click()
this.server.respond()
div.innerText.should.equal('')
setTimeout(function() {
div.innerText.should.equal('Clicked!')
done()
}, 100)
}, 30)
})
it('works with a settle delay', function(done) {
@ -538,8 +539,9 @@ describe('hx-swap attribute', function() {
it('swapError fires if swap throws exception', function() {
try {
htmx._('htmx.backupSwap = swap')
htmx._('swap = function() { throw new Error("throw") }')
// override makeSettleInfo to cause swap function to throw exception
htmx._('htmx.backupMakeSettleInfo = makeSettleInfo')
htmx._('makeSettleInfo = function() { throw new Error("throw") }')
var error = false
var handler = htmx.on('htmx:swapError', function(evt) {
error = true
@ -554,7 +556,7 @@ describe('hx-swap attribute', function() {
div.innerHTML.should.equal('')
error.should.equal(true)
htmx.off('htmx:swapError', handler)
htmx._('swap = htmx.backupSwap')
htmx._('makeSettleInfo = htmx.backupMakeSettleInfo')
}
})
})

View File

@ -494,6 +494,17 @@ describe('Core htmx API test', function() {
}, 30)
})
it('swap works with a view transition', function(done) {
var div = make("<div hx-get='/test'></div>")
div.innerText.should.equal('')
htmx.swap(div, 'jsswapped', { transition: true })
div.innerText.should.equal('')
setTimeout(function() {
div.innerText.should.equal('jsswapped')
done()
}, 30)
})
it('swaps content properly (with select)', function() {
var output = make('<output id="output"/>')
htmx.swap('#output', '<div><p id="select-me">Swapped!</p></div>', { swapStyle: 'innerHTML' }, { select: '#select-me' })