diff --git a/src/editors/jetbrains/htmx.web-types.json b/src/editors/jetbrains/htmx.web-types.json index acdafa011..9c64f65d0 100644 --- a/src/editors/jetbrains/htmx.web-types.json +++ b/src/editors/jetbrains/htmx.web-types.json @@ -452,7 +452,7 @@ }, { "name": "before:response", - "description": "Fires after response headers are received but before the body is consumed. `detail.ctx.response.raw` is the unconsumed `Response`.", + "description": "Fires after response headers are received but before the body is consumed. `detail.ctx.response.raw` is the unconsumed `Response`; `detail.ctx.response.headers` is a standard `Headers` instance.", "doc-url": "https://four.htmx.org/reference/events/htmx-before-response" }, { diff --git a/src/ext/hx-prompt.js b/src/ext/hx-prompt.js index 443080369..85d3b5f40 100644 --- a/src/ext/hx-prompt.js +++ b/src/ext/hx-prompt.js @@ -17,7 +17,7 @@ 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; - ctx.request.headers['HX-Prompt'] = encodeURI(answer); + ctx.request.headers.set('HX-Prompt', encodeURI(answer)); } }); })(); diff --git a/src/ext/hx-ptag.js b/src/ext/hx-ptag.js index b603de27e..ffa8a87ce 100644 --- a/src/ext/hx-ptag.js +++ b/src/ext/hx-ptag.js @@ -25,7 +25,7 @@ htmx_config_request(elt, {ctx}) { let ptag = elt._htmx?.ptag; - if (ptag) ctx.request.headers["HX-PTag"] = ptag; + if (ptag) ctx.request.headers.set("HX-PTag", ptag); }, htmx_after_request(elt, {ctx}) { diff --git a/src/ext/hx-sse.js b/src/ext/hx-sse.js index beddaafa5..a4989f55f 100644 --- a/src/ext/hx-sse.js +++ b/src/ext/hx-sse.js @@ -194,7 +194,7 @@ let ac = new AbortController(); connection.abortController = ac; try { - if (connection.lastEventId) ctx.request.headers['Last-Event-ID'] = connection.lastEventId; + if (connection.lastEventId) ctx.request.headers.set('Last-Event-ID', connection.lastEventId); currentResponse = await fetch(ctx.request.action, { ...ctx.request, signal: ac.signal @@ -342,7 +342,7 @@ }, htmx_config_request: (element, detail) => { - detail.ctx.request.headers['Accept'] = 'text/html, text/event-stream'; + detail.ctx.request.headers.append('Accept', 'text/event-stream'); }, // Intercept SSE responses before core consumes the body diff --git a/src/ext/hx-ws.js b/src/ext/hx-ws.js index b7c747a60..5b2d4a700 100644 --- a/src/ext/hx-ws.js +++ b/src/ext/hx-ws.js @@ -358,10 +358,12 @@ // [Correlation] Cleanup expired pending requests periodically cleanupExpiredRequests(connection); - // Build headers using core's request context (same as HTTP requests) let ctx = api.createRequestContext(element, event); - let headers = {...ctx.request.headers}; - delete headers['Accept']; + // Headers lowercases names; restore htmx-style casing for WS messages. + let headers = {}; + ctx.request.headers.forEach((value, name) => { + if (name !== 'accept') headers[name.replace(/(^|-)(hx|id|url|.)/g, (_, dash, part) => dash + ({hx: 'HX', id: 'ID', url: 'URL'}[part] || part.toUpperCase()))] = value; + }); // [Correlation] Add request ID as a header let requestId = crypto.randomUUID(); diff --git a/src/htmx.js b/src/htmx.js index a8d36b64c..298cadbb3 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -71,14 +71,21 @@ var htmx = (() => { merge(source, target) { if (typeof source === 'string') source = HCON.parse(source); - for (let [key, val] of Object.entries(source)) { + // Support native key-value collections like Headers and Map. + let entries = source?.[Symbol.iterator] && !Array.isArray(source) ? source : Object.entries(source); + let targetIsCollection = typeof target.get === 'function' && typeof target.set === 'function'; + + for (let [key, val] of entries) { if (['__proto__', 'constructor', 'prototype'].includes(key)) continue; + let targetVal = targetIsCollection ? target.get(key) : target[key]; let sourceIsObject = val && typeof val === 'object' && !Array.isArray(val); - let targetIsObject = target[key] && typeof target[key] === 'object' && !Array.isArray(target[key]); + let targetIsObject = targetVal && typeof targetVal === 'object' && !Array.isArray(targetVal); if (sourceIsObject && targetIsObject) { - HCON.merge(val, target[key]); + HCON.merge(val, targetVal); + } else if (targetIsCollection) { + target.set(key, val); } else { target[key] = val; } @@ -225,7 +232,6 @@ var htmx = (() => { if (metaConfig) { HCON.merge(metaConfig.content, this.config); } - this.__approvedExt = this.config.extensions; } __initRequestIndicatorCss() { @@ -440,9 +446,9 @@ var htmx = (() => { HCON.merge(sourceElement._htmx.boosted, ctx); } ctx.target = this.__resolveTarget(sourceElement, ctx.target); - ctx.request.headers["HX-Request-Type"] = (ctx.target === document.body || ctx.select) ? "full" : "partial"; + ctx.request.headers.set("HX-Request-Type", (ctx.target === document.body || ctx.select) ? "full" : "partial"); if (ctx.target) { - ctx.request.headers["HX-Target"] = this.__buildIdentifier(ctx.target); + ctx.request.headers.set("HX-Target", this.__buildIdentifier(ctx.target)); } // Apply hx-config overrides @@ -459,21 +465,21 @@ var htmx = (() => { } __createCoreHeaders(elt) { - let headers = { + let headers = new Headers({ "HX-Request": "true", "HX-Source": this.__buildIdentifier(elt), "HX-Current-URL": location.href, "Accept": "text/html" - }; + }); if (this.__isBoosted(elt)) { - headers["HX-Boosted"] = "true" + headers.set("HX-Boosted", "true") } return headers; } __handleHxHeaders(elt, ctx) { return this.__getAttributeObject(elt, "hx-headers", obj => { - for (let key in obj) ctx.request.headers[key] = String(obj[key]); + HCON.merge(obj, ctx.request.headers); }, {ctx}); } @@ -1594,10 +1600,11 @@ var htmx = (() => { sourceElt ||= document.body; let ctx = this.__createRequestContext(sourceElt, context.event || {}); - Object.assign(ctx, context); + HCON.merge(context, ctx); if (context.target) ctx.target = this.__resolveTarget(document.body, context.target); - Object.assign(ctx.request, {action: path, method: verb.toUpperCase()}); - if (context.headers) Object.assign(ctx.request.headers, context.headers); + ctx.request.action = path; + ctx.request.method = verb.toUpperCase(); + if (context.headers) HCON.merge(context.headers, ctx.request.headers); return this.__handleTriggerEvent(ctx); } @@ -2344,3 +2351,85 @@ var htmx = (() => { })() ; + +// Compatibility shims for breaking changes made during the htmx 4 beta. +// Auto-registered for now; eventually a third-party extension for beta compatibility. +htmx.registerExtension('hx-compat-htmx-4-beta', (() => { + let warned = new Set(); + + let warnCompatOnce = (message, oldSyntax, newSyntax, docs) => { + let warning = [ + `[htmx] ${message}`, + ` old: ${oldSyntax}`, + ` new: ${newSyntax}`, + docs && ` docs: ${docs}` + ].filter(Boolean).join('\n'); + + if (warned.has(warning)) return; + warned.add(warning); + console.warn(warning); + }; + + let headersCompat = (() => { + let warnBracketAccess = () => warnCompatOnce( + 'Deprecated: replace ctx.request.headers[name] with Headers methods', + 'ctx.request.headers[name]', + 'ctx.request.headers.get(name), .set(name, value), or .delete(name)', + 'https://developer.mozilla.org/en-US/docs/Web/API/Headers' + ); + let wrapped = new WeakSet(); + + return { + htmx_config_request(elt, {ctx}) { + if (wrapped.has(ctx.request.headers)) return; + + let headers = ctx.request.headers instanceof Headers ? ctx.request.headers : new Headers(ctx.request.headers || {}); + let proxy = new Proxy(headers, { + get(target, prop) { + let value = Reflect.get(target, prop, target); + if (value !== undefined) return typeof value === 'function' ? value.bind(target) : value; + if (typeof prop !== 'string' || !target.has(prop)) return; + warnBracketAccess(); + return target.get(prop); + }, + set(target, prop, value) { + if (typeof prop !== 'string' || prop in target) return Reflect.set(target, prop, value, target); + warnBracketAccess(); + target.set(prop, value); + return true; + }, + deleteProperty(target, prop) { + if (typeof prop !== 'string' || prop in target) return Reflect.deleteProperty(target, prop); + warnBracketAccess(); + target.delete(prop); + return true; + } + }); + wrapped.add(proxy); + ctx.request.headers = proxy; + } + }; + })(); + + let compatModules = [ + headersCompat, + // Add future beta compatibility modules here. + ]; + + let extension = {}; + for (let module of compatModules) { + for (let [hookName, fn] of Object.entries(module)) { + let previous = extension[hookName]; + extension[hookName] = previous + ? (...args) => { + if (previous(...args) === false || args[1]?.cancelled) return false; + return fn(...args); + } + : fn; + } + } + + return extension; +})()); + +htmx.__approvedExt = htmx.config.extensions; diff --git a/src/skills/htmx-extension-authoring.md b/src/skills/htmx-extension-authoring.md index 08ff2262b..a15efa6d9 100644 --- a/src/skills/htmx-extension-authoring.md +++ b/src/skills/htmx-extension-authoring.md @@ -177,7 +177,7 @@ The context object available via `detail.ctx` in hook callbacks: request: { action, // Request URL method, // HTTP method (GET, POST, etc.) - headers, // Request headers object + headers, // Request Headers instance body, // Request body (FormData) validate, // Whether to validate form abort, // Function to abort request @@ -189,7 +189,7 @@ The context object available via `detail.ctx` in hook callbacks: response: { // Available after request completes raw, // Raw Response object status, // HTTP status code - headers, // Response headers + headers, // Response Headers instance }, text, // Response text (after request) hx, // Parsed HX-* response headers diff --git a/src/skills/htmx-guidance.md b/src/skills/htmx-guidance.md index b1a88105d..7e3a7f454 100644 --- a/src/skills/htmx-guidance.md +++ b/src/skills/htmx-guidance.md @@ -244,7 +244,7 @@ document.body.addEventListener('htmx:config:request', (evt) => { // ctx.swap -- hx-swap value // ctx.request.action -- URL // ctx.request.method -- HTTP method - // ctx.request.headers -- headers object + // ctx.request.headers -- Headers instance // ctx.request.body -- FormData body }); ``` diff --git a/src/skills/htmx-upgrade-from-htmx2.md b/src/skills/htmx-upgrade-from-htmx2.md index 21327c20c..8c9ef1195 100644 --- a/src/skills/htmx-upgrade-from-htmx2.md +++ b/src/skills/htmx-upgrade-from-htmx2.md @@ -193,7 +193,7 @@ document.addEventListener('htmx:configRequest', (evt) => { // htmx 4: event.detail.ctx contains request context document.addEventListener('htmx:config:request', (evt) => { - evt.detail.ctx.request.headers['X-Custom'] = 'value'; + evt.detail.ctx.request.headers.set('X-Custom', 'value'); evt.detail.ctx.request.body.set('key', 'value'); // FormData evt.detail.ctx.request.action = '/modified-url'; }); diff --git a/test/manual/cors.html b/test/manual/cors.html index a24b72119..1b51f3178 100644 --- a/test/manual/cors.html +++ b/test/manual/cors.html @@ -37,7 +37,7 @@ document.addEventListener('htmx:config:request', function(evt) { // Clear htmx headers for CORS requests if (evt.detail.ctx.request.mode === 'cors') { - evt.detail.ctx.request.headers = {}; + evt.detail.ctx.request.headers = new Headers(); } }); diff --git a/test/tests/attributes/hx-config.js b/test/tests/attributes/hx-config.js index bd549f42f..9a0f1eaca 100644 --- a/test/tests/attributes/hx-config.js +++ b/test/tests/attributes/hx-config.js @@ -228,7 +228,21 @@ describe('hx-config attribute', function() { let btn = createProcessedHTML(''); btn.click() await forRequest() - assert.equal(ctx.request.headers['X-Custom'], 'value') - assert.equal(ctx.request.headers['HX-Request'], 'true') + assert.equal(ctx.request.headers.get('X-Custom'), 'value') + assert.equal(ctx.request.headers.get('HX-Request'), 'true') + }) + + it('merges dotted headers config into native request Headers', async function () { + mockResponse('GET', '/test', 'Done') + let ctx = null + document.addEventListener('htmx:config:request', function(e) { + ctx = e.detail.ctx + }, {once: true}) + + let btn = createProcessedHTML(''); + btn.click() + await forRequest() + assert.equal(ctx.request.headers.get('X-Custom'), 'value') + assert.equal(ctx.request.headers.get('HX-Request'), 'true') }) }) diff --git a/test/tests/attributes/hx-headers.js b/test/tests/attributes/hx-headers.js index dbeb31ab1..24f856bd5 100644 --- a/test/tests/attributes/hx-headers.js +++ b/test/tests/attributes/hx-headers.js @@ -13,7 +13,7 @@ describe('hx-headers attribute', function() { let div = createProcessedHTML("
") div.click() await forRequest() - fetchMock.calls[0].request.headers.i1.should.equal('test'); + fetchMock.calls[0].request.headers.get('i1').should.equal('test'); div.innerHTML.should.equal('Clicked!') }) @@ -22,7 +22,7 @@ describe('hx-headers attribute', function() { let div = createProcessedHTML('') div.click() await forRequest() - fetchMock.calls[0].request.headers.i1.should.equal('test'); + fetchMock.calls[0].request.headers.get('i1').should.equal('test'); div.innerHTML.should.equal('Clicked!') }) @@ -31,8 +31,8 @@ describe('hx-headers attribute', function() { let div = createProcessedHTML('') div.click() await forRequest() - fetchMock.calls[0].request.headers.v1.should.equal('test'); - fetchMock.calls[0].request.headers.v2.should.equal('42'); + fetchMock.calls[0].request.headers.get('v1').should.equal('test'); + fetchMock.calls[0].request.headers.get('v2').should.equal('42'); div.innerHTML.should.equal('Clicked!') }) @@ -42,7 +42,7 @@ describe('hx-headers attribute', function() { let div = find('#d1') div.click() await forRequest() - fetchMock.calls[0].request.headers.i1.should.equal('test'); + fetchMock.calls[0].request.headers.get('i1').should.equal('test'); div.innerHTML.should.equal('Clicked!') }) @@ -52,7 +52,7 @@ describe('hx-headers attribute', function() { let div = find('#d1') div.click() await forRequest() - fetchMock.calls[0].request.headers.i1.should.equal('best'); + fetchMock.calls[0].request.headers.get('i1').should.equal('best'); div.innerHTML.should.equal('Clicked!') }) @@ -61,7 +61,7 @@ describe('hx-headers attribute', function() { let div = createProcessedHTML('') div.click() await forRequest() - fetchMock.calls[0].request.headers.i1.should.equal('test'); + fetchMock.calls[0].request.headers.get('i1').should.equal('test'); div.innerHTML.should.equal('Clicked!') }) @@ -70,7 +70,7 @@ describe('hx-headers attribute', function() { let div = createProcessedHTML('') div.click() await forRequest() - fetchMock.calls[0].request.headers.i1.should.equal('test'); + fetchMock.calls[0].request.headers.get('i1').should.equal('test'); div.innerHTML.should.equal('Clicked!') }) @@ -79,8 +79,8 @@ describe('hx-headers attribute', function() { let div = createProcessedHTML('') div.click() await forRequest() - fetchMock.calls[0].request.headers.v1.should.equal('test'); - fetchMock.calls[0].request.headers.v2.should.equal('42'); + fetchMock.calls[0].request.headers.get('v1').should.equal('test'); + fetchMock.calls[0].request.headers.get('v2').should.equal('42'); div.innerHTML.should.equal('Clicked!') }) @@ -90,7 +90,7 @@ describe('hx-headers attribute', function() { let div = find('#d1') div.click() await forRequest() - fetchMock.calls[0].request.headers.i1.should.equal('test'); + fetchMock.calls[0].request.headers.get('i1').should.equal('test'); div.innerHTML.should.equal('Clicked!') }) @@ -100,7 +100,7 @@ describe('hx-headers attribute', function() { let div = find('#d1') div.click() await forRequest() - fetchMock.calls[0].request.headers.i1.should.equal('best'); + fetchMock.calls[0].request.headers.get('i1').should.equal('best'); div.innerHTML.should.equal('Clicked!') }) diff --git a/test/tests/attributes/hx-post.js b/test/tests/attributes/hx-post.js index abc037190..be0793a49 100644 --- a/test/tests/attributes/hx-post.js +++ b/test/tests/attributes/hx-post.js @@ -14,7 +14,7 @@ describe('hx-post attribute', function() { btn.click() await forRequest() fetchMock.calls[0].request.method.should.equal('POST'); - should.equal(fetchMock.calls[0].request.headers['X-HTTP-Method-Override'], undefined); + should.equal(fetchMock.calls[0].request.headers.get('X-HTTP-Method-Override'), null); btn.innerHTML.should.equal('Posted!') }) diff --git a/test/tests/ext/hx-compat-htmx-4-beta.js b/test/tests/ext/hx-compat-htmx-4-beta.js new file mode 100644 index 000000000..8e2ed03ef --- /dev/null +++ b/test/tests/ext/hx-compat-htmx-4-beta.js @@ -0,0 +1,119 @@ +describe('hx-compat-htmx-4-beta extension', function() { + + beforeEach(function() { + setupTest(); + }); + + afterEach(function() { + cleanupTest(); + }); + + it('is registered by default during beta', function() { + assert.isTrue(htmx.__registeredExt.has('hx-compat-htmx-4-beta')); + }); + + it('does not warn when code uses the Headers API', async function() { + let originalWarn = console.warn; + let warnings = []; + console.warn = msg => warnings.push(msg); + + let handler = htmx.on('htmx:config:request', event => { + event.detail.ctx.request.headers.set('X-Native', 'value'); + }); + + try { + mockResponse('GET', '/test', 'OK'); + let btn = createProcessedHTML(''); + btn.click(); + await forRequest(); + + assert.equal(lastFetch().request.headers.get('X-Native'), 'value'); + assert.equal(warnings.length, 0); + } finally { + document.removeEventListener('htmx:config:request', handler); + console.warn = originalWarn; + } + }); + + it('supports object-style request header access in extension hooks and warns once', async function() { + let backup = backupExtensions(); + let originalWarn = console.warn; + let warnings = []; + console.warn = msg => warnings.push(msg); + + try { + htmx.registerExtension('headers-compat-test', { + htmx_config_request: (elt, {ctx}) => { + delete ctx.request.headers['X-Missing']; + assert.equal(warnings.length, 1); + ctx.request.headers['X-Test'] = 'value'; + assert.equal(ctx.request.headers['X-Test'], 'value'); + delete ctx.request.headers['X-Test']; + ctx.request.headers['X-Test'] = 'final'; + } + }); + + mockResponse('GET', '/test', 'OK'); + let btn = createProcessedHTML(''); + btn.click(); + await forRequest(); + + assert.equal(lastFetch().request.headers.get('X-Test'), 'final'); + assert.equal(warnings.length, 1); + assert.include(warnings[0], '[htmx] Deprecated:'); + assert.include(warnings[0], 'Headers'); + assert.include(warnings[0].toLowerCase(), 'deprecated'); + assert.include(warnings[0], 'ctx.request.headers[name]'); + assert.include(warnings[0], '.set(name, value)'); + } finally { + console.warn = originalWarn; + restoreExtensions(backup); + } + }); + + it('supports object-style request header access in htmx.on listeners', async function() { + let handler = htmx.on('htmx:before:request', event => { + event.detail.ctx.request.headers['X-Htmx-On'] = 'value'; + }); + + try { + mockResponse('GET', '/test', 'OK'); + let btn = createProcessedHTML(''); + btn.click(); + await forRequest(); + + assert.equal(lastFetch().request.headers.get('X-Htmx-On'), 'value'); + } finally { + document.removeEventListener('htmx:before:request', handler); + } + }); + + it('supports object-style request header access in addEventListener listeners', async function() { + let handler = event => { + event.detail.ctx.request.headers['X-Listener'] = 'value'; + }; + document.addEventListener('htmx:config:request', handler); + + try { + mockResponse('GET', '/test', 'OK'); + let btn = createProcessedHTML(''); + btn.click(); + await forRequest(); + + assert.equal(lastFetch().request.headers.get('X-Listener'), 'value'); + } finally { + document.removeEventListener('htmx:config:request', handler); + } + }); + + it('supports object-style request header access in hx-on handlers', async function() { + mockResponse('GET', '/test', 'OK'); + let btn = createProcessedHTML(``); + btn.click(); + await forRequest(); + + assert.equal(lastFetch().request.headers.get('X-Hx-On'), 'value'); + }); + + +}); diff --git a/test/tests/ext/hx-prompt.js b/test/tests/ext/hx-prompt.js index 32037f497..404019870 100644 --- a/test/tests/ext/hx-prompt.js +++ b/test/tests/ext/hx-prompt.js @@ -39,7 +39,7 @@ describe('hx-prompt extension', function() { btn.click(); await forRequest(); - assert.equal(lastFetch().request.headers['HX-Prompt'], 'because'); + assert.equal(lastFetch().request.headers.get('HX-Prompt'), 'because'); }); it('URI-encodes unicode prompt responses for fetch headers', async function() { @@ -51,7 +51,7 @@ describe('hx-prompt extension', function() { btn.addEventListener('htmx:before:request', (e) => { e.detail.ctx.fetch = async (action, request) => { fetchCalled = true; - encodedHeader = request.headers['HX-Prompt']; + encodedHeader = request.headers.get('HX-Prompt'); new Request(new URL(action, location.href), request); return new Response('ok'); }; @@ -83,7 +83,7 @@ describe('hx-prompt extension', function() { btn.click(); await forRequest(); - assert.equal(lastFetch().request.headers['HX-Prompt'], ''); + assert.equal(lastFetch().request.headers.get('HX-Prompt'), ''); }); it('does nothing when hx-prompt is absent', async function() { @@ -96,7 +96,7 @@ describe('hx-prompt extension', function() { await forRequest(); assert.isFalse(prompted); - assert.isUndefined(lastFetch().request.headers['HX-Prompt']); + assert.isNull(lastFetch().request.headers.get('HX-Prompt')); }); it('inherits via :inherited from a container', async function() { @@ -112,7 +112,7 @@ describe('hx-prompt extension', function() { find('button').click(); await forRequest(); - assert.equal(lastFetch().request.headers['HX-Prompt'], 'inherited'); + assert.equal(lastFetch().request.headers.get('HX-Prompt'), 'inherited'); }); it('composes with hx-confirm (both pass)', async function() { @@ -127,7 +127,7 @@ describe('hx-prompt extension', function() { btn.click(); await forRequest(); - assert.equal(lastFetch().request.headers['HX-Prompt'], 'a%20reason'); + assert.equal(lastFetch().request.headers.get('HX-Prompt'), 'a%20reason'); window.confirm = originalConfirm; }); @@ -177,7 +177,7 @@ describe('hx-prompt extension', function() { window.prompt = () => 'long enough'; btn.click(); await forRequest(); - assert.equal(lastFetch().request.headers['HX-Prompt'], 'long%20enough'); + assert.equal(lastFetch().request.headers.get('HX-Prompt'), 'long%20enough'); }); it('uses window.htmxPrompt when defined', async function() { @@ -189,7 +189,7 @@ describe('hx-prompt extension', function() { btn.click(); await forRequest(); - assert.equal(lastFetch().request.headers['HX-Prompt'], 'answered:%20Q?'); + assert.equal(lastFetch().request.headers.get('HX-Prompt'), 'answered:%20Q?'); }); it('null from window.htmxPrompt aborts the request', async function() { @@ -204,7 +204,7 @@ describe('hx-prompt extension', function() { }); describe('hx-on recipe (no extension needed)', () => { - const recipe = "ctx.request.headers['HX-Prompt'] = prompt('Reason?') ?? event.preventDefault()"; + const recipe = "let answer = prompt('Reason?'); answer == null ? event.preventDefault() : ctx.request.headers.set('HX-Prompt', answer)"; it('sends HX-Prompt with the answer', async function() { window.prompt = () => 'a reason'; @@ -214,7 +214,18 @@ describe('hx-prompt extension', function() { btn.click(); await forRequest(); - assert.equal(lastFetch().request.headers['HX-Prompt'], 'a reason'); + assert.equal(lastFetch().request.headers.get('HX-Prompt'), 'a reason'); + }); + + it('sends an empty HX-Prompt when the answer is empty', async function() { + window.prompt = () => ''; + mockResponse('DELETE', '/items/1', 'ok'); + + let btn = createProcessedHTML(``); + btn.click(); + await forRequest(); + + assert.equal(lastFetch().request.headers.get('HX-Prompt'), ''); }); it('aborts the request on cancel', async function() { diff --git a/test/tests/ext/hx-sse.js b/test/tests/ext/hx-sse.js index e68358b9f..7b9d8c1c6 100644 --- a/test/tests/ext/hx-sse.js +++ b/test/tests/ext/hx-sse.js @@ -120,7 +120,7 @@ describe('hx-sse SSE extension', function() { assert.equal(lastEventIdSent, 'msg-123', 'Should send last event ID on reconnect'); const lastCall = lastFetch(); - assert.equal(lastCall.request.headers['Last-Event-ID'], 'msg-123', 'Last-Event-ID header should be set'); + assert.equal(lastCall.request.headers.get('Last-Event-ID'), 'msg-123', 'Last-Event-ID header should be set'); stream.close(); }); @@ -976,7 +976,7 @@ describe('hx-sse SSE extension', function() { // On reconnect, check Last-Event-ID and replay missed messages if (controllers.length > 1) { const calls = fetchMock.getCalls(); - const lastId = calls[calls.length - 1].request.headers?.['Last-Event-ID']; + const lastId = calls[calls.length - 1].request.headers?.get('Last-Event-ID'); if (lastId) { let start = allMessages.findIndex(m => m.id === lastId) + 1; for (let i = start; i < allMessages.length; i++) { @@ -1013,7 +1013,7 @@ describe('hx-sse SSE extension', function() { // Verify Last-Event-ID header was sent const reconnectCall = fetchMock.getCalls()[fetchMock.getCalls().length - 1]; - assert.equal(reconnectCall.request.headers['Last-Event-ID'], 'n-2', 'Should send Last-Event-ID of last received message'); + assert.equal(reconnectCall.request.headers.get('Last-Event-ID'), 'n-2', 'Should send Last-Event-ID of last received message'); // Server replayed n-3 on reconnect — verify it was swapped in await waitForEvent('htmx:after:sse:message', 1000); @@ -1055,7 +1055,7 @@ describe('hx-sse SSE extension', function() { find('button').click(); await htmx.timeout(1); - assert.equal(fetchMock.getLastCall().request.headers['Accept'], 'text/html, text/event-stream'); + assert.equal(fetchMock.getLastCall().request.headers.get('Accept'), 'text/html, text/event-stream'); stream.close(); }); diff --git a/test/tests/unit/HCON.js b/test/tests/unit/HCON.js index 73c038260..917267e4a 100644 --- a/test/tests/unit/HCON.js +++ b/test/tests/unit/HCON.js @@ -396,6 +396,52 @@ describe('HCON.merge unit tests', function() { assert.deepEqual(result.list, [3, 4]) }) + it('merges iterable key-value sources', function () { + let result = HCON.merge(new Map([['a', 1]]), {}) + assert.deepEqual(result, { a: 1 }) + }) + + it('sets key-value collection targets', function () { + let target = new Map([['a', 1]]) + let result = HCON.merge({ b: 2 }, target) + assert.strictEqual(result, target) + assert.equal(target.get('a'), 1) + assert.equal(target.get('b'), 2) + }) + + it('sets native Headers targets', function () { + let headers = new Headers({ 'HX-Request': 'true' }) + let result = HCON.merge({ 'X-Custom': 'value' }, headers) + assert.strictEqual(result, headers) + assert.equal(headers.get('X-Custom'), 'value') + assert.equal(headers.get('HX-Request'), 'true') + }) + + it('deep-merges into nested native Headers targets', function () { + let request = { headers: new Headers({ 'HX-Request': 'true' }) } + let result = HCON.merge({ headers: { 'X-Custom': 'value' } }, request) + assert.strictEqual(result, request) + assert.equal(request.headers.get('X-Custom'), 'value') + assert.equal(request.headers.get('HX-Request'), 'true') + }) + + it('sets native Headers targets from Headers sources', function () { + let source = new Headers({ 'X-Custom': 'value' }) + let headers = new Headers({ 'HX-Request': 'true' }) + let result = HCON.merge(source, headers) + assert.strictEqual(result, headers) + assert.equal(headers.get('X-Custom'), 'value') + assert.equal(headers.get('HX-Request'), 'true') + }) + + it('deep-merges nested Headers sources into native Headers targets', function () { + let request = { headers: new Headers({ 'HX-Request': 'true' }) } + let result = HCON.merge({ headers: new Headers({ 'X-Custom': 'value' }) }, request) + assert.strictEqual(result, request) + assert.equal(request.headers.get('X-Custom'), 'value') + assert.equal(request.headers.get('HX-Request'), 'true') + }) + // string source (auto-parse) it('parses an HCON string source', function () { diff --git a/test/tests/unit/ajax.js b/test/tests/unit/ajax.js index cb3ab30c7..a37e7a6e2 100644 --- a/test/tests/unit/ajax.js +++ b/test/tests/unit/ajax.js @@ -128,9 +128,10 @@ describe('ajax() unit Tests', function() { }); assert.equal(div.innerHTML, 'Clicked!'); const lastCall = lastFetch(); - assert.equal(lastCall.request.headers['X-Custom'], 'test-value'); + assert.equal(lastCall.request.headers.get('X-Custom'), 'test-value'); }); + it('ajax collects form data from source element', async function() { mockResponse('POST', '/test', 'Submitted!'); createProcessedHTML(''); diff --git a/test/tests/unit/headers.js b/test/tests/unit/headers.js index 6d675909f..520114822 100644 --- a/test/tests/unit/headers.js +++ b/test/tests/unit/headers.js @@ -8,30 +8,51 @@ describe('Request Headers', function() { cleanupTest(); }); + describe('Headers API', function() { + + it('uses a Headers-compatible request headers object', function() { + let btn = createProcessedHTML(''); + let ctx = htmx.__createRequestContext(btn, new Event('click')); + + assert.isTrue(ctx.request.headers instanceof Headers); + assert.equal(ctx.request.headers.get('Accept'), 'text/html'); + }); + + it('supports appending request header values', function() { + let btn = createProcessedHTML(''); + let ctx = htmx.__createRequestContext(btn, new Event('click')); + + ctx.request.headers.append('Accept', 'text/event-stream'); + + assert.equal(ctx.request.headers.get('Accept'), 'text/html, text/event-stream'); + }); + + }); + describe('HX-Source header', function() { it('formats element with id', function() { let btn = createProcessedHTML(''); let ctx = htmx.__createRequestContext(btn, new Event('click')); - ctx.request.headers['HX-Source'].should.equal('button#test-btn'); + ctx.request.headers.get('HX-Source').should.equal('button#test-btn'); }); it('formats element with neither id nor name', function() { let btn = createProcessedHTML(''); let ctx = htmx.__createRequestContext(btn, new Event('click')); - ctx.request.headers['HX-Source'].should.equal('button'); + ctx.request.headers.get('HX-Source').should.equal('button'); }); it('formats div element', function() { let div = createProcessedHTML(''); let ctx = htmx.__createRequestContext(div, new Event('click')); - ctx.request.headers['HX-Source'].should.equal('div#content'); + ctx.request.headers.get('HX-Source').should.equal('div#content'); }); it('formats form element', function() { let form = createProcessedHTML(''); let ctx = htmx.__createRequestContext(form, new Event('submit')); - ctx.request.headers['HX-Source'].should.equal('form#my-form'); + ctx.request.headers.get('HX-Source').should.equal('form#my-form'); }); }); @@ -43,21 +64,21 @@ describe('Request Headers', function() { let btn = document.querySelector('button'); let ctx = htmx.__createRequestContext(btn, new Event('click')); await htmx.__handleTriggerEvent(ctx); - ctx.request.headers['HX-Target'].should.equal('div#result'); + ctx.request.headers.get('HX-Target').should.equal('div#result'); }); it('formats target when targeting self', async function() { let btn = createProcessedHTML(''); let ctx = htmx.__createRequestContext(btn, new Event('click')); await htmx.__handleTriggerEvent(ctx); - ctx.request.headers['HX-Target'].should.equal('button#self-btn'); + ctx.request.headers.get('HX-Target').should.equal('button#self-btn'); }); it('formats body target', async function() { let btn = createProcessedHTML(''); let ctx = htmx.__createRequestContext(btn, new Event('click')); await htmx.__handleTriggerEvent(ctx); - ctx.request.headers['HX-Target'].should.equal('body'); + ctx.request.headers.get('HX-Target').should.equal('body'); }); }); @@ -69,35 +90,35 @@ describe('Request Headers', function() { let btn = document.querySelector('button'); let ctx = htmx.__createRequestContext(btn, new Event('click')); await htmx.__handleTriggerEvent(ctx); - ctx.request.headers['HX-Request-Type'].should.equal('partial'); + ctx.request.headers.get('HX-Request-Type').should.equal('partial'); }); it('sets to partial when targeting self', async function() { let btn = createProcessedHTML(''); let ctx = htmx.__createRequestContext(btn, new Event('click')); await htmx.__handleTriggerEvent(ctx); - ctx.request.headers['HX-Request-Type'].should.equal('partial'); + ctx.request.headers.get('HX-Request-Type').should.equal('partial'); }); it('sets to full when targeting body', async function() { let btn = createProcessedHTML(''); let ctx = htmx.__createRequestContext(btn, new Event('click')); await htmx.__handleTriggerEvent(ctx); - ctx.request.headers['HX-Request-Type'].should.equal('full'); + ctx.request.headers.get('HX-Request-Type').should.equal('full'); }); it('sets to full when hx-select is present', async function() { let btn = createProcessedHTML(''); let ctx = htmx.__createRequestContext(btn, new Event('click')); await htmx.__handleTriggerEvent(ctx); - ctx.request.headers['HX-Request-Type'].should.equal('full'); + ctx.request.headers.get('HX-Request-Type').should.equal('full'); }); it('sets to full when hx-select and body target both present', async function() { let btn = createProcessedHTML(''); let ctx = htmx.__createRequestContext(btn, new Event('click')); await htmx.__handleTriggerEvent(ctx); - ctx.request.headers['HX-Request-Type'].should.equal('full'); + ctx.request.headers.get('HX-Request-Type').should.equal('full'); }); }); diff --git a/www/src/content/docs.mdx b/www/src/content/docs.mdx index db180cd70..99d174a82 100644 --- a/www/src/content/docs.mdx +++ b/www/src/content/docs.mdx @@ -677,7 +677,7 @@ handle_swap: (swapStyle, target, fragment, swapSpec) => { All hooks receive `detail.ctx` with full request/response context: - `detail.ctx.request.body` (FormData in `htmx_config_request`) -- `detail.ctx.request.headers` +- `detail.ctx.request.headers` (a [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) instance) - `detail.ctx.response.status` - `detail.ctx.text` (response body, modifiable in `htmx_after_request`) - `detail.ctx.target` @@ -809,7 +809,7 @@ encodeParameters: function(xhr, parameters, elt) { // htmx 4 htmx_config_request: (elt, detail) => { - detail.ctx.request.headers['Content-Type'] = 'application/json'; + detail.ctx.request.headers.set('Content-Type', 'application/json'); var object = {}; detail.ctx.request.body.forEach(function(value, key) { if (Object.hasOwn(object, key)) { @@ -1606,7 +1606,7 @@ before it is issued: ```javascript document.body.addEventListener('htmx:config:request', function (evt) { evt.detail.ctx.request.parameters['auth_token'] = getAuthToken(); // add a new parameter into the request - evt.detail.ctx.request.headers['Authentication-Token'] = getAuthToken(); // add a new header into the request + evt.detail.ctx.request.headers.set('Authentication-Token', getAuthToken()); // add a new header into the request }); ``` @@ -2897,7 +2897,7 @@ The `detail.ctx` object contains request information: request: { action, // Request URL method, // HTTP method - headers, // Request headers + headers, // Request Headers instance body, // Request body (FormData) validate, // Whether to validate abort, // Function to abort request @@ -2906,7 +2906,7 @@ The `detail.ctx` object contains request information: response: { // Available after request raw, // Raw Response object status, // HTTP status code - headers // Response headers + headers // Response Headers instance }, text, // Response text (after request) hx // HX-* response headers (parsed) diff --git a/www/src/content/extensions/16-hx-prompt.md b/www/src/content/extensions/16-hx-prompt.md index 45895f90a..dcfef5f77 100644 --- a/www/src/content/extensions/16-hx-prompt.md +++ b/www/src/content/extensions/16-hx-prompt.md @@ -101,7 +101,7 @@ If you'd rather skip the extension, a single `hx-on` can do the same thing: ```html ``` diff --git a/www/src/content/reference/03-events/01-htmx-config-request.md b/www/src/content/reference/03-events/01-htmx-config-request.md index 1a6952079..d6cb9930a 100644 --- a/www/src/content/reference/03-events/01-htmx-config-request.md +++ b/www/src/content/reference/03-events/01-htmx-config-request.md @@ -28,7 +28,7 @@ The event provides a `ctx` object with the following structure: validate, // Whether to validate the form action, // Request URL method, // HTTP method - headers, // Request headers object + headers, // Request Headers instance body, // Request body (FormData) credentials, // Fetch credentials mode mode, // Fetch mode @@ -39,7 +39,7 @@ The event provides a `ctx` object with the following structure: } ``` -You can modify any property to change the request. +You can modify any property to change the request. `ctx.request.headers` is a request [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) instance. Call `evt.preventDefault()` to cancel the request. @@ -49,7 +49,7 @@ Call `evt.preventDefault()` to cancel the request. ```javascript document.body.addEventListener('htmx:config:request', function(evt) { - evt.detail.ctx.request.headers['X-Auth-Token'] = getAuthToken(); + evt.detail.ctx.request.headers.set('X-Auth-Token', getAuthToken()); }); ``` diff --git a/www/src/content/reference/03-events/35-htmx-before-response.md b/www/src/content/reference/03-events/35-htmx-before-response.md index 276978f92..04a7ce83a 100644 --- a/www/src/content/reference/03-events/35-htmx-before-response.md +++ b/www/src/content/reference/03-events/35-htmx-before-response.md @@ -13,7 +13,7 @@ After the network response is received, before the body is consumed or any swap - `ctx.response.raw` - The raw Response object (body not yet consumed) - `ctx.response.status` - The HTTP status code -- `ctx.response.headers` - The response headers +- `ctx.response.headers` - Response [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) instance ## Example diff --git a/www/src/content/reference/03-events/38-htmx-response-error.md b/www/src/content/reference/03-events/38-htmx-response-error.md index d64764e06..6166c6c46 100644 --- a/www/src/content/reference/03-events/38-htmx-response-error.md +++ b/www/src/content/reference/03-events/38-htmx-response-error.md @@ -15,7 +15,7 @@ This event does **not** fire for network errors or timeouts — use [`htmx:error - `ctx` - The full request context, including: - `ctx.response.status` - The HTTP status code - - `ctx.response.headers` - Response headers + - `ctx.response.headers` - Response [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) instance - `ctx.text` - The response body ## Example