diff --git a/src/ext/hx-head.js b/src/ext/hx-head.js index d78bc324e..7fe0b4660 100644 --- a/src/ext/hx-head.js +++ b/src/ext/hx-head.js @@ -6,6 +6,8 @@ (function () { let api + const pendingResources = new WeakSet() + const resourceLoadTimeout = 3000 // Appends a new head node, returning a promise for render-critical resources // (blocking scripts, stylesheets) or null for fire-and-forget resources. @@ -17,8 +19,16 @@ // stylesheet: await CSSOM or content will flash unstyled if (newNode.tagName === "LINK" && newNode.rel === "stylesheet") { return new Promise(resolve => { - newElt.onload = resolve - newElt.onerror = resolve // failed stylesheet shouldn't block swap + let timeout + const finish = () => { + clearTimeout(timeout) + pendingResources.delete(newElt) + resolve() + } + pendingResources.add(newElt) + timeout = setTimeout(finish, resourceLoadTimeout) + newElt.onload = finish + newElt.onerror = finish // failed stylesheet shouldn't block swap document.head.appendChild(newElt) }) } @@ -26,8 +36,17 @@ // blocking external script (no async/defer): must init before swap if (newNode.tagName === "SCRIPT" && newNode.src && !newNode.async && !newNode.defer) { return new Promise((resolve, reject) => { - newElt.onload = resolve - newElt.onerror = reject + let timeout + const finish = error => { + clearTimeout(timeout) + pendingResources.delete(newElt) + if (error) reject(error) + else resolve() + } + pendingResources.add(newElt) + timeout = setTimeout(() => finish(), resourceLoadTimeout) + newElt.onload = () => finish() + newElt.onerror = finish document.head.appendChild(newElt) }) } @@ -95,7 +114,7 @@ } 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) { + if (!pendingResources.has(currentHeadElt) && api.triggerHtmxEvent(document.body, "htmx:head:before:remove", {headElement: currentHeadElt}) !== false) { removed.push(currentHeadElt) } } else if (isReAppended) { diff --git a/src/htmx.js b/src/htmx.js index 7e811e5e1..501d5f7c0 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -581,8 +581,13 @@ var htmx = (() => { ctx.request.headers["HX-Request-Type"] = (ctx.target === document.body || ctx.select) ? "full" : "partial"; if (!this.__trigger(elt, "htmx:before:request", {ctx})) return; + let targetWasConnected = ctx.target?.isConnected; let response = await ctx.fetch(ctx.request.action, ctx.request); + // Ignore responses whose connected target was removed while the request was in flight. + // This must happen before response hooks (such as hx-head) can apply page-level effects. + if (targetWasConnected && !ctx.target.isConnected) return; + ctx.response = { raw: response, status: response.status, diff --git a/test/tests/ext/hx-head.js b/test/tests/ext/hx-head.js index 0561a49d5..727e8930b 100644 --- a/test/tests/ext/hx-head.js +++ b/test/tests/ext/hx-head.js @@ -200,6 +200,37 @@ describe('hx-head extension', function() { assert.isAtMost(headMergeTime, swapTime, 'head merge must complete before swap'); }); + it('keeps a stylesheet while another head merge is waiting for it', async function() { + let addedLink; + const disableStylesheet = event => { + const headElement = event.detail.headElement; + if (headElement.tagName === 'LINK' && headElement.rel === 'stylesheet' && headElement.href.endsWith('/pending.css')) { + headElement.setAttribute('disabled', ''); + } + }; + document.body.addEventListener('htmx:head:before:add', disableStylesheet); + + mockResponse('GET', '/slow', '

slow response

'); + mockResponse('GET', '/fast', '

fast response

'); + + const slowTarget = createProcessedHTML('
'); + const fastTarget = createProcessedHTML('
'); + const slowRequest = htmx.ajax('GET', '/slow', {target: slowTarget, swap: 'innerHTML'}); + + await waitForEvent('htmx:head:before:add', 500); + document.body.removeEventListener('htmx:head:before:add', disableStylesheet); + addedLink = document.head.querySelector('link[href="/pending.css"]'); + assert.isNotNull(addedLink, 'first response should add its stylesheet'); + if (addedLink) addedHeadElts.push(addedLink); + + await htmx.ajax('GET', '/fast', {target: fastTarget, swap: 'innerHTML'}); + + assert.isTrue(addedLink.isConnected, 'concurrent merge must not remove a stylesheet still loading'); + addedLink.dispatchEvent(new Event('load')); + await slowRequest; + assert.equal(slowTarget.innerHTML, '

slow response

'); + }); + it('replaces title in append mode when new title differs', async function() { let title = document.createElement('title'); title.textContent = 'Page 1'; diff --git a/test/tests/unit/ajax.js b/test/tests/unit/ajax.js index d98e1d733..2c8011914 100644 --- a/test/tests/unit/ajax.js +++ b/test/tests/unit/ajax.js @@ -333,6 +333,25 @@ describe('ajax() unit Tests', function() { assert.isTrue(errorFired); }); + it('ignores a response when its target is removed while the request is in flight', async function() { + const seq = mockSequentialResponses('GET', '/test', 'Old page

old

'); + const target = createProcessedHTML('
'); + document.title = 'Current page'; + + const promise = htmx.ajax('GET', '/test', { + target, + swap: 'innerHTML' + }); + + await htmx.timeout(1); + target.remove(); + await seq.next(); + await promise; + + assert.equal(document.title, 'Current page'); + assert.equal(target.innerHTML, ''); + }); + it('sets full URL for cross-origin GET requests with query params', async function() { let capturedAction; document.addEventListener('htmx:before:request', function handler(evt) {