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
2 changes: 1 addition & 1 deletion src/editors/jetbrains/htmx.web-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/ext/hx-prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
});
})();
2 changes: 1 addition & 1 deletion src/ext/hx-ptag.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}) {
Expand Down
4 changes: 2 additions & 2 deletions src/ext/hx-sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/ext/hx-ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
115 changes: 102 additions & 13 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -225,7 +232,6 @@ var htmx = (() => {
if (metaConfig) {
HCON.merge(metaConfig.content, this.config);
}
this.__approvedExt = this.config.extensions;
}

__initRequestIndicatorCss() {
Expand Down Expand Up @@ -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
Expand All @@ -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});
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
4 changes: 2 additions & 2 deletions src/skills/htmx-extension-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/skills/htmx-guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
```
Expand Down
2 changes: 1 addition & 1 deletion src/skills/htmx-upgrade-from-htmx2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
});
Expand Down
2 changes: 1 addition & 1 deletion test/manual/cors.html
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
</script>
Expand Down
18 changes: 16 additions & 2 deletions test/tests/attributes/hx-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,21 @@ describe('hx-config attribute', function() {
let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'{"headers": {"X-Custom": "value"}}\'>Click</button>');
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('<button hx-get="/test" hx-config="headers.X-Custom:value">Click</button>');
btn.click()
await forRequest()
assert.equal(ctx.request.headers.get('X-Custom'), 'value')
assert.equal(ctx.request.headers.get('HX-Request'), 'true')
})
})
24 changes: 12 additions & 12 deletions test/tests/attributes/hx-headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('hx-headers attribute', function() {
let div = createProcessedHTML("<div hx-post='/vars' hx-headers='\"i1\":\"test\"'></div>")
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!')
})

Expand All @@ -22,7 +22,7 @@ describe('hx-headers attribute', function() {
let div = createProcessedHTML('<div hx-post="/vars" hx-headers=\'{"i1":"test"}\'></div>')
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!')
})

Expand All @@ -31,8 +31,8 @@ describe('hx-headers attribute', function() {
let div = createProcessedHTML('<div hx-post="/vars" hx-headers=\'{"v1":"test", "v2":"42"}\'></div>')
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!')
})

Expand All @@ -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!')
})

Expand All @@ -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!')
})

Expand All @@ -61,7 +61,7 @@ describe('hx-headers attribute', function() {
let div = createProcessedHTML('<div hx-post="/vars" hx-headers="javascript:i1:\'test\'"></div>')
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!')
})

Expand All @@ -70,7 +70,7 @@ describe('hx-headers attribute', function() {
let div = createProcessedHTML('<div hx-post="/vars" hx-headers="javascript:{i1:\'test\'}"></div>')
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!')
})

Expand All @@ -79,8 +79,8 @@ describe('hx-headers attribute', function() {
let div = createProcessedHTML('<div hx-post="/vars" hx-headers="javascript:v1:\'test\', v2:42"></div>')
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!')
})

Expand All @@ -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!')
})

Expand All @@ -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!')
})

Expand Down
Loading
Loading