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
29 changes: 24 additions & 5 deletions src/ext/hx-head.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -17,17 +19,34 @@
// 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)
})
}

// 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)
})
}
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions test/tests/ext/hx-head.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<html><head hx-head="merge"><link rel="stylesheet" href="/pending.css"></head><body><p>slow response</p></body></html>');
mockResponse('GET', '/fast', '<html><head hx-head="merge"></head><body><p>fast response</p></body></html>');

const slowTarget = createProcessedHTML('<div></div>');
const fastTarget = createProcessedHTML('<div></div>');
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, '<p>slow response</p>');
});

it('replaces title in append mode when new title differs', async function() {
let title = document.createElement('title');
title.textContent = 'Page 1';
Expand Down
19 changes: 19 additions & 0 deletions test/tests/unit/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<title>Old page</title><p>old</p>');
const target = createProcessedHTML('<div id="target"></div>');
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) {
Expand Down