From 05b4b26094f9738e32854dcd349d68445d075eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammed=20Ali=20K=C3=B6sen?= Date: Wed, 30 Sep 2026 10:08:21 +0300 Subject: [PATCH] fix(hx-preload): build preloads with hx-headers and htmx:config:request A preload was built with createRequestContext plus a bare fetch, so it missed the header work a triggered request does: hx-headers never ran, htmx:config:request never fired, and the documented HX-Preloaded header was never sent. The click then reused the preload response even though its own request would have carried different headers. Apply hx-headers and publish ctx.vals, assign form, submitter and body to ctx.request, and fire htmx:config:request (cancellable), as __handleTriggerEvent does. Set HX-Preloaded before the event, so a listener can tell the preload from the click that fires the event again, and HX-Request-Type after it, as core does. Fold the body into the query string after the event, so a listener's changes reach the URL, and clear it before fetching, since browsers reject a body on GET. hx-prompt now cancels a preload instead of answering it. Before, the click prompted and then reused a preload sent without HX-Prompt, so the answer never reached the server; with htmx:config:request firing for preloads, the element would also prompt twice. Refs #4110 --- src/ext/hx-preload.js | 17 +++++- src/ext/hx-prompt.js | 1 + test/tests/ext/hx-preload.js | 67 +++++++++++++++++++++ test/tests/ext/hx-prompt.js | 30 +++++++++ www/src/content/extensions/05-hx-preload.md | 2 + www/src/content/extensions/16-hx-prompt.md | 4 ++ 6 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/ext/hx-preload.js b/src/ext/hx-preload.js index 2011ca81e..75fc362b4 100644 --- a/src/ext/hx-preload.js +++ b/src/ext/hx-preload.js @@ -38,16 +38,29 @@ let form = elt.form || elt.closest("form"); let body = api.collectFormData(elt, form, evt.submitter); let valsResult = api.getAttributeObject(elt, 'hx-vals', obj => { + ctx.vals = obj; // make available for json extensions for (let key in obj) body.set(key, obj[key]); - }); + }, {ctx}); if (valsResult) await valsResult; + // Apply hx-headers and fire htmx:config:request here, as a triggered request does, + // so the preloaded response matches the request the click would have sent + let headersResult = api.getAttributeObject(elt, 'hx-headers', obj => { + for (let key in obj) ctx.request.headers[key] = String(obj[key]); + }, {ctx}); + if (headersResult) await headersResult; + + Object.assign(ctx.request, {form, submitter: evt.submitter, body}); + ctx.request.headers["HX-Preloaded"] = "true"; + if (!api.triggerHtmxEvent(elt, "htmx:config:request", {ctx})) return; + ctx.request.headers["HX-Request-Type"] = (ctx.target === document.body || ctx.select) ? "full" : "partial"; let action = ctx.request.action.replace?.(/#.*$/, ''); - let params = new URLSearchParams(body); + let params = new URLSearchParams(ctx.request.body); if (params.size) action += (/\?/.test(action) ? "&" : "?") + params; + ctx.request.body = null; let url = new URL(action, location.href); elt._htmx.preload = { diff --git a/src/ext/hx-prompt.js b/src/ext/hx-prompt.js index 443080369..239e79182 100644 --- a/src/ext/hx-prompt.js +++ b/src/ext/hx-prompt.js @@ -14,6 +14,7 @@ htmx_config_request(elt, {ctx}) { let question = api.attributeValue(ctx.sourceElement, 'hx-prompt'); if (question == null) return; + if (ctx.request.headers['HX-Preloaded']) return false; let answer = (window.htmxPrompt || window.prompt)(question); if (answer === null) return false; if (!htmx.trigger(ctx.sourceElement, 'htmx:prompt', { prompt: answer, target: ctx.target })) return false; diff --git a/test/tests/ext/hx-preload.js b/test/tests/ext/hx-preload.js index 64f41c5d3..f08b6f20b 100644 --- a/test/tests/ext/hx-preload.js +++ b/test/tests/ext/hx-preload.js @@ -160,6 +160,73 @@ describe('hx-preload attribute', function() { assert.equal(lastFetch().request.headers['HX-Request-Type'], 'partial') }) + it('sends inherited hx-headers and HX-Preloaded with the preload', async function () { + mockResponse('GET', '/test', 'Response') + let div = createProcessedHTML('
'); + let btn = div.querySelector('button') + btn.dispatchEvent(new Event('mouseenter')) + await htmx.timeout(20) + assert.equal(lastFetch().request.headers['X-Inherited'], '1') + assert.equal(lastFetch().request.headers['HX-Preloaded'], 'true') + }) + + it('applies htmx:config:request changes to the preload', async function () { + mockResponse('GET', '/test', 'Response') + let listener = (evt) => { + evt.detail.ctx.request.headers['X-Configured'] = '1' + evt.detail.ctx.request.body.set('q', '1') + } + document.addEventListener('htmx:config:request', listener) + try { + let btn = createProcessedHTML(''); + btn.dispatchEvent(new Event('mouseenter')) + await htmx.timeout(20) + assert.equal(lastFetch().request.headers['X-Configured'], '1') + assert.equal(lastFetch().url, '/test?q=1') + assert.isNull(lastFetch().request.body) + } finally { + document.removeEventListener('htmx:config:request', listener) + } + }) + + it('lets an htmx:config:request listener tell a preload from the click', async function () { + let fetchCount = 0 + mockResponse('GET', '/test', () => { fetchCount++; return 'Response'; }) + let seen = [] + let listener = (evt) => { + let preloaded = evt.detail.ctx.request.headers['HX-Preloaded'] + seen.push(preloaded) + if (preloaded) evt.preventDefault() + } + document.addEventListener('htmx:config:request', listener) + try { + let btn = createProcessedHTML(''); + btn.dispatchEvent(new Event('mouseenter')) + await htmx.timeout(20) + assert.equal(fetchCount, 0, 'preload should be cancelled') + btn.click() + await htmx.timeout(20) + assert.equal(fetchCount, 1, 'click should fetch') + assert.deepEqual(seen, ['true', undefined]) + assert.isUndefined(lastFetch().request.headers['HX-Preloaded']) + } finally { + document.removeEventListener('htmx:config:request', listener) + } + }) + + it('reuses preload containing hx-headers for the click', async function () { + let fetchCount = 0; + mockResponse('GET', '/test', () => { fetchCount++; return 'Response'; }) + let btn = createProcessedHTML(''); + btn.dispatchEvent(new Event('mouseenter')) + await htmx.timeout(20) + assert.equal(fetchCount, 1, 'preload should fetch') + btn.click() + await htmx.timeout(20) + assert.equal(fetchCount, 1, 'click should reuse preload, not fetch again') + assert.equal(lastFetch().request.headers['X-Headers'], '1') + }) + it('reuses preload with URL containing fragment', async function () { let fetchCount = 0; mockResponse('GET', '/test', () => { fetchCount++; return 'Response'; }) diff --git a/test/tests/ext/hx-prompt.js b/test/tests/ext/hx-prompt.js index 32037f497..a08c14f9f 100644 --- a/test/tests/ext/hx-prompt.js +++ b/test/tests/ext/hx-prompt.js @@ -228,4 +228,34 @@ describe('hx-prompt extension', function() { assert.isUndefined(fetchMock.getLastCall()); }); }); + + describe('with hx-preload', () => { + before(async () => { + let script = document.createElement('script'); + script.src = '../src/ext/hx-preload.js'; + await new Promise(resolve => { + script.onload = resolve; + document.head.appendChild(script); + }); + }); + + it('skips the preload and prompts once, on the click', async function() { + let prompts = 0; + window.htmxPrompt = () => { prompts++; return 'answer'; }; + let fetchCount = 0; + mockResponse('GET', '/items/1', () => { fetchCount++; return 'ok'; }); + + let btn = createProcessedHTML(''); + btn.dispatchEvent(new Event('mouseenter')); + await htmx.timeout(20); + assert.equal(prompts, 0, 'preload should not prompt'); + assert.equal(fetchCount, 0, 'preload should be cancelled'); + + btn.click(); + await forRequest(); + assert.equal(prompts, 1); + assert.equal(fetchCount, 1); + assert.equal(lastFetch().request.headers['HX-Prompt'], 'answer'); + }); + }); }); diff --git a/www/src/content/extensions/05-hx-preload.md b/www/src/content/extensions/05-hx-preload.md index f12785e08..eedf2b389 100644 --- a/www/src/content/extensions/05-hx-preload.md +++ b/www/src/content/extensions/05-hx-preload.md @@ -28,6 +28,8 @@ Add an `hx-preload` attribute to any boosted hyperlinks and [`hx-get`](/referenc All preload requests include an additional `HX-Preloaded: true` header. +A preload applies [`hx-headers`](/reference/attributes/hx-headers) and fires [`htmx:config:request`](/reference/events/htmx-config-request) before it is sent, so headers added there reach the server even when the click reuses the response. The click fires the event again; `HX-Preloaded` is already set on the preload's request, so a listener can tell the two apart and call `preventDefault()` to cancel only the preload. + ## hx-boost Integration When the preload extension is loaded, all [`hx-boost`](/reference/attributes/hx-boost) anchor tags are automatically preloaded on `mousedown` without needing an explicit `hx-preload` attribute. To opt out of this behaviour, set `htmx.config.preload.autoBoost = false`. diff --git a/www/src/content/extensions/16-hx-prompt.md b/www/src/content/extensions/16-hx-prompt.md index b679dc260..e2ccf3cea 100644 --- a/www/src/content/extensions/16-hx-prompt.md +++ b/www/src/content/extensions/16-hx-prompt.md @@ -73,6 +73,10 @@ The `hx-prompt` attribute supports inheritance. If the user cancels the prompt, the confirm dialog is not shown. +### Composing with hx-preload + +An [`hx-preload`](/extensions/hx-preload) request never prompts. The extension cancels the preload of an element with `hx-prompt`, so the prompt opens once, on the click, and the click sends its own request. + ### Custom Dialogs Assign a function to `window.htmxPrompt` to use a custom (synchronous) dialog.