From b85b6467980b7a7c7e0e43bb88edb062986b6cc7 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 21 Jul 2026 21:11:08 +0300 Subject: [PATCH 1/9] Improve WebSocket extension --- src/editors/jetbrains/htmx.web-types.json | 34 +- src/ext/hx-ws.js | 229 +++++--- src/scripts/upgrade-check.py | 12 +- test/manual/WS_README.md | 47 +- test/manual/ws-server.js | 28 +- test/manual/ws.html | 10 +- test/tests/ext/hx-ws.js | 619 +++++++++++++++------- www/src/content/extensions/03-hx-ws.md | 482 ++++++++++++----- 8 files changed, 984 insertions(+), 477 deletions(-) diff --git a/src/editors/jetbrains/htmx.web-types.json b/src/editors/jetbrains/htmx.web-types.json index 64af559bd..cfefde7fc 100644 --- a/src/editors/jetbrains/htmx.web-types.json +++ b/src/editors/jetbrains/htmx.web-types.json @@ -566,18 +566,18 @@ "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxafterssemessage" }, { - "name": "before:ws:connection", + "name": "ws:before:connection", "description": "Fires before a WebSocket connection attempt. `detail.connection` can be modified; set `cancelled` or cancel the event to stop connecting.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxbeforewsconnection" + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsbeforeconnection" }, { - "name": "after:ws:connection", + "name": "ws:after:connection", "description": "Fires after a successful WebSocket connection. `detail.connection` describes the connection.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxafterwsconnection" + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsafterconnection" }, { "name": "ws:close", - "description": "Fires when a WebSocket connection closes. `detail.connection`, `detail.reason`, and `detail.code` describe the close.", + "description": "Fires when a WebSocket connection closes. `detail.connection`, `detail.reason`, and `detail.code` describe the close. Codes in `detail.connection.config.reconnectCodes` reconnect.", "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsclose" }, { @@ -586,24 +586,24 @@ "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwserror" }, { - "name": "before:ws:request", - "description": "Fires before sending a WebSocket message. `detail.headers` and `detail.body` are modifiable. Cancel to skip sending.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxbeforewsrequest" + "name": "ws:before:message:outgoing", + "description": "Fires before sending a WebSocket message. Modify `detail.message`, use `detail.waitUntil()` to delay sending, or set `detail.cancelled` to cancel.", + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsbeforemessageoutgoing" }, { - "name": "after:ws:request", - "description": "Fires after a WebSocket message is sent. `detail.headers` and `detail.body` contain the sent payload.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxafterwsrequest" + "name": "ws:after:message:outgoing", + "description": "Fires after a WebSocket message is sent. `detail.message.data` is the value passed to `WebSocket.send()`.", + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsaftermessageoutgoing" }, { - "name": "before:ws:message", - "description": "Fires before a WebSocket message is processed. `detail.message.text`, `detail.message.json`, and `detail.message.cancelled` are available.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxbeforewsmessage" + "name": "ws:before:message:incoming", + "description": "Fires before an incoming WebSocket message is processed. Convert `detail.message`, use `detail.waitUntil()` to delay processing, or set `detail.cancelled` to cancel. Associated messages fire from their sending element.", + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsbeforemessageincoming" }, { - "name": "after:ws:message", - "description": "Fires after a WebSocket message is processed. `detail.message.text` and `detail.message.json` are available.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxafterwsmessage" + "name": "ws:after:message:incoming", + "description": "Fires after an incoming WebSocket message is processed. `detail.message.data` preserves the native data; `text()`, `json()`, `blob()`, and `arrayBuffer()` convert it. Associated messages fire from their sending element.", + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsaftermessageincoming" }, { "name": "download:start", diff --git a/src/ext/hx-ws.js b/src/ext/hx-ws.js index 3138c6bad..a84ecba2f 100644 --- a/src/ext/hx-ws.js +++ b/src/ext/hx-ws.js @@ -15,29 +15,20 @@ // ======================================== function getConfig(element) { - const defaults = { + let hxConfig = api.HCON.parse(api.attributeValue(element, 'hx-config')).ws || {}; + + return { reconnect: true, + reconnectCodes: [1006, 1011, 1012, 1013], reconnectDelay: 500, reconnectMaxDelay: 60000, reconnectMaxAttempts: Infinity, reconnectJitter: 0.3, pauseOnBackground: true, - pendingRequestTTL: 30000 + pendingRequestTTL: 30000, + ...htmx.config.ws, // global defaults + ...hxConfig // hx-config overrides }; - let global = htmx.config.ws || {}; - let perElement = {}; - if (element) { - let ctx = api.createRequestContext(element, new CustomEvent('_')); - perElement = ctx.request.ws || {}; - } - let merged = { ...defaults, ...global, ...perElement }; - - // Backwards compat: boolean reconnectJitter (old API used true/false) - if (typeof merged.reconnectJitter === 'boolean') { - merged.reconnectJitter = merged.reconnectJitter ? 0.3 : 0; - } - - return merged; } // ======================================== @@ -97,12 +88,13 @@ attempt: 0, timer: null, pendingRequests: new Map(), + queue: [], abortController: null, visibilityHandler: null, cancelled: false }; - if (!api.triggerHtmxEvent(element, 'htmx:before:ws:connection', {connection}) || connection.cancelled) { + if (!api.triggerHtmxEvent(element, 'htmx:ws:before:connection', {connection}) || connection.cancelled) { api.triggerHtmxEvent(element, 'htmx:ws:close', { connection, reason: 'cancelled', code: null }); @@ -149,6 +141,7 @@ connection.abortController.abort(); } connection.pendingRequests.clear(); + connection.queue.length = 0; if (connection.socket) { try { if (connection.socket.readyState === WebSocket.OPEN || connection.socket.readyState === WebSocket.CONNECTING) { @@ -187,17 +180,21 @@ connection.socket.addEventListener('open', () => { let elt = findConnectedElement(url); if (elt) { - api.triggerHtmxEvent(elt, 'htmx:after:ws:connection', {connection}); + api.triggerHtmxEvent(elt, 'htmx:ws:after:connection', {connection}); } else { // Element was removed while connecting β€” orphaned socket cleanupOrphanedConnection(url, connection); return; } connection.attempt = 0; + flushQueue(connection); }, opts); connection.socket.addEventListener('message', (event) => { - handleMessage(connection, event); + handleMessage(connection, event).catch(error => { + let elt = findConnectedElement(connection.url); + if (elt) api.triggerHtmxEvent(elt, 'htmx:ws:error', { url: connection.url, error }); + }); }, opts); connection.socket.addEventListener('close', (event) => { @@ -213,7 +210,7 @@ let config = connection.config; if (config.pauseOnBackground && document.hidden) return; - if (config.reconnect && findConnectedElement(url)) { + if (config.reconnect && config.reconnectCodes.includes(event.code) && findConnectedElement(url)) { scheduleReconnect(url, connection); } else { // No element or reconnect disabled β€” full cleanup @@ -259,7 +256,7 @@ let elt = findConnectedElement(url); if (elt) { connection.cancelled = false; - if (!api.triggerHtmxEvent(elt, 'htmx:before:ws:connection', {connection}) || connection.cancelled) { + if (!api.triggerHtmxEvent(elt, 'htmx:ws:before:connection', {connection}) || connection.cancelled) { api.triggerHtmxEvent(elt, 'htmx:ws:close', { connection, reason: 'cancelled', code: null }); @@ -293,6 +290,7 @@ connection.abortController.abort(); } connection.pendingRequests.clear(); + connection.queue.length = 0; api.triggerHtmxEvent(element, 'htmx:ws:close', { connection, reason: 'removed', code: null }); @@ -321,6 +319,23 @@ // REQUESTS // ======================================== + function sendMessage(connection, element, message, requestId) { + try { + connection.socket.send(message.data); + connection.pendingRequests.set(requestId, { element, timestamp: Date.now() }); + api.triggerHtmxEvent(element, 'htmx:ws:after:message:outgoing', {message}); + } catch (error) { + api.triggerHtmxEvent(element, 'htmx:ws:error', { url: connection.url, error }); + } + } + + function flushQueue(connection) { + while (connection.queue.length && connection.socket?.readyState === WebSocket.OPEN) { + let queuedMessage = connection.queue.shift(); + sendMessage(connection, queuedMessage.element, queuedMessage.message, queuedMessage.requestId); + } + } + async function sendRequest(element, event) { // hx-ws:send="/url" creates its own connection; hx-ws:send (no value) uses ancestor's let sendAttr = api.attributeValue(element, 'hx-ws:send'); @@ -342,16 +357,7 @@ let normalizedUrl = normalizeWebSocketUrl(url); let connection = connections.get(normalizedUrl); - // Wait for socket to open if still connecting - if (connection && connection.socket && connection.socket.readyState === WebSocket.CONNECTING) { - await new Promise(resolve => { - connection.socket.addEventListener('open', resolve, { once: true }); - connection.socket.addEventListener('close', resolve, { once: true }); - connection.socket.addEventListener('error', resolve, { once: true }); - }); - } - - if (!connection || !connection.socket || connection.socket.readyState !== WebSocket.OPEN) { + if (!connection) { api.triggerHtmxEvent(element, 'htmx:ws:error', { url: normalizedUrl, error: 'Connection not open' }); return; } @@ -368,36 +374,55 @@ let requestId = crypto.randomUUID(); headers['HX-Request-ID'] = requestId; - // Build body from form data + // Build outgoing values from form data. let form = element.form || element.closest('form'); let formData = api.collectFormData(element, form, event.submitter); // Preserve multi-value form fields (checkboxes, multi-selects) - let body = {}; + let values = {}; for (let [key, value] of formData) { - if (key in body) { - body[key] = [].concat(body[key], value); + if (key in values) { + values[key] = [].concat(values[key], value); } else { - body[key] = value; + values[key] = value; } } // Merge hx-vals after serialization to preserve JS types (numbers, booleans) - let valsResult = api.getAttributeObject(element, 'hx-vals', obj => Object.assign(body, obj)); - if (valsResult) await valsResult; - - let detail = { headers, body }; - if (!api.triggerHtmxEvent(element, 'htmx:before:ws:request', detail)) { - return; - } + let hxValsResult = api.getAttributeObject(element, 'hx-vals', obj => Object.assign(values, obj)); + if (hxValsResult) await hxValsResult; + delete values.headers; + + let pendingWork = []; + let message = { + headers, + values, + data: undefined + }; + let detail = { + message, + cancelled: false, + waitUntil(promise) { + pendingWork.push(Promise.resolve(promise)); + } + }; + let shouldSend = api.triggerHtmxEvent(element, 'htmx:ws:before:message:outgoing', detail); try { - connection.socket.send(JSON.stringify(detail)); + await Promise.all(pendingWork); + if (!shouldSend || detail.cancelled) return; - // [Correlation] Store pending request for response matching - connection.pendingRequests.set(requestId, { element, timestamp: Date.now() }); + message.data ??= JSON.stringify({ ...message.values, headers: message.headers }); + if (connections.get(normalizedUrl) !== connection) { + api.triggerHtmxEvent(element, 'htmx:ws:error', { url: normalizedUrl, error: 'Connection closed' }); + return; + } - api.triggerHtmxEvent(element, 'htmx:after:ws:request', detail); + if (connection.socket?.readyState === WebSocket.OPEN) { + sendMessage(connection, element, message, requestId); + } else { + connection.queue.push({element, message, requestId}); + } } catch (error) { api.triggerHtmxEvent(element, 'htmx:ws:error', { url: normalizedUrl, error }); } @@ -407,78 +432,113 @@ // MESSAGE RECEIVING & ROUTING // ======================================== - function handleMessage(connection, event) { + async function handleMessage(connection, event) { + let data = event.data; + let textResult; + let jsonResult; + let arrayBufferResult; + let blobResult; + let pendingWork = []; + let message = { + data, + type: typeof data === 'string' ? 'text' : 'binary', + text() { + return textResult ??= typeof data === 'string' + ? Promise.resolve(data) + : data instanceof Blob + ? data.text() + : Promise.resolve(new TextDecoder().decode(data)); + }, + json() { + return jsonResult ??= message.text().then(JSON.parse); + }, + arrayBuffer() { + return arrayBufferResult ??= data instanceof ArrayBuffer + ? Promise.resolve(data) + : data instanceof Blob + ? data.arrayBuffer() + : Promise.resolve(new TextEncoder().encode(data).buffer); + }, + blob() { + return blobResult ??= data instanceof Blob + ? Promise.resolve(data) + : Promise.resolve(new Blob([data])); + } + }; + let json = null; - try { - json = JSON.parse(event.data); - } catch (e) { - // Not JSON - will be treated as raw HTML below + if (message.type === 'text') { + try { + json = await message.json(); + } catch (e) { + // Non-JSON text is treated as raw HTML. + } } // [Correlation] Cleanup expired pending requests on every message cleanupExpiredRequests(connection); - // [Correlation] Match response to originating element, or fall back to first subscriber - let connectionElement = null; - let requestId = json?.['HX-Request-ID'] || json?.request_id; - if (requestId && connection.pendingRequests.has(requestId)) { - connectionElement = connection.pendingRequests.get(requestId).element; - connection.pendingRequests.delete(requestId); - // If the correlated element has been removed from the DOM, fall back - if (!connectionElement.isConnected) { - connectionElement = findConnectedElement(connection.url); - } - } else { - connectionElement = findConnectedElement(connection.url); - } + let requestId = json?.headers?.['HX-Request-ID']; + let pending = connection.pendingRequests.get(requestId); + if (pending) connection.pendingRequests.delete(requestId); - if (!connectionElement) { + // Route associated incoming messages through their sender. + let element = pending?.element; + if (!element?.isConnected) element = findConnectedElement(connection.url); + + if (!element) { // No element in DOM for this connection β€” orphan cleanup cleanupOrphanedConnection(connection.url, connection); return; } let detail = { - message: { text: event.data, json, cancelled: false } + message, + cancelled: false, + waitUntil(promise) { + pendingWork.push(Promise.resolve(promise)); + } }; + let shouldProcess = api.triggerHtmxEvent(element, 'htmx:ws:before:message:incoming', detail); - if (!api.triggerHtmxEvent(connectionElement, 'htmx:before:ws:message', detail) || detail.message.cancelled) { - return; - } + await Promise.all(pendingWork); + if (!shouldProcess || detail.cancelled) return; // JSON with 'content' or 'payload' field: swap the HTML // Raw (non-JSON) string: swap the entire string as HTML // JSON without 'content'/'payload': data-only message, no swap (handle via events) let html; - if (detail.message.json) { - if (detail.message.json.content !== undefined) { - html = detail.message.json.content; - } else if (detail.message.json.payload !== undefined) { - html = detail.message.json.payload; // backwards compat + if (json) { + if (json.content !== undefined) { + html = json.content; + } else if (json.payload !== undefined) { + html = json.payload; // backwards compat // Warn once per connection (not on every message) if (!connection._payloadWarnFired) { console.warn('htmx: [hx-ws] json.payload is deprecated; use json.content instead'); connection._payloadWarnFired = true; } } - } else { - html = detail.message.text; + } else if (message.type === 'text') { + html = await message.text(); } if (html != null) { - let target = detail.message.json?.target || api.attributeValue(connectionElement, 'hx-target'); - let swap = detail.message.json?.swap || api.attributeValue(connectionElement, 'hx-swap'); + let target = json?.target || api.attributeValue(element, 'hx-target'); + let swap = json?.swap || api.attributeValue(element, 'hx-swap') || htmx.config.defaultSwap; + if (!/(?:^|\s)swapEmpty(?::(?:true|false))?(?=\s|$)/.test(swap)) swap += ' swapEmpty:false'; htmx.swap({ - sourceElement: connectionElement, - target: target || connectionElement, - swap: swap || (target ? htmx.config.defaultSwap : 'none'), + sourceElement: element, + target: target || element, + swap, + select: json?.select ?? api.attributeValue(element, 'hx-select'), + selectOOB: api.attributeValue(element, 'hx-select-oob'), text: html, transition: false }); } - delete detail.message.cancelled; - api.triggerHtmxEvent(connectionElement, 'htmx:after:ws:message', detail); + api.triggerHtmxEvent(element, 'htmx:ws:after:message:incoming', {message}); } // ======================================== @@ -637,6 +697,7 @@ connection.socket.close(); } connection.pendingRequests.clear(); + connection.queue.length = 0; }); }, get: (key) => connections.get(normalizeWebSocketUrl(key)), diff --git a/src/scripts/upgrade-check.py b/src/scripts/upgrade-check.py index a91f939c8..33eae8612 100755 --- a/src/scripts/upgrade-check.py +++ b/src/scripts/upgrade-check.py @@ -90,13 +90,13 @@ } WS_EVENT_RENAMES = { - "htmx:wsOpen": "htmx:after:ws:connection", + "htmx:wsOpen": "htmx:ws:after:connection", "htmx:wsClose": "htmx:ws:close", - "htmx:wsConfigSend": "htmx:before:ws:request", - "htmx:wsBeforeSend": "htmx:before:ws:request", - "htmx:wsAfterSend": "htmx:after:ws:request", - "htmx:wsBeforeMessage": "htmx:before:ws:message", - "htmx:wsAfterMessage": "htmx:after:ws:message", + "htmx:wsConfigSend": "htmx:ws:before:message:outgoing", + "htmx:wsBeforeSend": "htmx:ws:before:message:outgoing", + "htmx:wsAfterSend": "htmx:ws:after:message:outgoing", + "htmx:wsBeforeMessage": "htmx:ws:before:message:incoming", + "htmx:wsAfterMessage": "htmx:ws:after:message:incoming", } # Extension attribute renames diff --git a/test/manual/WS_README.md b/test/manual/WS_README.md index d10bb32d5..18db45ac6 100644 --- a/test/manual/WS_README.md +++ b/test/manual/WS_README.md @@ -27,7 +27,7 @@ A beautiful, comprehensive demonstration of the `hx-ws` extension showcasing rea ### 2. **Live Notifications** - Receive random notifications every 5-8 seconds - Shows real-time server push -- Uses `beforeend` swap to prepend new notifications +- Uses `afterbegin` to prepend new notifications ### 3. **Shared Counter** - Multiple clients share the same counter state @@ -64,32 +64,40 @@ A beautiful, comprehensive demonstration of the `hx-ws` extension showcasing rea ## 🎨 Key Concepts -### HTML Partial Format +### HTML Message Format -Server messages use this format: +Server messages use `content` for HTML and may specify a target and serialized swap specification: ```json { - "channel": "ui", - "format": "html", - "payload": "Content" + "content": "

Content

", + "target": "#target-id", + "swap": "beforeend settle:10ms" } ``` -### Request/Response Pattern +### Message Flow Client sends: ```json { - "type": "request", - "request_id": "uuid-here", - "values": { - "message": "Hello!" - } + "headers": { + "HX-Request-ID": "uuid-here" + }, + "message": "Hello!" } ``` -Server responds with matching `request_id` to target the originating element. +The server copies `HX-Request-ID` into the incoming message: + +```json +{ + "headers": { + "HX-Request-ID": "uuid-here" + }, + "content": "

Saved

" +} +``` ### Multiple Partials @@ -112,7 +120,9 @@ htmx.config.ws = { reconnectMaxDelay: 60000, // Max delay (ms) reconnectMaxAttempts: Infinity,// Max reconnect attempts reconnectJitter: 0.3, // Jitter factor (0-1) - pauseOnBackground: true // Pause connection when tab is backgrounded + pauseOnBackground: true, // Pause connection when tab is backgrounded + pendingRequestTTL: 30000, // Discard unmatched requests after this many ms + protocols: null // Optional WebSocket subprotocols }; ``` @@ -143,7 +153,7 @@ htmx.config.ws = { ### Button Actions ```html - ``` @@ -160,8 +170,9 @@ htmx.config.ws = { ## πŸ› Debugging The demo includes a live event log that shows: -- Connection events (`htmx:before:ws:connection`, `htmx:after:ws:connection`) -- Message events (`htmx:before:ws:send`, `htmx:after:ws:message`) +- Connection events (`htmx:ws:before:connection`, `htmx:ws:after:connection`) +- Outgoing message events (`htmx:ws:before:message:outgoing`, `htmx:ws:after:message:outgoing`) +- Message events (`htmx:ws:before:message:incoming`, `htmx:ws:after:message:incoming`) - Error events (`htmx:ws:error`, `htmx:ws:close`) ## 🀝 Contributing @@ -176,7 +187,7 @@ Try modifying the demos to learn: ## πŸ“– Documentation For full documentation, visit: -- [HTMX WebSocket Extension Docs](https://htmx.org/extensions/websockets/) +- [htmx WebSocket Extension Docs](https://four.htmx.org/extensions/hx-ws) - [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) ## πŸŽ‰ Have Fun! diff --git a/test/manual/ws-server.js b/test/manual/ws-server.js index 7232fb2eb..06fe9a7ec 100644 --- a/test/manual/ws-server.js +++ b/test/manual/ws-server.js @@ -118,7 +118,7 @@ wss.on('connection', (ws, req) => { function handleChatConnection(ws) { // Send welcome message (no hx-partial, let hx-swap handle it) ws.send(JSON.stringify({ - payload: '
πŸ‘‹ Welcome to the chat!
' + new Date().toLocaleTimeString() + '
' + content: '
πŸ‘‹ Welcome to the chat!
' + new Date().toLocaleTimeString() + '
' })); } @@ -143,7 +143,7 @@ function handleNotificationsConnection(ws) { const html = `
${notification}
${new Date().toLocaleTimeString()}
`; broadcast('notifications', { - payload: html + content: html }); setTimeout(sendNotification, 5000 + Math.random() * 3000); @@ -156,7 +156,7 @@ function handleNotificationsConnection(ws) { function handleCounterConnection(ws) { // Send current counter value ws.send(JSON.stringify({ - payload: `${counter}` + content: `${counter}` })); } @@ -172,7 +172,7 @@ function handleTickerConnection(ws) { ).join(''); ws.send(JSON.stringify({ - payload: `${html}` + content: `${html}` })); // Update prices every 2-3 seconds @@ -196,7 +196,7 @@ function handleTickerConnection(ws) { }).join(''); broadcast('ticker', { - payload: `${html}` + content: `${html}` }); setTimeout(updatePrices, 2000 + Math.random() * 1000); @@ -214,7 +214,7 @@ function handleDashboardConnection(ws) { const disk = Math.floor(Math.random() * 100); broadcast('dashboard', { - payload: ` + content: ` CPU: ${cpu}% Memory: ${memory}% Disk: ${disk}% @@ -233,15 +233,15 @@ function handleMessage(ws, data) { if (ws.channel === 'chat') { // Broadcast chat message - if (data.values && data.values.message) { - const message = data.values.message; + if (data.message) { + const message = data.message; // Don't use hx-partial - just send raw HTML and let hx-swap="beforeend" handle it const html = `
${escapeHtml(message)}
${new Date().toLocaleTimeString()}
`; // Echo back to sender ws.send(JSON.stringify({ - payload: html, - request_id: data.request_id + headers: { 'HX-Request-ID': data.headers?.['HX-Request-ID'] }, + content: html })); // Simulate bot response after 1 second @@ -259,13 +259,13 @@ function handleMessage(ws, data) { const botHtml = `
πŸ€– ${botResponse}
${new Date().toLocaleTimeString()}
`; broadcast('chat', { - payload: botHtml + content: botHtml }); }, 1000); } } else if (ws.channel === 'counter') { // Handle counter actions - const action = data.values?.action || data.action; + const action = data.action; if (action === 'increment') { counter++; @@ -277,8 +277,8 @@ function handleMessage(ws, data) { // Broadcast new counter value to all clients broadcast('counter', { - payload: `${counter}`, - request_id: data.request_id + headers: { 'HX-Request-ID': data.headers?.['HX-Request-ID'] }, + content: `${counter}` }); } } diff --git a/test/manual/ws.html b/test/manual/ws.html index 7599d659f..8ee28bf4c 100644 --- a/test/manual/ws.html +++ b/test/manual/ws.html @@ -385,7 +385,7 @@

} // Listen to WebSocket events - document.addEventListener('htmx:before:ws:connection', (e) => { + document.addEventListener('htmx:ws:before:connection', (e) => { let attempt = e.detail.connection.attempt; if (attempt === 0) { logEvent('CONNECT', `Connecting to ${e.detail.connection.url}`); @@ -394,7 +394,7 @@

} }); - document.addEventListener('htmx:after:ws:connection', (e) => { + document.addEventListener('htmx:ws:after:connection', (e) => { logEvent('CONNECTED', `Connected to ${e.detail.url}`); }); @@ -406,16 +406,16 @@

logEvent('ERROR', `WebSocket error: ${e.detail.url}`); }); - document.addEventListener('htmx:before:ws:send', (e) => { + document.addEventListener('htmx:ws:before:message:outgoing', (e) => { logEvent('SEND', `Sending message`); }); - document.addEventListener('htmx:after:ws:message', (e) => { + document.addEventListener('htmx:ws:after:message:incoming', (e) => { logEvent('MESSAGE', `Received message`); }); // Clear chat input after sending - document.addEventListener('htmx:after:ws:send', (e) => { + document.addEventListener('htmx:ws:after:message:outgoing', (e) => { if (e.target.matches('form')) { const input = e.target.querySelector('input[name="message"]'); if (input) { diff --git a/test/tests/ext/hx-ws.js b/test/tests/ext/hx-ws.js index 0821f7670..36133a39c 100644 --- a/test/tests/ext/hx-ws.js +++ b/test/tests/ext/hx-ws.js @@ -44,6 +44,8 @@ describe('hx-ws WebSocket extension', function() { throw new Error('WebSocket is not open'); } this.lastSent = data; + this.sentMessages ??= []; + this.sentMessages.push(data); } close(code = 1000, reason = '') { @@ -210,12 +212,9 @@ describe('hx-ws WebSocket extension', function() { assert.equal(mockWebSocketInstances.length, 1); let ws = mockWebSocketInstances[0]; - - await htmx.swap({ - text: '', - target: document.getElementById('container'), - swap: 'innerHTML' - }); + let target = document.getElementById('container'); + + await htmx.swap({ text: '', target, swap: 'innerHTML', sourceElement: target }); await htmx.timeout(50); assert.equal(ws.readyState, mockWebSocket.CLOSED); @@ -231,12 +230,9 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let ws = mockWebSocketInstances[0]; - - await htmx.swap({ - text: '', - target: document.getElementById('div1'), - swap: 'delete' - }); + let target = document.getElementById('div1'); + + await htmx.swap({ text: '', target, swap: 'delete', sourceElement: target }); await htmx.timeout(50); assert.equal(ws.readyState, mockWebSocket.OPEN); @@ -259,11 +255,8 @@ describe('hx-ws WebSocket extension', function() { // Remove div1 (the element captured as firstElement in createWebSocket) // but keep the connection alive via div2 - await htmx.swap({ - text: '', - target: document.getElementById('div1'), - swap: 'delete' - }); + let target = document.getElementById('div1'); + await htmx.swap({ text: '', target, swap: 'delete', sourceElement: target }); await htmx.timeout(50); // Trigger an error on the still-open socket @@ -282,18 +275,56 @@ describe('hx-ws WebSocket extension', function() { describe('Message Sending', function() { - it('sends message on load trigger (waits for socket open)', async function() { + it('queues a message until the initial connection opens', async function() { let div = createProcessedHTML(`
`); - await htmx.timeout(50); + await htmx.timeout(1); let ws = mockWebSocketInstances[0]; - assert.isDefined(ws.lastSent, 'Should have sent a message on load'); - let sent = JSON.parse(ws.lastSent); - assert.equal(sent.body.test, 'load'); + let connection = htmx.ext.ws.getRegistry().get('/ws/test'); + assert.equal(connection.queue.length, 1); + assert.isUndefined(ws.lastSent); + + await htmx.timeout(30); + + assert.equal(connection.queue.length, 0); + assert.equal(JSON.parse(ws.lastSent).test, 'load'); + }); + + it('queues messages during reconnect and sends them in order', async function() { + htmx.config.ws = { reconnectDelay: 50, reconnectJitter: 0 }; + let div = createProcessedHTML(` +
+ + +
+ `); + await htmx.timeout(20); + + let sentOrders = []; + div.addEventListener('htmx:ws:after:message:outgoing', event => { + sentOrders.push(event.detail.message.values.order); + }); + + mockWebSocketInstances[0].close(1006); + let buttons = div.querySelectorAll('button'); + buttons[0].click(); + buttons[1].click(); + await htmx.timeout(10); + + let connection = htmx.ext.ws.getRegistry().get('/ws/test'); + assert.equal(connection.queue.length, 2); + assert.deepEqual(sentOrders, []); + + await htmx.timeout(70); + + let sent = mockWebSocketInstances[1].sentMessages.map(JSON.parse); + assert.deepEqual(sent.map(message => message.order), ['first', 'second']); + assert.deepEqual(sentOrders, ['first', 'second']); + assert.equal(connection.queue.length, 0); }); it('sends message with hx-ws:send on form submit', async function() { @@ -316,7 +347,8 @@ describe('hx-ws WebSocket extension', function() { let sent = JSON.parse(ws.lastSent); assert.isDefined(sent.headers['HX-Request-ID']); - assert.equal(sent.body.message, 'hello'); + assert.equal(sent.message, 'hello'); + assert.notProperty(sent, 'body'); assert.isDefined(sent.headers['HX-Source']); assert.isDefined(sent.headers['HX-Current-URL']); }); @@ -355,7 +387,7 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - assert.equal(sent.body.extra, 'data'); + assert.equal(sent.extra, 'data'); }); it('preserves JS types (number, boolean) from hx-vals', async function() { @@ -370,9 +402,9 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(20); let sent = JSON.parse(mockWebSocketInstances[0].lastSent); - assert.strictEqual(sent.body.count, 42, 'number should not be coerced to string'); - assert.strictEqual(sent.body.active, true, 'boolean should not be coerced to string'); - assert.strictEqual(sent.body.ratio, 1.5, 'float should not be coerced to string'); + assert.strictEqual(sent.count, 42, 'number should not be coerced to string'); + assert.strictEqual(sent.active, true, 'boolean should not be coerced to string'); + assert.strictEqual(sent.ratio, 1.5, 'float should not be coerced to string'); }); it('hx-vals overrides form field with correct type', async function() { @@ -389,7 +421,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(20); let sent = JSON.parse(mockWebSocketInstances[0].lastSent); - assert.strictEqual(sent.body.count, 99, 'hx-vals number should win over form string value'); + assert.strictEqual(sent.count, 99, 'hx-vals number should win over form string value'); }); it('finds connection from nearest ancestor', async function() { @@ -451,7 +483,7 @@ describe('hx-ws WebSocket extension', function() { assert.equal(sent.headers['HX-Source'], 'button#my-button'); }); - it('generates unique request_id for each message', async function() { + it('generates a unique HX-Request-ID for each message', async function() { let div = createProcessedHTML(`
@@ -504,7 +536,7 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - assert.equal(sent.body.asyncField, 'asyncValue'); + assert.equal(sent.asyncField, 'asyncValue'); delete window.testAsyncValue; }); @@ -664,29 +696,46 @@ describe('hx-ws WebSocket extension', function() { delete window.wsScriptAttrTest; }); - it('matches request_id for request/response pattern', async function() { + // Incoming messages with matching IDs use their sender for relative targets and swap lifecycle. + it('routes incoming messages with matching IDs through the sending element', async function() { let container = createProcessedHTML(`
- -
+
+ +
`); await htmx.timeout(50); - + let button = document.getElementById('btn'); + let result = container.querySelector('.result'); + let eventSource, finalContext, mainTask; + button.addEventListener('htmx:before:swap', event => { + eventSource = event.target; + finalContext = event.detail.ctx; + mainTask = event.detail.tasks.find(task => task.type === 'main'); + }); + button.click(); await htmx.timeout(20); - + let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - + ws.simulateMessage({ - content: 'Response', - 'HX-Request-ID': sent.headers['HX-Request-ID'] + content: '

Response

', + swap: 'beforeend swap:10ms settle:0', + headers: { 'HX-Request-ID': sent.headers['HX-Request-ID'] } }); - await htmx.timeout(20); - - assert.include(document.getElementById('result').innerHTML, 'Response'); + await htmx.timeout(30); + + assert.equal(eventSource, button); + assert.equal(finalContext.target, 'closest .result'); + assert.equal(mainTask.target, result); + assert.equal(mainTask.swapSpec.style, 'beforeend'); + assert.equal(mainTask.swapSpec.swap, '10ms'); + assert.equal(mainTask.swapSpec.settle, 0); + assert.equal(document.getElementById('response').parentElement, result); }); }); @@ -706,9 +755,9 @@ describe('hx-ws WebSocket extension', function() { let eventFired = false; let eventMessage = null; - container.addEventListener('htmx:after:ws:message', (e) => { + container.addEventListener('htmx:ws:after:message:incoming', async (e) => { eventFired = true; - eventMessage = e.detail.message.json; + eventMessage = await e.detail.message.json(); }); let ws = mockWebSocketInstances[0]; @@ -720,14 +769,39 @@ describe('hx-ws WebSocket extension', function() { assert.equal(document.getElementById('content').textContent, 'Original', 'Data-only messages should not swap'); }); - it('fires htmx:before:ws:message for all messages', async function() { + it('exposes binary messages without swapping them', async function() { + let container = createProcessedHTML(` +
+
Original
+
+ `); + await htmx.timeout(50); + + let receivedMessage; + container.addEventListener('htmx:ws:before:message:incoming', event => { + receivedMessage = event.detail.message; + }); + + let data = new TextEncoder().encode(JSON.stringify({ content: '

Not swapped

' })).buffer; + mockWebSocketInstances[0].simulateRawMessage(data); + await htmx.timeout(20); + + assert.equal(document.getElementById('content').textContent, 'Original'); + assert.equal(receivedMessage.type, 'binary'); + assert.strictEqual(receivedMessage.data, data); + assert.strictEqual(await receivedMessage.arrayBuffer(), data); + assert.equal((await receivedMessage.json()).content, '

Not swapped

'); + assert.instanceOf(await receivedMessage.blob(), Blob); + }); + + it('fires htmx:ws:before:message:incoming for all messages', async function() { let container = createProcessedHTML(`
`); await htmx.timeout(50); let beforeFired = false; - container.addEventListener('htmx:before:ws:message', () => { + container.addEventListener('htmx:ws:before:message:incoming', () => { beforeFired = true; }); @@ -748,7 +822,7 @@ describe('hx-ws WebSocket extension', function() { `); await htmx.timeout(50); - container.addEventListener('htmx:before:ws:message', (e) => { + container.addEventListener('htmx:ws:before:message:incoming', (e) => { e.preventDefault(); }); @@ -761,19 +835,54 @@ describe('hx-ws WebSocket extension', function() { assert.equal(document.getElementById('content').textContent, 'Original'); }); - it('uses swap:none for raw HTML when no hx-target is set', async function() { + // Bare connections follow normal target and swap defaults. + it('waits for incoming message work before processing', async function() { let container = createProcessedHTML(` -
+
Original
`); await htmx.timeout(50); + container.addEventListener('htmx:ws:before:message:incoming', (event) => { + event.detail.waitUntil(htmx.timeout(20).then(() => { + event.detail.cancelled = true; + })); + }); + + mockWebSocketInstances[0].simulateMessage({ content: '

Changed

' }); + await htmx.timeout(5); + assert.equal(document.getElementById('content').textContent, 'Original'); + + await htmx.timeout(30); + assert.equal(document.getElementById('content').textContent, 'Original'); + }); + + it('swaps raw HTML into the connection element by default', async function() { + let container = createProcessedHTML(` +
Original
+ `); + await htmx.timeout(50); + let ws = mockWebSocketInstances[0]; - ws.simulateRawMessage('
Should not replace
'); + ws.simulateRawMessage('

Updated

'); await htmx.timeout(20); - assert.equal(document.getElementById('content').textContent, 'Original'); + assert.equal(container.innerHTML, '

Updated

'); + }); + + // JSON content uses the same target and swap defaults as raw HTML. + it('swaps JSON content into the connection element by default', async function() { + let container = createProcessedHTML(` +
Original
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateMessage({ content: '

Updated

' }); + await htmx.timeout(20); + + assert.equal(container.innerHTML, '

Updated

'); }); it('swaps raw HTML into hx-target when set', async function() { @@ -834,19 +943,48 @@ describe('hx-ws WebSocket extension', function() { assert.isTrue(closeFired); }); - it('attempts reconnection on close when config.reconnect is true', async function() { + it('defaults to the htmx 2 reconnect codes', async function() { + createProcessedHTML('
'); + await htmx.timeout(20); + + let connection = htmx.ext.ws.getRegistry().get('/ws/test'); + assert.deepEqual(connection.config.reconnectCodes, [1006, 1011, 1012, 1013]); + }); + + it('reconnects after an allowed close code', async function() { htmx.config.ws = { reconnect: true, reconnectDelay: 50 }; - - let container = createProcessedHTML(` -
- `); + + createProcessedHTML('
'); await htmx.timeout(50); - - let firstWs = mockWebSocketInstances[0]; - firstWs.close(); + + mockWebSocketInstances[0].close(1006); await htmx.timeout(100); - - assert.isTrue(mockWebSocketInstances.length > 1, 'Should create new WebSocket for reconnection'); + + assert.isTrue(mockWebSocketInstances.length > 1); + }); + + it('does not reconnect after a normal close', async function() { + htmx.config.ws = { reconnect: true, reconnectDelay: 20 }; + + createProcessedHTML('
'); + await htmx.timeout(50); + + mockWebSocketInstances[0].close(1000); + await htmx.timeout(50); + + assert.equal(mockWebSocketInstances.length, 1); + }); + + it('uses custom reconnectCodes', async function() { + htmx.config.ws = { reconnectCodes: [1000], reconnectDelay: 20 }; + + createProcessedHTML('
'); + await htmx.timeout(50); + + mockWebSocketInstances[0].close(1000); + await htmx.timeout(50); + + assert.equal(mockWebSocketInstances.length, 2); }); it('does not reconnect when config.reconnect is false', async function() { @@ -858,13 +996,13 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let firstWs = mockWebSocketInstances[0]; - firstWs.close(); + firstWs.close(1006); await htmx.timeout(100); assert.equal(mockWebSocketInstances.length, 1); }); - it('emits htmx:before:ws:connection with attempt > 0 on reconnect', async function() { + it('emits htmx:ws:before:connection with attempt > 0 on reconnect', async function() { htmx.config.ws = { reconnect: true, reconnectDelay: 50 }; let container = createProcessedHTML(` @@ -872,7 +1010,7 @@ describe('hx-ws WebSocket extension', function() { `); let reconnectAttempt = null; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempt = e.detail.connection.attempt; } @@ -880,7 +1018,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let firstWs = mockWebSocketInstances[0]; - firstWs.close(); + firstWs.close(1006); await htmx.timeout(100); assert.equal(reconnectAttempt, 1); @@ -899,7 +1037,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectTimes = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectTimes.push(Date.now()); } @@ -907,17 +1045,17 @@ describe('hx-ws WebSocket extension', function() { // First close let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(200); // Second close ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(300); // Third close ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(500); // Verify delays are increasing @@ -967,21 +1105,36 @@ describe('hx-ws WebSocket extension', function() { assert.include(document.getElementById('content').innerHTML, 'Raw HTML update'); }); - it('uses swap:none for non-JSON messages without hx-target', async function() { + // OOB-only messages update their targets without clearing the connection element. + it('defaults swapEmpty to false for OOB-only messages', async function() { let container = createProcessedHTML(` -
-
Original
-
+
Original
+
Waiting
`); await htmx.timeout(50); - + let ws = mockWebSocketInstances[0]; - // Send raw HTML without hx-partial targeting β€” should not wipe connection element - ws.simulateRawMessage('

Should not appear

'); + ws.simulateRawMessage('
Connected
'); await htmx.timeout(20); - - // Connection element content should be preserved - assert.include(document.getElementById('ws-conn').innerHTML, 'Original'); + + assert.equal(document.getElementById('ws-conn').textContent, 'Original'); + assert.equal(document.getElementById('status').textContent, 'Connected'); + }); + + // Explicit swapEmpty:true restores the normal empty main swap. + it('allows swapEmpty:true to clear the connection element', async function() { + let container = createProcessedHTML(` +
Original
+
Waiting
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateRawMessage('
Connected
'); + await htmx.timeout(20); + + assert.equal(document.getElementById('ws-conn').textContent, ''); + assert.equal(document.getElementById('status').textContent, 'Connected'); }); it('processes hx-partial in non-JSON messages even without hx-target', async function() { @@ -999,7 +1152,7 @@ describe('hx-ws WebSocket extension', function() { assert.include(document.getElementById('widget').innerHTML, 'Updated via partial'); }); - it('fires htmx:before:ws:message for non-JSON data with message=null', async function() { + it('fires htmx:ws:before:message:incoming for non-JSON data', async function() { let container = createProcessedHTML(`
Original
@@ -1009,10 +1162,9 @@ describe('hx-ws WebSocket extension', function() { let eventFired = false; let receivedData = null; - let receivedMessage = 'not-set'; - container.addEventListener('htmx:before:ws:message', (e) => { + container.addEventListener('htmx:ws:before:message:incoming', async (e) => { eventFired = true; - receivedMessage = e.detail.message.json; + receivedData = await e.detail.message.text(); }); let ws = mockWebSocketInstances[0]; @@ -1020,10 +1172,10 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(20); assert.isTrue(eventFired); - assert.isNull(receivedMessage, 'message.json should be null for raw messages'); + assert.equal(receivedData, '

Raw content

'); }); - it('prevents swap when htmx:before:ws:message is cancelled for raw data', async function() { + it('prevents swap when htmx:ws:before:message:incoming is cancelled for raw data', async function() { let container = createProcessedHTML(`
Original
@@ -1031,8 +1183,8 @@ describe('hx-ws WebSocket extension', function() { `); await htmx.timeout(50); - container.addEventListener('htmx:before:ws:message', (e) => { - if (!e.detail.message.json) e.detail.message.cancelled = true; + container.addEventListener('htmx:ws:before:message:incoming', (e) => { + if (e.detail.message.type === 'text') e.detail.cancelled = true; }); let ws = mockWebSocketInstances[0]; @@ -1073,7 +1225,7 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let closeTime = Date.now(); - ws.close(); + ws.close(1006); await htmx.timeout(100); assert.equal(mockWebSocketInstances.length, 1, 'Should not reconnect yet'); @@ -1096,7 +1248,7 @@ describe('hx-ws WebSocket extension', function() { // This test just ensures jitter doesn't break reconnection let ws = mockWebSocketInstances[0]; - ws.close(); + ws.close(1006); await htmx.timeout(200); assert.isTrue(mockWebSocketInstances.length > 1); @@ -1116,13 +1268,13 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectCount = 0; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) reconnectCount++; }); // Close the first connection β€” this triggers reconnect attempt 1 let ws = mockWebSocketInstances[0]; - ws.close(); + ws.close(1006); await htmx.timeout(50); // The reconnected socket auto-opens (mock behavior), which resets @@ -1155,14 +1307,14 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectAttempts = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempts.push(e.detail.connection.attempt); } }); let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(200); assert.isAtLeast(reconnectAttempts.length, 1, 'Should have at least 1 reconnect'); @@ -1182,7 +1334,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectAttempts = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempts.push(e.detail.connection.attempt); } @@ -1191,11 +1343,11 @@ describe('hx-ws WebSocket extension', function() { // Each reconnect succeeds (mock auto-opens), so reconnectAttempts // resets to 0 β€” each subsequent close starts at attempt 1 again let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(50); ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(50); assert.isAtLeast(reconnectAttempts.length, 2, 'Should have at least 2 reconnects'); @@ -1214,14 +1366,14 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectAttempts = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempts.push(e.detail.connection.attempt); } }); let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(100); // Per-element config set reconnectDelay to 20ms (not global 5000ms), @@ -1237,19 +1389,19 @@ describe('hx-ws WebSocket extension', function() { }; let container = createProcessedHTML(` -
+
`); await htmx.timeout(50); let reconnectAttempts = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempts.push(e.detail.connection.attempt); } }); let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1000); await htmx.timeout(100); assert.isAtLeast(reconnectAttempts.length, 1, 'Should reconnect using per-element JSON config'); @@ -1277,7 +1429,7 @@ describe('hx-ws WebSocket extension', function() { assert.equal(errorMsg, 'Connection not open'); }); - it('raw messages go through before/after:ws:message with message=null', async function() { + it('raw messages go through incoming message events', async function() { let container = createProcessedHTML(`
Original
@@ -1287,10 +1439,10 @@ describe('hx-ws WebSocket extension', function() { let beforeDetail = null; let afterDetail = null; - container.addEventListener('htmx:before:ws:message', (e) => { + container.addEventListener('htmx:ws:before:message:incoming', (e) => { beforeDetail = e.detail; }); - container.addEventListener('htmx:after:ws:message', (e) => { + container.addEventListener('htmx:ws:after:message:incoming', (e) => { afterDetail = e.detail; }); @@ -1298,15 +1450,16 @@ describe('hx-ws WebSocket extension', function() { ws.simulateRawMessage('

Updated

'); await htmx.timeout(20); - assert.isNotNull(beforeDetail, 'before:ws:message should fire for raw messages'); - assert.isNull(beforeDetail.message.json, 'message.json should be null for raw data'); - assert.isString(beforeDetail.message.text, 'message.text should be present'); + assert.isNotNull(beforeDetail, 'ws:before:message:incoming should fire for raw messages'); + assert.equal(beforeDetail.message.type, 'text'); + assert.equal(beforeDetail.message.data, '

Updated

'); + assert.equal(await beforeDetail.message.text(), beforeDetail.message.data); - assert.isNotNull(afterDetail, 'after:ws:message should fire for raw messages'); - assert.isNull(afterDetail.message.json, 'message.json should be null in after event too'); + assert.isNotNull(afterDetail, 'ws:after:message:incoming should fire for raw messages'); + assert.strictEqual(afterDetail.message, beforeDetail.message); }); - it('JSON messages go through before/after:ws:message with message object', async function() { + it('JSON messages go through incoming message events', async function() { let container = createProcessedHTML(`
@@ -1315,7 +1468,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let beforeDetail = null; - container.addEventListener('htmx:before:ws:message', (e) => { + container.addEventListener('htmx:ws:before:message:incoming', (e) => { beforeDetail = e.detail; }); @@ -1325,9 +1478,9 @@ describe('hx-ws WebSocket extension', function() { }); await htmx.timeout(20); - assert.isNotNull(beforeDetail, 'before:ws:message should fire'); - assert.isNotNull(beforeDetail.message.json, 'message.json should be set for JSON messages'); - assert.isDefined(beforeDetail.message.json.content, 'message.json should have content field'); + assert.isNotNull(beforeDetail, 'ws:before:message:incoming should fire'); + let json = await beforeDetail.message.json(); + assert.isDefined(json.content, 'message.json() should parse the message'); }); it('passes protocols to WebSocket constructor', async function() { htmx.config.ws = { protocols: 'my-protocol' }; @@ -1376,13 +1529,13 @@ describe('hx-ws WebSocket extension', function() { describe('Event Emission', function() { - it('emits htmx:before:ws:connection before connection', async function() { + it('emits htmx:ws:before:connection before connection', async function() { let beforeFired = false; let attempt = null; let container = document.createElement('div'); container.innerHTML = '
'; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { beforeFired = true; attempt = e.detail.connection.attempt; }); @@ -1396,12 +1549,12 @@ describe('hx-ws WebSocket extension', function() { container.remove(); }); - it('emits htmx:after:ws:connection after connection', async function() { + it('emits htmx:ws:after:connection after connection', async function() { let afterFired = false; let container = document.createElement('div'); container.innerHTML = '
'; - container.addEventListener('htmx:after:ws:connection', () => { + container.addEventListener('htmx:ws:after:connection', () => { afterFired = true; }); @@ -1413,11 +1566,11 @@ describe('hx-ws WebSocket extension', function() { container.remove(); }); - it('can cancel initial connection via htmx:before:ws:connection', async function() { + it('can cancel initial connection via htmx:ws:before:connection', async function() { let container = document.createElement('div'); container.innerHTML = '
'; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { e.detail.connection.cancelled = true; }); @@ -1429,14 +1582,14 @@ describe('hx-ws WebSocket extension', function() { container.remove(); }); - it('can cancel reconnection via htmx:before:ws:connection', async function() { + it('can cancel reconnection via htmx:ws:before:connection', async function() { htmx.config.ws = { reconnect: true, reconnectDelay: 50 }; let container = createProcessedHTML(`
`); - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { e.detail.connection.cancelled = true; } @@ -1444,13 +1597,13 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let firstWs = mockWebSocketInstances[0]; - firstWs.close(); + firstWs.close(1006); await htmx.timeout(150); assert.equal(mockWebSocketInstances.length, 1, 'Should not reconnect when cancelled'); }); - it('emits htmx:before:ws:request before sending', async function() { + it('emits htmx:ws:before:message:outgoing before sending', async function() { let beforeFired = false; let div = createProcessedHTML(`
@@ -1458,7 +1611,7 @@ describe('hx-ws WebSocket extension', function() {
`); - div.addEventListener('htmx:before:ws:request', () => { + div.addEventListener('htmx:ws:before:message:outgoing', () => { beforeFired = true; }); @@ -1469,34 +1622,37 @@ describe('hx-ws WebSocket extension', function() { assert.isTrue(beforeFired); }); - it('emits htmx:after:ws:request after sending', async function() { - let afterFired = false; + it('emits htmx:ws:after:message:outgoing after sending', async function() { + let afterMessage; let div = createProcessedHTML(`
`); - div.addEventListener('htmx:after:ws:request', () => { - afterFired = true; + div.addEventListener('htmx:ws:after:message:outgoing', (event) => { + afterMessage = event.detail.message; }); await htmx.timeout(50); div.querySelector('button').click(); await htmx.timeout(20); - assert.isTrue(afterFired); + assert.isString(afterMessage.data); + assert.deepEqual(JSON.parse(afterMessage.data), { + headers: afterMessage.headers + }); }); - it('allows modifying message via htmx:before:ws:request', async function() { + it('allows modifying message via htmx:ws:before:message:outgoing', async function() { let div = createProcessedHTML(`
`); - div.addEventListener('htmx:before:ws:request', (e) => { - e.detail.body.custom = 'added'; + div.addEventListener('htmx:ws:before:message:outgoing', (e) => { + e.detail.message.values.custom = 'added'; }); await htmx.timeout(50); @@ -1505,17 +1661,66 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - assert.equal(sent.body.custom, 'added'); + assert.equal(sent.custom, 'added'); }); - it('can cancel send via htmx:before:ws:request', async function() { + it('waits for outgoing message work before sending', async function() { let div = createProcessedHTML(`
`); - div.addEventListener('htmx:before:ws:request', (e) => { + div.addEventListener('htmx:ws:before:message:outgoing', (event) => { + event.detail.waitUntil(htmx.timeout(20).then(() => { + event.detail.message.values.delayed = true; + })); + }); + + await htmx.timeout(50); + div.querySelector('button').click(); + await htmx.timeout(5); + + let ws = mockWebSocketInstances[0]; + assert.isUndefined(ws.lastSent); + + await htmx.timeout(30); + assert.isTrue(JSON.parse(ws.lastSent).delayed); + }); + + it('sends replacement WebSocket data', async function() { + let div = createProcessedHTML(` +
+ +
+ `); + let data = new Uint8Array([1, 2, 3]); + let afterMessage; + + div.addEventListener('htmx:ws:before:message:outgoing', (event) => { + event.detail.message.data = data; + }); + div.addEventListener('htmx:ws:after:message:outgoing', (event) => { + afterMessage = event.detail.message; + }); + + await htmx.timeout(50); + div.querySelector('button').click(); + await htmx.timeout(20); + + let ws = mockWebSocketInstances[0]; + assert.strictEqual(ws.lastSent, data); + assert.strictEqual(afterMessage.data, data); + }); + + it('can cancel send via htmx:ws:before:message:outgoing', async function() { + let div = createProcessedHTML(` +
+ +
+ `); + + div.addEventListener('htmx:ws:before:message:outgoing', (e) => { e.preventDefault(); }); @@ -1734,6 +1939,57 @@ describe('hx-ws WebSocket extension', function() { assert.include(content.innerHTML, 'Item 1'); assert.include(content.innerHTML, 'Item 2'); }); + + // Incoming HTML uses inherited hx-select before the main swap. + it('uses element hx-select', async function() { + let container = createProcessedHTML(` +
+
+
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateRawMessage('

Selected

Ignored
'); + await htmx.timeout(20); + + assert.equal(document.getElementById('content').innerHTML, '

Selected

'); + }); + + // A JSON select overrides inherited hx-select for one incoming message. + it('message select overrides element hx-select', async function() { + let container = createProcessedHTML(` +
+
+
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateMessage({ + content: '

Default

Override

', + select: '.override' + }); + await htmx.timeout(20); + + assert.equal(document.getElementById('content').innerHTML, '

Override

'); + }); + + // Incoming HTML uses inherited hx-select-oob for client-selected OOB updates. + it('uses element hx-select-oob', async function() { + let container = createProcessedHTML(` +
Original
+
Waiting
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateRawMessage('
Connected
'); + await htmx.timeout(20); + + assert.equal(document.getElementById('ws-conn').textContent, 'Original'); + assert.equal(document.getElementById('status').textContent, 'Connected'); + }); it('message target overrides element hx-target', async function() { let container = createProcessedHTML(` @@ -1782,7 +2038,7 @@ describe('hx-ws WebSocket extension', function() { describe('Bug Regressions', function() { - it('htmx:after:ws:connection reports correct attempt number on reconnect', async function() { + it('htmx:ws:after:connection reports correct attempt number on reconnect', async function() { htmx.config.ws = { reconnect: true, reconnectDelay: 50, reconnectJitter: 0 }; let container = createProcessedHTML(` @@ -1791,20 +2047,20 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reportedAttempt = null; - container.addEventListener('htmx:after:ws:connection', (e) => { + container.addEventListener('htmx:ws:after:connection', (e) => { reportedAttempt = e.detail.connection.attempt; }); // Close to trigger reconnect let ws = mockWebSocketInstances[0]; - ws.close(); + ws.close(1006); await htmx.timeout(150); assert.isNotNull(reportedAttempt, 'after:ws:connection should have fired on reconnect'); assert.equal(reportedAttempt, 1, 'Reconnection attempt should be 1, not 0'); }); - it('htmx:before:ws:message includes raw data string', async function() { + it('htmx:ws:before:message:incoming includes raw data string', async function() { let container = createProcessedHTML(`
@@ -1812,20 +2068,18 @@ describe('hx-ws WebSocket extension', function() { `); await htmx.timeout(50); - let receivedData = null; let receivedMessage = null; - container.addEventListener('htmx:before:ws:message', (e) => { - receivedData = e.detail.message.text; - receivedMessage = e.detail.message.json; + container.addEventListener('htmx:ws:before:message:incoming', async (e) => { + receivedMessage = e.detail.message; }); let ws = mockWebSocketInstances[0]; ws.simulateMessage({ content: '

Hello

' }); await htmx.timeout(20); - assert.isString(receivedData, 'text should be the raw string'); - assert.isNotNull(receivedMessage, 'json should be the parsed JSON'); - assert.equal(receivedMessage.content, '

Hello

'); + assert.equal(receivedMessage.data, JSON.stringify({ content: '

Hello

' })); + assert.equal(await receivedMessage.text(), receivedMessage.data); + assert.equal((await receivedMessage.json()).content, '

Hello

'); }); }); @@ -1851,10 +2105,12 @@ describe('hx-ws WebSocket extension', function() { assert.isTrue(registry.has('/ws/test'), 'Connection should be in registry'); // Swap out the ws-host element entirely (simulates hx-swap replacing it) + let target = document.getElementById('outer'); await htmx.swap({ text: '
Replaced β€” no hx-ws:connect
', - target: document.getElementById('outer'), - swap: 'innerHTML' + target, + swap: 'innerHTML', + sourceElement: target }); await htmx.timeout(50); @@ -1929,14 +2185,16 @@ describe('hx-ws WebSocket extension', function() { let registry = htmx.ext.ws.getRegistry(); // Close to trigger reconnect scheduling - ws.close(); + ws.close(1006); await htmx.timeout(20); // Remove element during the reconnect delay + let target = document.getElementById('outer'); await htmx.swap({ text: '
No more WS
', - target: document.getElementById('outer'), - swap: 'innerHTML' + target, + swap: 'innerHTML', + sourceElement: target }); await htmx.timeout(250); @@ -2047,53 +2305,7 @@ describe('hx-ws WebSocket extension', function() { }); // ======================================== - // 14. RECONNECT JITTER BOOLEAN COMPAT (POLISH) - // ======================================== - - describe('reconnectJitter Boolean Compatibility', function() { - - it('treats reconnectJitter: true as 0.3 (default jitter)', async function() { - htmx.config.ws = { - reconnect: true, - reconnectDelay: 50, - reconnectJitter: true - }; - - let container = createProcessedHTML(` -
- `); - await htmx.timeout(50); - - // Should reconnect without breaking (true * delay would give NaN-like behavior) - let ws = mockWebSocketInstances[0]; - ws.close(); - await htmx.timeout(150); - - assert.isTrue(mockWebSocketInstances.length > 1, 'Should reconnect with boolean jitter=true'); - }); - - it('treats reconnectJitter: false as 0 (no jitter)', async function() { - htmx.config.ws = { - reconnect: true, - reconnectDelay: 50, - reconnectJitter: false - }; - - let container = createProcessedHTML(` -
- `); - await htmx.timeout(50); - - let ws = mockWebSocketInstances[0]; - ws.close(); - await htmx.timeout(100); - - assert.isTrue(mockWebSocketInstances.length > 1, 'Should reconnect with boolean jitter=false'); - }); - }); - - // ======================================== - // 15. ADDITIONAL FINDINGS β€” DEEP REVIEW + // 14. ADDITIONAL FINDINGS β€” DEEP REVIEW // ======================================== describe('Deep Review Fixes', function() { @@ -2121,10 +2333,10 @@ describe('hx-ws WebSocket extension', function() { form.remove(); await htmx.timeout(20); - // Server responds with the request ID β€” should fall back to live connect element + // Server sends the request ID β€” should fall back to live connect element ws.simulateMessage({ content: 'Response', - 'HX-Request-ID': requestId + headers: { 'HX-Request-ID': requestId } }); await htmx.timeout(20); @@ -2175,11 +2387,8 @@ describe('hx-ws WebSocket extension', function() { let ac = conn.abortController; // Remove the element to trigger closeConnection - await htmx.swap({ - text: '', - target: document.getElementById('outer'), - swap: 'innerHTML' - }); + let target = document.getElementById('outer'); + await htmx.swap({ text: '', target, swap: 'innerHTML', sourceElement: target }); await htmx.timeout(50); assert.isTrue(ac.signal.aborted, 'AbortController should be aborted on close'); diff --git a/www/src/content/extensions/03-hx-ws.md b/www/src/content/extensions/03-hx-ws.md index d853bae09..2e80f1000 100644 --- a/www/src/content/extensions/03-hx-ws.md +++ b/www/src/content/extensions/03-hx-ws.md @@ -24,7 +24,7 @@ If you used [`ws`](https://htmx.org/extensions/ws/) in htmx 2.0, see [migration Open a [persistent](#wsreconnect) WebSocket connection: ```html -
+
...
``` @@ -38,18 +38,16 @@ The browser receives this WebSocket message: The result is: ```html -
+

New message

``` -The explicit target enables the normal swap. htmx uses: +htmx uses the same rules as with a `text/html` response: - [`hx-target="this"`](/reference/attributes/hx-target#this) - [`hx-swap="innerHTML"`](/reference/attributes/hx-swap#innerhtml) (from [`htmx.config.defaultSwap`](/reference/config/htmx-config-defaultSwap)) -Without an element or JSON target, plain incoming HTML uses `swap:none`. Explicit [`hx-swap-oob`](/reference/attributes/hx-swap-oob) and [``](/reference/tags/hx-partial) swaps still run. - **Choose the Swap** Use [`hx-swap`](/reference/attributes/hx-swap) and [`hx-target`](/reference/attributes/hx-target) to choose how and where updates swap: @@ -88,12 +86,17 @@ The result is:
``` +You can also use: + +- [`hx-select`](/reference/attributes/hx-select) to select content for the swap +- [`hx-select-oob`](/reference/attributes/hx-select-oob) to select more elements to swap + ### Update Elements -Use explicit extra swaps to update several elements: +Start with the page elements to update: ```html -
+

Old

@@ -101,7 +104,7 @@ Use explicit extra swaps to update several elements:
Offline
``` -The server sends an [`hx-swap-oob`](/reference/attributes/hx-swap-oob) element and an [``](/reference/tags/hx-partial): +We’ll send an [`hx-swap-oob`](/reference/attributes/hx-swap-oob) element and an [``](/reference/tags/hx-partial) (new in 4.0): ```html @@ -116,7 +119,7 @@ The server sends an [`hx-swap-oob`](/reference/attributes/hx-swap-oob) element a The page becomes: ```html -
+

Old

@@ -125,20 +128,48 @@ The page becomes:
Online
``` -`hx-swap="none"` disables the connection element's normal swap. The explicit extra swaps still run. +
+Why wasn't the normal swap used? + +After htmx extracts extra swaps, the normal swap may be empty: + +```text +(empty) +``` + +[`hx-swap-oob`](/reference/attributes/hx-swap-oob) and [``](/reference/tags/hx-partial) elements are extracted before the normal swap. By default, [`swapEmpty:false`](/reference/attributes/hx-swap#swapempty) leaves the connection unchanged. + +The server can also mix these updates with ordinary HTML: + +```html +

New chat content

+ + +Busy +``` + +The first element uses the connection's target and swap. The partial updates `#status`. + +To disable the connection's swap, set `hx-swap="none"`: + +```html +
+ ... +
+``` + +[`hx-swap-oob`](/reference/attributes/hx-swap-oob) and [``](/reference/tags/hx-partial) swaps still run. + +
### Send a Message -Add [`hx-ws:send`](#hx-wssend) to a form inside the connection: +Add [`hx-ws:send`](#hx-wssend) to an input inside the connection: ```html
- -
- - -
+
``` @@ -150,17 +181,15 @@ The outgoing message is: "HX-Request": "true", "HX-Request-ID": "550e8400-e29b-41d4-a716-446655440000", "HX-Request-Type": "partial", - "HX-Source": "form", + "HX-Source": "input", "HX-Target": "div#messages", "HX-Current-URL": "https://example.com/chat" }, - "body": { - "message": "Hello" - } + "message": "Hello" } ``` -`headers` contains htmx metadata. `body` contains form values and [`hx-vals`](/reference/attributes/hx-vals). +`headers` is reserved for metadata. Form values and `hx-vals` use the other top-level keys. Repeat a form field to send an array: @@ -172,16 +201,16 @@ Repeat a form field to send an array: ``` +The outgoing message is: + ```jsonc { "headers": { /* ... */ }, - "body": { - "tag": ["urgent", "public"] - } + "tag": ["urgent", "public"] } ``` -`hx-vals` overrides form values without coercing its types: +[`hx-vals`](/reference/attributes/hx-vals) overrides form values without coercing its types: ```html
@@ -191,7 +220,7 @@ Repeat a form field to send an array: ``` ```jsonc -{ "headers": { /* ... */ }, "body": { "count": 2 } } +{ "headers": { /* ... */ }, "count": 2 } ``` ### Override an Incoming Swap @@ -202,27 +231,45 @@ Use JSON to override the connection's swap: { "content": "

New message

", "target": "#messages", - "swap": "beforeend settle:10ms" + "swap": "beforeend settle:10ms", + "select": ".message" } ``` +- `headers`: metadata such as `HX-Request-ID` - `content`: the HTML to swap - `target`: where to swap it - `swap`: a serialized [`hx-swap`](/reference/attributes/hx-swap) specification -- `HX-Request-ID`: an optional top-level sender correlation ID -- `request_id`: a supported legacy correlation ID +- `select`: what to select from `content` + +HTTP `HX-Re*` headers replace values already chosen for a request. + +A WebSocket message may arrive without a request, so its JSON fields can choose those values from the start: + +| JSON field | HTTP response header | Element default | +|------------|----------------------|-----------------| +| `target` | [`HX-Retarget`](/reference/headers/HX-Retarget) | [`hx-target`](/reference/attributes/hx-target) | +| `swap` | [`HX-Reswap`](/reference/headers/HX-Reswap) | [`hx-swap`](/reference/attributes/hx-swap) | +| `select` | [`HX-Reselect`](/reference/headers/HX-Reselect) | [`hx-select`](/reference/attributes/hx-select) | + +`content` uses the same `hx-target`, `hx-swap`, and `hx-select` attributes as plain HTML. `hx-swap-oob` and `` inside it still produce independent swaps. The JSON fields override the corresponding attributes: ```text TARGET -JSON target --> hx-target --> connection element +JSON target --> hx-target --> connection element* SWAP -JSON swap --> hx-swap --> defaultSwap when a target is set +JSON swap --> hx-swap --> defaultSwap + +SELECT +JSON select --> hx-select --> all content + +* incoming messages with a matching HX-Request-ID use the sending element ``` -`hx-swap-oob` and `` inside `content` still produce independent swaps. +`hx-select-oob` remains an element setting. A server can use `hx-swap-oob` or `` inside `content` instead. ### Handle Custom Messages @@ -235,22 +282,36 @@ JSON without `content` is not swapped: } ``` -Handle it with [`htmx:before:ws:message`](#htmxbeforewsmessage): +Handle it with [`htmx:ws:before:message:incoming`](#htmxwsbeforemessageincoming): ```js -document.addEventListener('htmx:before:ws:message', event => { - let message = event.detail.message.json - if (message?.type === 'notification') showNotification(message) +document.addEventListener('htmx:ws:before:message:incoming', async event => { + let message = await event.detail.message.json() + if (message.type === 'notification') showNotification(message) }) ``` -The event exposes: +Cancel the event to take over custom or binary processing: -- `message.text`: the original text -- `message.json`: the parsed object, or `null` -- `message.cancelled`: set to `true` to skip built-in handling +```js +document.addEventListener('htmx:ws:before:message:incoming', async event => { + event.preventDefault() + handleCustomMessage(await event.detail.message.text()) +}) +``` -You can also call `event.preventDefault()` to take over processing. +`message.data` contains the original string, `Blob`, or `ArrayBuffer`. + +Conversions are cached: + +```js +await message.text() +await message.json() +await message.blob() +await message.arrayBuffer() +``` + +Cancel to skip built-in handling. Binary messages are not swapped automatically. ### Persistent Connections @@ -275,12 +336,12 @@ All [`hx-trigger` modifiers](/reference/attributes/hx-trigger#event-modifiers) a Give `hx-ws:send` a URL to open a connection: ```html - ``` -Clicking the button opens `/actions` and sends the values over that connection. +Clicking the button opens `/actions` and sends `action=refresh` over that connection. ##### Use Shared Connections @@ -288,44 +349,61 @@ Put several [`hx-ws:send`](#hx-wssend) elements inside one [`hx-ws:connect`](#hx ```html
- -
+
``` -Both buttons use the same WebSocket connection. Copy an outgoing [`HX-Request-ID`](#hx-request-id) into the top level of its incoming message to use the sending button's target: +Both buttons use the same WebSocket connection, but each incoming message needs the right target. + +**Route Incoming Messages** + +Copy an outgoing [`HX-Request-ID`](#hx-request-id) into the incoming message: ```json { - "HX-Request-ID": "550e8400-e29b-41d4-a716-446655440000", + "headers": { + "HX-Request-ID": "550e8400-e29b-41d4-a716-446655440000" + }, "content": "

Saved

" } ``` -Without the ID, a live connection element handles the message. +Without the ID, the connection element handles the message. + +With the ID: + +- Save uses `#save-result` +- Delete uses `#delete-result` +- Relative targets and swap events use the sending button -Separate `hx-ws:connect` elements with the same URL also share one connection: +**Reuse by URL** + +Separate `hx-ws:connect` elements with the same URL share a connection too: ```html
``` -The connection closes when htmx removes its last element. +Only one connection to `/actions` is opened. It closes when htmx removes its last element. #### Close Connections -By default, a WebSocket close schedules a reconnect. Set [`ws.reconnect:false`](#wsreconnect) when the connection should remain closed: +Close with code `1000` to stop reconnecting: -```html -
+```js +socket.close(1000, 'done') ``` +Codes in [`ws.reconnectCodes`](#wsreconnectcodes) reconnect instead. + #### Configure Connections You can configure `hx-ws` in three places: @@ -361,27 +439,28 @@ These values are read when the connection is created. Opens a WebSocket connection: ```html -
+
``` -Incoming HTML uses: - -- [`hx-target`](/reference/attributes/hx-target): enables the normal swap and chooses its target -- [`hx-swap`](/reference/attributes/hx-swap): defaults to [`htmx.config.defaultSwap`](/reference/config/htmx-config-defaultSwap) when a target is set +Incoming HTML uses these inherited swap attributes: -Without an element or JSON target, ordinary incoming HTML uses `swap:none`. Explicit `hx-swap-oob` and `` swaps still run. +- [`hx-target`](/reference/attributes/hx-target): defaults to the connection element +- [`hx-swap`](/reference/attributes/hx-swap): defaults to [`htmx.config.defaultSwap`](/reference/config/htmx-config-defaultSwap) +- [`hx-select`](/reference/attributes/hx-select): selects content for the connection's swap +- [`hx-select-oob`](/reference/attributes/hx-select-oob): selects more elements to swap -Other defaults: +Defaults: -- [`hx-trigger="load"`](/reference/attributes/hx-trigger#load) +- [`swapEmpty:false`](/reference/attributes/hx-swap#swapempty); set it explicitly in `hx-swap` to override it +- [`hx-trigger="load"`](/reference/attributes/hx-trigger#load); use [`hx-trigger`](#open-connections) to change it - [`ws.reconnect:true`](#wsreconnect) - [`ws.pauseOnBackground:true`](#wspauseonbackground) -Elements using the same normalized URL share one connection. +[Elements using the same URL share one connection](#use-shared-connections). ### `hx-ws:send` -Sends form data and [`hx-vals`](/reference/attributes/hx-vals) as `{headers, body}` JSON. +Sends form data and [`hx-vals`](/reference/attributes/hx-vals) as JSON. ```html
@@ -398,9 +477,9 @@ Sends form data and [`hx-vals`](/reference/attributes/hx-vals) as `{headers, bod Default [`hx-trigger`](/reference/attributes/hx-trigger): -- `change` for inputs other than button and submit inputs, plus `"}) + await htmx.swap("", "#test-playground") document.activeElement.id.should.equal('focused-textarea') document.activeElement.selectionStart.should.equal(6) diff --git a/www/src/content/docs.mdx b/www/src/content/docs.mdx index 4520deeac..f261ea8a1 100644 --- a/www/src/content/docs.mdx +++ b/www/src/content/docs.mdx @@ -677,8 +677,9 @@ All hooks receive `detail.ctx` with full request/response context: - `detail.ctx.request.body` (FormData in `htmx_config_request`) - `detail.ctx.request.headers` (plain mutable object) - `detail.ctx.response.status` -- `detail.ctx.text` (response body, modifiable in `htmx_after_request`) -- `detail.ctx.target` +- `detail.ctx.swap.content` (response body, modifiable in `htmx_after_request`) +- `detail.ctx.swap.target` +- `detail.ctx.swap.style` ##### OOB swap stripping @@ -746,7 +747,7 @@ htmx_before_swap: (elt, detail) => { if (detail.ctx.response.status !== 200) { var target = getRespCodeTarget(elt, detail.ctx.response.status); if (target) { - detail.ctx.target = target; + detail.ctx.swap.target = target; } } } @@ -754,7 +755,7 @@ htmx_before_swap: (elt, detail) => { ##### `transformResponse` -Removed. Modify `detail.ctx.text` in `htmx_after_request`: +Removed. Modify `detail.ctx.swap.content` in `htmx_after_request`: ```javascript // htmx 2.x @@ -772,14 +773,14 @@ transformResponse: function(text, xhr, elt) { htmx_after_request: (elt, detail) => { var tpl = elt.closest('[mustache-template]'); if (tpl) { - var data = JSON.parse(detail.ctx.text); + var data = JSON.parse(detail.ctx.swap.content); var template = document.querySelector('#' + tpl.getAttribute('mustache-template')); - detail.ctx.text = Mustache.render(template.innerHTML, data); + detail.ctx.swap.content = Mustache.render(template.innerHTML, data); } } ``` -Event flow: response received, `ctx.text` set, `htmx:after:request` fires, `ctx.text` consumed into fragment, `htmx:before:swap`. +Event flow: response received, `ctx.swap.content` set, `htmx:after:request` fires, content consumed into a fragment, `htmx:before:swap`. ##### `encodeParameters` @@ -857,7 +858,7 @@ Return truthy if handled, falsy otherwise. Can return an array of elements for s |-----------------------------------------|---------------------------------------------------------------| | `getSelectors()` | `htmx_after_init` hook | | `onEvent(name, evt)` | Individual `htmx_*` hooks | -| `transformResponse(text, xhr, elt)` | `htmx_after_request` hook (modify `detail.ctx.text`) | +| `transformResponse(text, xhr, elt)` | `htmx_after_request` hook (modify `detail.ctx.swap.content`) | | `encodeParameters(xhr, params, elt)` | `htmx_before_request` hook (modify final `detail.ctx.request.body`) | | `isInlineSwap(swapStyle)` | `handle_swap` or name swap style with "outer" prefix | | `handleSwap(style, target, frag, info)` | `handle_swap(style, target, frag, spec)` | diff --git a/www/src/content/reference/03-events/06-htmx-after-swap.md b/www/src/content/reference/03-events/06-htmx-after-swap.md index 4a3414751..9b872efd1 100644 --- a/www/src/content/reference/03-events/06-htmx-after-swap.md +++ b/www/src/content/reference/03-events/06-htmx-after-swap.md @@ -17,7 +17,7 @@ Immediately after the DOM swap operation completes, before elements are processe ```javascript htmx.on('htmx:after:swap', (evt) => { - console.log('Content swapped into:', evt.detail.ctx.target); + console.log('Content swapped into:', evt.detail.ctx.swap.target); // Initialize widgets, scroll to position, etc. }); ``` 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 44c93262b..fc77ba7b1 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 @@ -16,7 +16,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.text` - The response body + - `ctx.swap.content` - The response body ## Example diff --git a/www/src/content/reference/05-methods/13-htmx-swap.md b/www/src/content/reference/05-methods/13-htmx-swap.md index d1403c119..4524ca117 100644 --- a/www/src/content/reference/05-methods/13-htmx-swap.md +++ b/www/src/content/reference/05-methods/13-htmx-swap.md @@ -3,147 +3,146 @@ title: "htmx.swap()" description: "Swaps HTML content" --- -Use `htmx.swap()` to run the swap lifecycle without issuing a request. - -```javascript -await htmx.swap({ - text: '

Done

', - target: '#result' -}) -``` - -htmx swaps the content into `#result` with the default swap style. - -For requests, use [`htmx.ajax()`](/reference/methods/htmx-ajax). +The `htmx.swap()` function runs the swap lifecycle without issuing a request. ## Syntax ```javascript -htmx.swap(ctx) +htmx.swap(content, target) +htmx.swap(content, target, swap) +htmx.swap(content, target, options) ``` -Set the content, target, and swap style in the context: - ```javascript -await htmx.swap({ - text: '

Done

', - target: '#result', - swap: 'outerHTML transition:true' -}) +// Default swap +await htmx.swap('

Done

', '#result') + +// Serialized swap +await htmx.swap( + '

Done

', + '#result', + 'outerHTML transition:true' +) + +// Structured swap +await htmx.swap( + '

Done

', + '#result', + { + style: 'outerHTML', + transition: true + } +) ``` -## Context +## Parameters -### `text` +### `content` The HTML string to swap. ```javascript -await htmx.swap({ - text: 'Saved', - target: '#status' -}) +await htmx.swap('Saved', '#status') ``` ### `target` -The target element or selector. It defaults to `document.body`. +The target element or selector. ```javascript -await htmx.swap({ - text: 'Saved', - target: document.querySelector('#status') -}) -``` - -```javascript -await htmx.swap({ - text: 'Saved', - target: '#status' -}) +await htmx.swap('Done', document.querySelector('#status')) +await htmx.swap('Done', '#status') ``` ### `swap` -A serialized [`hx-swap`](/reference/attributes/hx-swap) value. +A serialized [`hx-swap`](/reference/attributes/hx-swap) specification. ```javascript -await htmx.swap({ - text: 'Saved', - target: '#status', - swap: 'innerHTML transition:true settle:100ms' -}) +await htmx.swap( + 'Done', + '#status', + 'innerHTML transition:true settle:100ms' +) ``` -It defaults to [`htmx.config.defaultSwap`](/reference/config/htmx-config-defaultSwap). +### `options` -### Other Fields +An object with structured swap fields. -| Field | Description | -|---|---| -| `sourceElement` | Element used for relative selectors and swap events | -| `select` | Content selected from `text` | -| `selectOOB` | Out-of-band content selected from `text` | -| `transition` | Whether to use a view transition | - -## Set the Source +```javascript +await htmx.swap('Done', '#status', { + style: 'innerHTML', + transition: true, + settleDelay: '100ms' +}) +``` -Set `sourceElement` when a target or swap modifier uses a relative selector: +Use `swap` to combine serialized or structured swap input with other options: ```javascript -let button = document.querySelector('#save') - -await htmx.swap({ - text: 'Saved', - target: 'closest .result', - sourceElement: button +await htmx.swap('Done', '#status', { + swap: 'innerHTML transition:true', + source: '#save' }) ``` -The source element also receives swap lifecycle events. +Flat swap fields override fields from `swap`. + +Supported fields: + +- `swap` - Serialized or structured swap input +- `style` +- [`select`](/reference/attributes/hx-select) +- [`selectOOB`](/reference/attributes/hx-select-oob) +- `transition` +- `swapDelay` +- `settleDelay` +- Other [`hx-swap` modifiers](/reference/attributes/hx-swap) +- `source` -## Select Response Content +## Source -Use `select` to swap part of the content: +Pass `source` when the swap needs an element for relative selectors or lifecycle events. ```javascript -await htmx.swap({ - text: '

Saved

Ignored

', - target: '#status', - select: '#message' +let button = document.querySelector('#save') + +await htmx.swap('Saved', 'closest .result', { + swap: 'innerHTML transition:true', + source: button }) ``` -Use `selectOOB` for [out-of-band content](/reference/attributes/hx-select-oob). +`source` accepts an element or selector. If omitted, the resolved target becomes the source. ## Events `htmx.swap()` fires: - [`htmx:before:swap`](/reference/events/htmx-before-swap) -- [`htmx:before:settle`](/reference/events/htmx-before-settle) -- [`htmx:after:settle`](/reference/events/htmx-after-settle) - [`htmx:after:swap`](/reference/events/htmx-after-swap) - [`htmx:finally:swap`](/reference/events/htmx-finally-swap) +- [`htmx:before:settle`](/reference/events/htmx-before-settle) +- [`htmx:after:settle`](/reference/events/htmx-after-settle) -Swap events fire on `sourceElement`. Settle events fire on each swap target. +Swap events fire on `source`. Settle events fire on the swap target. ## Return Value -`htmx.swap()` returns a `Promise` that resolves after the swap finishes. +Returns a `Promise` that resolves after the swap finishes. ```javascript -await htmx.swap({ - text: 'Saved', - target: '#result' -}) +await htmx.swap('Saved', '#result') console.log('Swap complete') ``` ## Notes -- `htmx.swap()` processes main, out-of-band, and `` content. +- `content` and `target` come from the positional arguments. +- `source` is public input. Events expose it as `ctx.sourceElement`. - `htmx.swap()` does not issue a request. +- `htmx.swap()` does not run response actions or update history. ## See Also From 1b8942d1cd6be4a762156e8a44baf8f5dc6f509a Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 21 Jul 2026 22:10:44 +0300 Subject: [PATCH 3/9] Run response actions through one lifecycle --- src/editors/jetbrains/htmx.web-types.json | 12 +- src/htmx.d.ts | 51 ++++- src/htmx.js | 152 ++++++------- src/skills/htmx-extension-authoring.md | 20 +- src/skills/htmx-guidance.md | 6 + test/test.html | 5 +- test/tests/unit/__extractHxHeaders.js | 127 ----------- test/tests/unit/__extractResponseActions.js | 97 +++++++++ test/tests/unit/__handleHistoryUpdate.js | 92 -------- .../__handleHxHeadersAndMaybeReturnEarly.js | 57 ----- test/tests/unit/__resolveHistoryAction.js | 24 +-- test/tests/unit/__runActions.js | 200 ++++++++++++++++++ www/src/content/docs.mdx | 31 ++- .../03-events/39-htmx-before-actions.md | 40 ++++ .../03-events/40-htmx-after-actions.md | 29 +++ 15 files changed, 554 insertions(+), 389 deletions(-) delete mode 100644 test/tests/unit/__extractHxHeaders.js create mode 100644 test/tests/unit/__extractResponseActions.js delete mode 100644 test/tests/unit/__handleHistoryUpdate.js delete mode 100644 test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js create mode 100644 test/tests/unit/__runActions.js create mode 100644 www/src/content/reference/03-events/39-htmx-before-actions.md create mode 100644 www/src/content/reference/03-events/40-htmx-after-actions.md diff --git a/src/editors/jetbrains/htmx.web-types.json b/src/editors/jetbrains/htmx.web-types.json index cfefde7fc..38df7d456 100644 --- a/src/editors/jetbrains/htmx.web-types.json +++ b/src/editors/jetbrains/htmx.web-types.json @@ -457,7 +457,7 @@ }, { "name": "after:request", - "description": "Fires after the response body is consumed. `detail.ctx` contains request, response, and text data.", + "description": "Fires after the response body is consumed. `detail.ctx.swap.content` contains the response content.", "doc-url": "https://four.htmx.org/reference/events/htmx-after-request" }, { @@ -465,6 +465,16 @@ "description": "Fires when an HTTP error status code (400 or higher) is received. `detail.ctx.response.status` contains the status code.", "doc-url": "https://four.htmx.org/reference/events/htmx-response-error" }, + { + "name": "before:actions", + "description": "Fires before a set of actions executes. `detail.actions` holds the actions; cancel to skip execution.", + "doc-url": "https://four.htmx.org/reference/events/htmx-before-actions" + }, + { + "name": "after:actions", + "description": "Fires after a set of actions executes. `detail.actions` holds the actions.", + "doc-url": "https://four.htmx.org/reference/events/htmx-after-actions" + }, { "name": "finally:request", "description": "Always fires at the end of the request lifecycle. `detail.ctx` is the request context.", diff --git a/src/htmx.d.ts b/src/htmx.d.ts index b1cb09973..f94d0a3d8 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -292,6 +292,28 @@ export interface HtmxResponse { headers: Headers; } +/** + * Server actions decoded from attributes and HX-* response headers. + * Unknown HX-* headers become custom actions: HX-Toast β†’ toast. + * Core ignores custom actions; extensions consume them in htmx:before:actions / htmx:after:actions. + */ +export interface HtmxActions { + /** URL to push into history. `"true"` uses the request URL, `"false"` skips */ + pushUrl?: string | boolean; + /** URL to replace in history. `"true"` uses the request URL, `"false"` skips */ + replaceUrl?: string | boolean; + /** Event names or HCON object to trigger (HX-Trigger) */ + trigger?: string; + /** Path or HCON options for a follow-up GET navigation (HX-Location) */ + location?: string; + /** URL for a hard redirect via `location.href` (HX-Redirect) */ + redirect?: string; + /** `true` reloads the page (HX-Refresh) */ + refresh?: string | boolean; + /** Custom actions from unknown HX-* headers */ + [action: string]: string | boolean | undefined; +} + /** Request context passed as evt.detail.ctx on most htmx request lifecycle events */ export interface HtmxRequestCtx { /** Element that triggered the request */ @@ -300,15 +322,12 @@ export interface HtmxRequestCtx { sourceEvent: Event | null; /** Swap fields */ swap: HtmxSwap; - /** History actions collected from request attributes */ - actions: { - pushUrl?: string | boolean; - replaceUrl?: string | boolean; - }; /** Fetch request options β€” modify here in htmx:config:request */ request: HtmxRequestOptions; /** Response object, available after fetch resolves */ response?: HtmxResponse; + /** Server actions. Attributes initialize them; HX-* response headers override them */ + actions: HtmxActions; } /** History detail shared by htmx:before:history:update and htmx:after:history:update */ @@ -419,6 +438,18 @@ export interface HtmxEventMap { */ 'htmx:response:error': { ctx: HtmxRequestCtx }; + /** + * Fires before a set of server actions executes. + * Read or mutate `detail.actions`; handle custom actions here. + * Cancel to skip execution and `htmx:after:actions`. + */ + 'htmx:before:actions': { actions: HtmxActions; ctx?: HtmxRequestCtx; [key: string]: unknown }; + + /** + * Fires after a set of server actions executed. + */ + 'htmx:after:actions': { actions: HtmxActions; ctx?: HtmxRequestCtx; [key: string]: unknown }; + /** * Control event β€” fire this on an element to abort its ongoing request. * @example htmx.trigger('#myElement', 'htmx:abort') @@ -469,12 +500,12 @@ export interface HtmxEventMap { * Fires before `history.pushState()` or `history.replaceState()` is called. * Cancel to prevent the history update. */ - 'htmx:before:history:update': { history: HtmxHistoryDetail; sourceElement: Element; response: HtmxResponse }; + 'htmx:before:history:update': { history: HtmxHistoryDetail; sourceElement: Element }; /** * Fires after `history.pushState()` or `history.replaceState()` completes. */ - 'htmx:after:history:update': { history: HtmxHistoryDetail; sourceElement: Element; response: HtmxResponse }; + 'htmx:after:history:update': { history: HtmxHistoryDetail; sourceElement: Element }; /** * Fires after a `history.pushState()` operation (new history entry created). @@ -527,9 +558,11 @@ export interface HtmxAjaxOptions { select?: string; /** Selector for out-of-band swaps */ selectOOB?: string; - /** Push a URL into browser history after the swap. `true` uses the request URL */ + /** Server actions to run, e.g. `{pushUrl: '/inbox'}`. Response headers override these */ + actions?: HtmxActions; + /** Shorthand for `actions.pushUrl`. `true` uses the request URL */ push?: string | boolean; - /** Replace the current history entry after the swap. `true` uses the request URL */ + /** Shorthand for `actions.replaceUrl`. `true` uses the request URL */ replace?: string | boolean; } diff --git a/src/htmx.js b/src/htmx.js index 04f10b6d3..a014c1dc3 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -181,6 +181,7 @@ var htmx = (() => { if (asyncFn) this.#AsyncFunction = asyncFn; }, onTrigger: this.__onTrigger.bind(this), + runActions: this.__runActions.bind(this), htmxProp: this.__htmxProp.bind(this), triggerHtmxEvent: this.__trigger.bind(this), executeJavaScript: this.__executeJavaScript.bind(this) @@ -650,7 +651,20 @@ var htmx = (() => { status: response.status, headers: response.headers, } - this.__extractHxHeaders(ctx); + // Swap directives update ctx.swap; the rest are actions. + let {retarget, reswap, reselect, ...headerActions} = this.__extractResponseActions(ctx.response); + ctx.actions = {...ctx.actions, ...headerActions}; + if (retarget) ctx.swap.target = retarget; + if (reselect) ctx.swap.select = reselect; + if (reswap) { + let {content, target, select, selectOOB} = ctx.swap; + ctx.swap = { + content, target, select, selectOOB, + transition: this.config.transitions, + ...this.__parseSwapSpec(this.config.defaultSwap), + ...this.__parseSwapSpec(reswap) + }; + } if (!this.__trigger(elt, "htmx:before:response", {ctx})) return; ctx.swap.content = await response.text(); if (!this.__trigger(elt, "htmx:after:request", {ctx})) return; @@ -659,31 +673,22 @@ var htmx = (() => { this.__trigger(elt, "htmx:response:error", {ctx}) } - if(this.__handleHeadersAndMaybeReturnEarly(ctx)){ - ctx.keepIndicators = true; - return - } - if (ctx.status === "issuing") { - if (ctx.hx.retarget) ctx.swap.target = ctx.hx.retarget; // HX-Retarget - if (ctx.hx.reswap) { - ctx.swap = { - content: ctx.swap.content, - target: ctx.swap.target, - style: undefined, // default or HX-Reswap - select: ctx.swap.select, - selectOOB: ctx.swap.selectOOB, - transition: this.config.transitions, - // default - ...this.__parseSwapSpec(this.config.defaultSwap), - // HX-Reswap - ...this.__parseSwapSpec(ctx.hx.reswap) - }; - } - if (ctx.hx.reselect) ctx.swap.select = ctx.hx.reselect; // HX-Reselect ctx.status = "response received"; this.__handleStatusCodes(ctx); - this.__handleHistoryUpdate(ctx); + + let {pushUrl, replaceUrl, ...otherActions} = ctx.actions; + let historyAction = this.__resolveHistoryAction(ctx); + ctx.actions = { + ...otherActions, + ...(historyAction && {[historyAction.type + 'Url']: historyAction.path}) + }; + + if (this.__runActions(ctx.actions, ctx.sourceElement, {ctx})) { + ctx.keepIndicators = true; + return + } + await this.__handleSwap(ctx); ctx.status = "swapped"; } @@ -707,33 +712,53 @@ var htmx = (() => { } } - // Extract HX-* response headers into ctx.hx - // Maps: HX-Trigger β†’ ctx.hx.trigger, HX-Push-Url β†’ ctx.hx.pushurl, etc. - __extractHxHeaders(ctx) { - ctx.hx = {} - for (let [k, v] of ctx.response.raw.headers) { - if (k.toLowerCase().startsWith('hx-')) { - ctx.hx[k.slice(3).toLowerCase().replace(/-/g, '')] = v + // Decode all HX-* response headers into a single object. + // HX-Push-Url β†’ pushUrl, HX-Reswap β†’ reswap, HX-Toast β†’ toast. + __extractResponseActions(response) { + let actions = {}; + for (let [name, value] of response.headers) { + name = name.toLowerCase(); + if (name.startsWith('hx-')) { + actions[name.slice(3).replace(/-(\w)/g, (_, c) => c.toUpperCase())] = value; } } + return actions; } - // Handle response headers that abort normal swap processing. - // Returns true if the response was fully handled by a header. - __handleHeadersAndMaybeReturnEarly(ctx) { - if (ctx.hx.trigger) { // HX-Trigger - this.__handleTriggerHeader(ctx.hx.trigger, ctx.sourceElement); + // Run a set of server actions, whole or subset. Timing comes from the call site. + // Unknown actions are left for extensions to handle in the action events. + // Returns true when a terminal action (refresh, redirect, location) ran. + __runActions(actions, element, detail = {}) { + if (!Object.keys(actions).length) return false; + + detail = {...detail, actions}; + if (!this.__trigger(element, "htmx:before:actions", detail)) return false; + let {trigger, pushUrl, replaceUrl, refresh, redirect, location: goTo} = detail.actions; + + if (trigger) this.__handleTriggerHeader(trigger, element); + + if (pushUrl === 'false' || pushUrl === false) pushUrl = null; + if (replaceUrl === 'false' || replaceUrl === false) replaceUrl = null; + if (this.config.history && (pushUrl != null || replaceUrl != null)) { + let type = pushUrl != null ? 'push' : 'replace'; + let path = pushUrl ?? replaceUrl; + if (path === 'true' || path === true) path = location.pathname + location.search; + let historyDetail = {history: {type, path}, sourceElement: element}; + if (this.__trigger(document, "htmx:before:history:update", historyDetail)) { + path = historyDetail.history.path; + if (type === 'push') this.__pushUrlIntoHistory(path); + else this.__replaceUrlInHistory(path); + this.__trigger(document, "htmx:after:history:update", historyDetail); + } } - if (ctx.hx.refresh === 'true') { // HX-Refresh + + let terminal = true; + if (refresh === 'true' || refresh === true) { location.reload(); - return true - } - if (ctx.hx.redirect) { // HX-Redirect - location.href = ctx.hx.redirect; - return true - } - if (ctx.hx.location) { // HX-Location - let path = ctx.hx.location, opts = {}; + } else if (redirect) { + location.href = redirect; + } else if (goTo) { + let path = goTo, opts = {}; if (path[0] === '{' || /[\s,]/.test(path)) { opts = HCON.parse(path); path = opts.path; @@ -741,8 +766,12 @@ var htmx = (() => { } opts.push ??= 'true'; this.ajax('GET', path, opts); - return true + } else { + terminal = false; } + + this.__trigger(element, "htmx:after:actions", detail); + return terminal; } __initTimeout(ctx) { @@ -1790,15 +1819,9 @@ var htmx = (() => { } __resolveHistoryAction(ctx) { - let {sourceElement, hx, response} = ctx; + let {sourceElement, response} = ctx; let {pushUrl: push, replaceUrl: replace} = ctx.actions; - // allow response headers to override history action - if (hx?.pushurl || hx?.replaceurl) { // HX-Push-Url, HX-Replace-Url - push = hx.pushurl; - replace = hx.replaceurl; - } - // if this is a boosted element, default to pushing if (push == null && replace == null && this.__isBoosted(sourceElement)) { push = 'true'; @@ -1812,7 +1835,7 @@ var htmx = (() => { let path = push || replace; // if the path is simply "true" normalize to the current path - if (path === 'true') { + if (path === 'true' || path === true) { let finalUrl = response?.raw?.url || ctx.request.action; let url = new URL(finalUrl, location.href); path = url.pathname + url.search + (ctx.request.anchor ? '#' + ctx.request.anchor : ''); @@ -1822,24 +1845,6 @@ var htmx = (() => { return {type, path}; } - __handleHistoryUpdate(ctx) { - let action = this.__resolveHistoryAction(ctx); - if (!action) return; - - let historyDetail = { - history: action, - sourceElement: ctx.sourceElement, - response: ctx.response - }; - if (!this.__trigger(document, "htmx:before:history:update", historyDetail)) return; - if (action.type === 'push') { - this.__pushUrlIntoHistory(action.path); - } else { - this.__replaceUrlInHistory(action.path); - } - this.__trigger(document, "htmx:after:history:update", historyDetail); - } - // hx-on: binds to directly // hx-on:: is shorthand for hx-on:htmx: (htmx events) __handleHxOnAttributes(node) { @@ -2396,7 +2401,10 @@ var htmx = (() => { ...this.__parseSwapSpec(swap), ...swapOverrides }, ctx.swap); - if (push !== undefined || replace !== undefined) { + // HX-Push-Url / HX-Replace-Url headers outrank hx-status config + if ((push !== undefined || replace !== undefined) + && ctx.response.headers?.get('HX-Push-Url') == null + && ctx.response.headers?.get('HX-Replace-Url') == null) { ctx.actions.pushUrl = push; ctx.actions.replaceUrl = replace; } diff --git a/src/skills/htmx-extension-authoring.md b/src/skills/htmx-extension-authoring.md index 7efd98d89..f9d883564 100644 --- a/src/skills/htmx-extension-authoring.md +++ b/src/skills/htmx-extension-authoring.md @@ -92,6 +92,13 @@ Hook names use underscores (not colons). All hooks receive `(elt, detail)` unles | `htmx_finally_request` | `htmx:finally:request` | When request completes, fails, or is cancelled | | `htmx_error` | `htmx:error` | On any error | +### Actions + +| Hook | Event | Description | +|------|-------|-------------| +| `htmx_before_actions` | `htmx:before:actions` | Before actions run; return `false` to skip | +| `htmx_after_actions` | `htmx:after:actions` | After actions run | + ### Swap | Hook | Event | Description | @@ -164,6 +171,7 @@ init: (internalAPI) => { api = internalAPI; }, | `api.isSoftMatch(oldNode, newNode)` | Test whether two nodes can be morphed | | `api.initSecurity(ttPolicy, syncFn, asyncFn)` | Configure Trusted Types and script constructors | | `api.onTrigger(elt, spec, handler)` | Attach a parsed trigger handler | +| `api.runActions(actions, element, detail)` | Run actions and fire action events | | `api.htmxProp(elt)` | Get an element's internal htmx state | | `api.triggerHtmxEvent(elt, name, detail, bubbles)` | Dispatch an htmx event | | `api.executeJavaScript(thisArg, values, code, expression, isAsync)` | Execute JavaScript through htmx security policy | @@ -188,8 +196,13 @@ The context object available via `detail.ctx` in hook callbacks: ...modifiers, }, actions: { - pushUrl, // hx-push-url value - replaceUrl, // hx-replace-url value + pushUrl, // HX-Push-Url or hx-push-url + replaceUrl, // HX-Replace-Url or hx-replace-url + trigger, // HX-Trigger + location, // HX-Location + redirect, // HX-Redirect + refresh, // HX-Refresh + ...customActions, }, request: { action, // Request URL @@ -208,7 +221,6 @@ The context object available via `detail.ctx` in hook callbacks: status, // HTTP status code headers, // Response headers }, - hx, // Parsed HX-* response headers } ``` @@ -216,6 +228,8 @@ The context object available via `detail.ctx` in hook callbacks: **Modifying the response:** Change `detail.ctx.swap.content` in `htmx_after_request` (before swap). +**Handling custom actions:** Unknown `HX-*` response headers become entries in `detail.ctx.actions` (`HX-Toast` becomes `toast`). Consume them in `htmx_before_actions` through `detail.actions`. Run transport actions with `api.runActions(actions, element, detail)`. + **Overriding fetch:** Set `detail.ctx.fetch` to a function returning a Response or Promise. ## Custom Swap Strategies diff --git a/src/skills/htmx-guidance.md b/src/skills/htmx-guidance.md index 175aaac0f..27533d29c 100644 --- a/src/skills/htmx-guidance.md +++ b/src/skills/htmx-guidance.md @@ -216,6 +216,11 @@ htmx 4 naming convention: `htmx:phase:action` - `htmx:finally:request` -- when request completes, fails, or is cancelled - `htmx:error` -- on any error (network, response, swap) +**Actions:** + +- `htmx:before:actions` -- before actions run. Cancel to skip +- `htmx:after:actions` -- after actions run + **Swap:** - `htmx:before:swap` / `htmx:after:swap` -- before/after content swap @@ -243,6 +248,7 @@ document.body.addEventListener('htmx:config:request', (evt) => { // ctx.swap.target -- swap target element // ctx.swap.style -- hx-swap style // ctx.swap.content -- response content + // ctx.actions -- response and history actions // ctx.request.action -- URL // ctx.request.method -- HTTP method // ctx.request.headers -- headers object diff --git a/test/test.html b/test/test.html index 6b126751a..16ed5fc25 100644 --- a/test/test.html +++ b/test/test.html @@ -90,11 +90,10 @@ - + - - + diff --git a/test/tests/unit/__extractHxHeaders.js b/test/tests/unit/__extractHxHeaders.js deleted file mode 100644 index 2f1316e06..000000000 --- a/test/tests/unit/__extractHxHeaders.js +++ /dev/null @@ -1,127 +0,0 @@ -describe('__extractHxHeaders unit tests', function() { - - beforeEach(function() { - setupTest(); - }); - - afterEach(function() { - cleanupTest(); - }); - - it('extracts HX headers from response', function () { - let ctx = { - response: { - raw: { - headers: new Headers({ - 'HX-Trigger': 'myEvent', - 'HX-Redirect': '/new-page', - 'Content-Type': 'text/html' - }) - } - } - } - - htmx.__extractHxHeaders(ctx) - - assert.equal(ctx.hx.trigger, 'myEvent') - assert.equal(ctx.hx.redirect, '/new-page') - assert.isUndefined(ctx.hx.contenttype) - }) - - it('converts header names to lowercase and removes hyphens', function () { - let ctx = { - response: { - raw: { - headers: new Headers({ - 'HX-Push-Url': '/new-url', - 'HX-Replace-Url': '/replace-url', - 'HX-Re-Swap': 'outerHTML' - }) - } - } - } - - htmx.__extractHxHeaders(ctx) - - assert.equal(ctx.hx.pushurl, '/new-url') - assert.equal(ctx.hx.replaceurl, '/replace-url') - assert.equal(ctx.hx.reswap, 'outerHTML') - }) - - it('handles empty headers', function () { - let ctx = { - response: { - raw: { - headers: new Headers() - } - } - } - - htmx.__extractHxHeaders(ctx) - - assert.deepEqual(ctx.hx, {}) - }) - - it('only extracts headers that start with HX-', function () { - let ctx = { - response: { - raw: { - headers: new Headers({ - 'HX-Trigger': 'myEvent', - 'X-Custom-Header': 'value', - 'Content-Type': 'text/html', - 'HX-Refresh': 'true' - }) - } - } - } - - htmx.__extractHxHeaders(ctx) - - assert.equal(ctx.hx.trigger, 'myEvent') - assert.equal(ctx.hx.refresh, 'true') - assert.isUndefined(ctx.hx.customheader) - assert.isUndefined(ctx.hx.contenttype) - }) - - it('handles case-insensitive HX- prefix', function () { - let ctx = { - response: { - raw: { - headers: new Headers({ - 'hx-trigger': 'lowercase', - 'Hx-Redirect': 'mixedcase', - 'HX-REFRESH': 'uppercase' - }) - } - } - } - - htmx.__extractHxHeaders(ctx) - - assert.equal(ctx.hx.trigger, 'lowercase') - assert.equal(ctx.hx.redirect, 'mixedcase') - assert.equal(ctx.hx.refresh, 'uppercase') - }) - - it('overwrites existing ctx.hx object', function () { - let ctx = { - hx: { - oldValue: 'should be removed' - }, - response: { - raw: { - headers: new Headers({ - 'HX-Trigger': 'newEvent' - }) - } - } - } - - htmx.__extractHxHeaders(ctx) - - assert.equal(ctx.hx.trigger, 'newEvent') - assert.isUndefined(ctx.hx.oldValue) - }) - -}); diff --git a/test/tests/unit/__extractResponseActions.js b/test/tests/unit/__extractResponseActions.js new file mode 100644 index 000000000..14db65006 --- /dev/null +++ b/test/tests/unit/__extractResponseActions.js @@ -0,0 +1,97 @@ +describe('__extractResponseActions unit tests', function() { + + beforeEach(function() { + setupTest(); + }); + + afterEach(function() { + cleanupTest(); + }); + + it('extracts HX headers from response', function () { + let response = { + headers: new Headers({ + 'HX-Trigger': 'myEvent', + 'HX-Redirect': '/new-page', + 'Content-Type': 'text/html' + }) + } + + let actions = htmx.__extractResponseActions(response) + + assert.equal(actions.trigger, 'myEvent') + assert.equal(actions.redirect, '/new-page') + assert.isUndefined(actions.contentType) + }) + + it('converts header names to camelCase', function () { + let response = { + headers: new Headers({ + 'HX-Push-Url': '/new-url', + 'HX-Replace-Url': '/replace-url', + 'HX-Reswap': 'outerHTML' + }) + } + + let actions = htmx.__extractResponseActions(response) + + assert.equal(actions.pushUrl, '/new-url') + assert.equal(actions.replaceUrl, '/replace-url') + assert.equal(actions.reswap, 'outerHTML') + }) + + it('extracts unknown HX headers as custom actions', function () { + let response = { + headers: new Headers({ + 'HX-Toast': 'Saved!', + 'HX-Foo-Bar': 'baz' + }) + } + + let actions = htmx.__extractResponseActions(response) + + assert.equal(actions.toast, 'Saved!') + assert.equal(actions.fooBar, 'baz') + }) + + it('handles empty headers', function () { + let response = { headers: new Headers() } + + assert.deepEqual(htmx.__extractResponseActions(response), {}) + }) + + it('only extracts headers that start with HX-', function () { + let response = { + headers: new Headers({ + 'HX-Trigger': 'myEvent', + 'X-Custom-Header': 'value', + 'Content-Type': 'text/html', + 'HX-Refresh': 'true' + }) + } + + let actions = htmx.__extractResponseActions(response) + + assert.equal(actions.trigger, 'myEvent') + assert.equal(actions.refresh, 'true') + assert.isUndefined(actions.customHeader) + assert.isUndefined(actions.contentType) + }) + + it('handles case-insensitive HX- prefix', function () { + let response = { + headers: new Headers({ + 'hx-trigger': 'lowercase', + 'Hx-Redirect': 'mixedcase', + 'HX-REFRESH': 'uppercase' + }) + } + + let actions = htmx.__extractResponseActions(response) + + assert.equal(actions.trigger, 'lowercase') + assert.equal(actions.redirect, 'mixedcase') + assert.equal(actions.refresh, 'uppercase') + }) + +}); diff --git a/test/tests/unit/__handleHistoryUpdate.js b/test/tests/unit/__handleHistoryUpdate.js deleted file mode 100644 index d1d6879e2..000000000 --- a/test/tests/unit/__handleHistoryUpdate.js +++ /dev/null @@ -1,92 +0,0 @@ -describe('__handleHistoryUpdate unit tests', function() { - - let originalUrl - let originalState - - beforeEach(function() { - setupTest(); - // Save current URL and state - originalUrl = window.location.href - originalState = history.state - }); - - afterEach(function() { - cleanupTest(); - // Restore original URL and state - history.replaceState(originalState, '', originalUrl) - }); - - it('does nothing when push and replace are false', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - actions: { pushUrl: 'false', replaceUrl: 'false' }, - response: { headers: new Headers() }, - request: { action: '/test' } - } - - htmx.__handleHistoryUpdate(ctx) - - assert.equal(window.location.href, originalUrl) - }) - - it('pushes URL when push is set to true', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - actions: { pushUrl: 'true' }, - response: { headers: new Headers() }, - request: { action: '/test-path' } - } - - htmx.__handleHistoryUpdate(ctx) - - assert.include(window.location.href, '/test-path') - }) - - it('replaces URL when replace is set to true', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - actions: { replaceUrl: 'true' }, - response: { headers: new Headers() }, - request: { action: '/replace-path' } - } - - htmx.__handleHistoryUpdate(ctx) - - assert.include(window.location.href, '/replace-path') - }) - - it('pushes specific URL when push is set to path', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - actions: { pushUrl: '/custom-path' }, - response: { headers: new Headers() }, - request: { action: '/test' } - } - - htmx.__handleHistoryUpdate(ctx) - - assert.include(window.location.href, '/custom-path') - }) - - it('pushes redirected URL when push is true and response has raw url', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - actions: { pushUrl: 'true' }, - response: { - headers: new Headers(), - raw: { url: 'http://localhost/redirected-path?foo=bar' } - }, - request: { action: '/test' } - } - - htmx.__handleHistoryUpdate(ctx) - - assert.include(window.location.href, '/redirected-path?foo=bar') - }) - -}); diff --git a/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js b/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js deleted file mode 100644 index e76fec61f..000000000 --- a/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js +++ /dev/null @@ -1,57 +0,0 @@ -describe('__handleHxHeadersAndMaybeReturnEarly unit tests', function() { - - beforeEach(function() { - setupTest(); - }); - - afterEach(function() { - cleanupTest(); - }); - - it('handles hx-trigger header', function () { - let triggerFired = false - let listener = () => { triggerFired = true } - - let container = createProcessedHTML('
') - container.addEventListener('myEvent', listener) - - let ctx = { - hx: { - trigger: 'myEvent' - }, - sourceElement: container - } - - let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx) - - assert.isNotOk(result) - assert.isTrue(triggerFired) - }) - - it('returns false when no headers to handle', function () { - let ctx = { - hx: {}, - sourceElement: createProcessedHTML('
') - } - - let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx) - - assert.isNotOk(result) - }) - - it('returns false when only hx-trigger is present', function () { - let container = createProcessedHTML('
') - - let ctx = { - hx: { - trigger: 'someEvent' - }, - sourceElement: container - } - - let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx) - - assert.isNotOk(result) - }) - -}); diff --git a/test/tests/unit/__resolveHistoryAction.js b/test/tests/unit/__resolveHistoryAction.js index 0c65e2237..5df54ea8c 100644 --- a/test/tests/unit/__resolveHistoryAction.js +++ b/test/tests/unit/__resolveHistoryAction.js @@ -30,22 +30,6 @@ describe('__resolveHistoryAction unit tests', function() { assert.equal(action.path, '/replaced') }) - it('server HX-Push-Url header overrides attribute', function() { - let div = createProcessedHTML('
') - let ctx = { sourceElement: div, actions: { pushUrl: '/from-attr' }, hx: { pushurl: '/from-header' } } - let action = htmx.__resolveHistoryAction(ctx) - assert.equal(action.type, 'push') - assert.equal(action.path, '/from-header') - }) - - it('server HX-Replace-Url header overrides attribute', function() { - let div = createProcessedHTML('
') - let ctx = { sourceElement: div, actions: { replaceUrl: '/from-attr' }, hx: { replaceurl: '/from-header' } } - let action = htmx.__resolveHistoryAction(ctx) - assert.equal(action.type, 'replace') - assert.equal(action.path, '/from-header') - }) - it('push "false" returns null', function() { let div = createProcessedHTML('
') let ctx = { sourceElement: div, actions: { pushUrl: 'false' } } @@ -58,17 +42,17 @@ describe('__resolveHistoryAction unit tests', function() { assert.isNull(htmx.__resolveHistoryAction(ctx)) }) - it('HX-Push-Url: false does not block HX-Replace-Url', function() { + it('pushUrl "false" does not block replaceUrl', function() { let div = createProcessedHTML('
') - let ctx = { sourceElement: div, actions: {}, hx: { pushurl: 'false', replaceurl: '/new-path' } } + let ctx = { sourceElement: div, actions: { pushUrl: 'false', replaceUrl: '/new-path' } } let action = htmx.__resolveHistoryAction(ctx) assert.equal(action.type, 'replace') assert.equal(action.path, '/new-path') }) - it('HX-Replace-Url: false does not block HX-Push-Url', function() { + it('replaceUrl "false" does not block pushUrl', function() { let div = createProcessedHTML('
') - let ctx = { sourceElement: div, actions: {}, hx: { pushurl: '/new-path', replaceurl: 'false' } } + let ctx = { sourceElement: div, actions: { pushUrl: '/new-path', replaceUrl: 'false' } } let action = htmx.__resolveHistoryAction(ctx) assert.equal(action.type, 'push') assert.equal(action.path, '/new-path') diff --git a/test/tests/unit/__runActions.js b/test/tests/unit/__runActions.js new file mode 100644 index 000000000..0ce16bbfd --- /dev/null +++ b/test/tests/unit/__runActions.js @@ -0,0 +1,200 @@ +describe('__runActions unit tests', function() { + + let originalUrl + let originalState + + beforeEach(function() { + setupTest(); + originalUrl = window.location.href + originalState = history.state + }); + + afterEach(function() { + cleanupTest(); + history.replaceState(originalState, '', originalUrl) + }); + + it('runs trigger action', function () { + let triggerFired = false + let container = createProcessedHTML('
') + container.addEventListener('myEvent', () => { triggerFired = true }) + + let terminal = htmx.__runActions({trigger: 'myEvent'}, container) + + assert.isNotOk(terminal) + assert.isTrue(triggerFired) + }) + + it('returns falsy when no terminal action ran', function () { + let container = createProcessedHTML('
') + + assert.isNotOk(htmx.__runActions({}, container)) + assert.isNotOk(htmx.__runActions({trigger: 'someEvent'}, container)) + }) + + it('fires htmx:before:actions and htmx:after:actions', function () { + let events = [] + let container = createProcessedHTML('
') + container.addEventListener('htmx:before:actions', e => events.push(['before', e.detail.actions, e.detail.ctx])) + container.addEventListener('htmx:after:actions', e => events.push(['after', e.detail.actions, e.detail.ctx])) + + htmx.__runActions({trigger: 'someEvent'}, container) + + assert.equal(events.length, 2) + assert.equal(events[0][0], 'before') + assert.equal(events[0][1].trigger, 'someEvent') + assert.equal(events[1][0], 'after') + assert.isUndefined(events[0][2]) + assert.isUndefined(events[1][2]) + }) + + it('cancelling htmx:before:actions skips execution and htmx:after:actions', function () { + let triggerFired = false + let afterFired = false + let container = createProcessedHTML('
') + container.addEventListener('myEvent', () => { triggerFired = true }) + container.addEventListener('htmx:before:actions', e => e.preventDefault()) + container.addEventListener('htmx:after:actions', () => { afterFired = true }) + + let terminal = htmx.__runActions({trigger: 'myEvent'}, container) + + assert.isNotOk(terminal) + assert.isFalse(triggerFired) + assert.isFalse(afterFired) + }) + + it('extensions consume custom actions in htmx:before:actions', function () { + let toastMessage = null + let container = createProcessedHTML('
') + container.addEventListener('htmx:before:actions', e => { + if (e.detail.actions.toast) toastMessage = e.detail.actions.toast + }) + + htmx.__runActions({toast: 'Saved!'}, container) + + assert.equal(toastMessage, 'Saved!') + }) + + it('ignores unknown action keys', function () { + let container = createProcessedHTML('
') + + assert.isNotOk(htmx.__runActions({toast: 'Saved!'}, container)) + }) + + it('pushUrl action pushes into history with history events', function () { + let events = [] + let container = createProcessedHTML('
') + let onBefore = e => events.push(['before', e.detail.history]) + let onAfter = e => events.push(['after', e.detail.history]) + document.addEventListener('htmx:before:history:update', onBefore) + document.addEventListener('htmx:after:history:update', onAfter) + + htmx.__runActions({pushUrl: '/pushed-path'}, container) + + document.removeEventListener('htmx:before:history:update', onBefore) + document.removeEventListener('htmx:after:history:update', onAfter) + + assert.include(window.location.href, '/pushed-path') + assert.equal(events.length, 2) + assert.equal(events[0][1].type, 'push') + assert.equal(events[0][1].path, '/pushed-path') + }) + + it('replaceUrl action replaces the URL', function () { + let container = createProcessedHTML('
') + + htmx.__runActions({replaceUrl: '/replaced-path'}, container) + + assert.include(window.location.href, '/replaced-path') + }) + + it('cancelling htmx:before:history:update skips the history update', function () { + let container = createProcessedHTML('
') + let onBefore = e => e.preventDefault() + document.addEventListener('htmx:before:history:update', onBefore) + + htmx.__runActions({pushUrl: '/cancelled-path'}, container) + + document.removeEventListener('htmx:before:history:update', onBefore) + + assert.equal(window.location.href, originalUrl) + }) + + it('pushUrl "false" does not update history', function () { + let container = createProcessedHTML('
') + + htmx.__runActions({pushUrl: 'false'}, container) + + assert.equal(window.location.href, originalUrl) + }) + + it('does not run history events when history is disabled', function () { + let events = 0 + let container = createProcessedHTML('
') + let onBefore = () => events++ + let onAfter = () => events++ + let originalHistory = htmx.config.history + htmx.config.history = false + document.addEventListener('htmx:before:history:update', onBefore) + document.addEventListener('htmx:after:history:update', onAfter) + + try { + htmx.__runActions({pushUrl: '/disabled-history'}, container) + } finally { + htmx.config.history = originalHistory + document.removeEventListener('htmx:before:history:update', onBefore) + document.removeEventListener('htmx:after:history:update', onAfter) + } + + assert.equal(events, 0) + }) + + it('forwards custom detail to action events', function () { + let seenPart + let container = createProcessedHTML('
') + let part = {id: 1} + container.addEventListener('htmx:before:actions', e => { seenPart = e.detail.part }) + + htmx.__runActions({toast: 'Saved!'}, container, {part}) + + assert.strictEqual(seenPart, part) + }) + + it('ajax push option normalizes to the pushUrl action', async function () { + mockResponse('GET', '/test', 'Done') + createProcessedHTML('
') + + await htmx.ajax('GET', '/test', {target: '#ajax-target', push: '/ajax-pushed'}) + + assert.include(window.location.href, '/ajax-pushed') + }) + + it('HX-Push-Url header overrides hx-push-url attribute', async function () { + mockResponse('GET', '/test', 'Done', {headers: {'HX-Push-Url': '/from-header'}}) + let div = createProcessedHTML('
') + + div.click() + await forRequest() + + assert.include(window.location.href, '/from-header') + }) + + it('custom HX headers and ctx reach htmx:before:actions during requests', async function () { + let toast = null + let actionCtx + mockResponse('GET', '/test', 'Done', {headers: {'HX-Toast': 'Saved!'}}) + let div = createProcessedHTML('
') + div.addEventListener('htmx:before:actions', e => { + toast = e.detail.actions.toast + actionCtx = e.detail.ctx + }) + + div.click() + await forRequest() + + assert.equal(toast, 'Saved!') + assert.equal(actionCtx.sourceElement, div) + assert.equal(div.textContent, 'Done') + }) + +}); diff --git a/www/src/content/docs.mdx b/www/src/content/docs.mdx index f261ea8a1..8affe5a5d 100644 --- a/www/src/content/docs.mdx +++ b/www/src/content/docs.mdx @@ -2818,6 +2818,13 @@ Extensions hook into htmx lifecycle events. Event names use underscores instead | `htmx_finally_request` | [`htmx:finally:request`](/reference/events/htmx-finally-request) | `(elt, detail)` | When request completes, fails, or is cancelled | | `htmx_error` | [`htmx:error`](/reference/events/htmx-error) | `(elt, detail)` | On request error | +##### Action Events + +| Hook Name | Triggered Event | Parameters | Description | +|-----------|----------------|------------|-------------| +| `htmx_before_actions` | [`htmx:before:actions`](/reference/events/htmx-before-actions) | `(elt, detail)` | Before actions run | +| `htmx_after_actions` | [`htmx:after:actions`](/reference/events/htmx-after-actions) | `(elt, detail)` | After actions run | + ##### Swap Events | Hook Name | Triggered Event | Parameters | Description | @@ -2888,6 +2895,7 @@ Available internal API: - `isSoftMatch(oldNode, newNode)` - Test whether two nodes can be morphed - `initSecurity(ttPolicy, syncFn, asyncFn)` - Configure Trusted Types and script constructors - `onTrigger(elt, spec, handler)` - Attach a parsed trigger handler +- `runActions(actions, element, detail)` - Run actions and fire action events - `htmxProp(elt)` - Get an element's internal htmx state - `triggerHtmxEvent(elt, name, detail, bubbles)` - Dispatch an htmx event - `executeJavaScript(thisArg, values, code, expression, isAsync)` - Execute JavaScript through htmx security policy @@ -2902,8 +2910,23 @@ The `detail.ctx` object contains request information: sourceElement, // Element triggering request sourceEvent, // Event that triggered request status, // Request status - target, // Target element for swap - swap, // Swap strategy + swap: { + content, // Response content + target, // Target element + style, // Swap style + select, + selectOOB, + ...modifiers + }, + actions: { + pushUrl, + replaceUrl, + trigger, + location, + redirect, + refresh, + ...customActions + }, request: { action, // Request URL method, // HTTP method @@ -2917,9 +2940,7 @@ The `detail.ctx` object contains request information: raw, // Raw Response object status, // HTTP status code headers // Response headers: https://developer.mozilla.org/en-US/docs/Web/API/Headers - }, - text, // Response text (after request) - hx // HX-* response headers (parsed) + } } ``` diff --git a/www/src/content/reference/03-events/39-htmx-before-actions.md b/www/src/content/reference/03-events/39-htmx-before-actions.md new file mode 100644 index 000000000..6d3d15a51 --- /dev/null +++ b/www/src/content/reference/03-events/39-htmx-before-actions.md @@ -0,0 +1,40 @@ +--- +title: "htmx:before:actions" +description: "Fires before server actions run" +--- + +The `htmx:before:actions` event fires before htmx runs a set of server actions, such as `HX-Trigger` or `HX-Push-Url`. + +## When It Fires + +Before each `runActions()` call executes. Core runs response actions once per request, after status and history rules and before the swap. + +## Event Detail + +- `actions` - Actions about to run, e.g. `{trigger: "myEvent"}` +- `ctx` - Request context for HTTP response actions + +Other callers can add detail. For example, multipart actions also include `part`. + +## Example + +```javascript +htmx.on('htmx:before:actions', (evt) => { + console.log('Running actions:', evt.detail.actions); +}); +``` + +Unknown `HX-*` response headers become custom actions: `HX-Toast: Saved!` arrives as `actions.toast`. Core ignores them; handle them here. + +```javascript +htmx.on('htmx:before:actions', (evt) => { + if (evt.detail.actions.toast) showToast(evt.detail.actions.toast); +}); +``` + +Cancel this event to skip execution and [`htmx:after:actions`](/reference/events/htmx-after-actions). + +## See Also + +- [`htmx:after:actions`](/reference/events/htmx-after-actions) +- [`htmx:before:history:update`](/reference/events/htmx-before-history-update) diff --git a/www/src/content/reference/03-events/40-htmx-after-actions.md b/www/src/content/reference/03-events/40-htmx-after-actions.md new file mode 100644 index 000000000..676d2368b --- /dev/null +++ b/www/src/content/reference/03-events/40-htmx-after-actions.md @@ -0,0 +1,29 @@ +--- +title: "htmx:after:actions" +description: "Fires after server actions run" +--- + +The `htmx:after:actions` event fires after htmx runs a set of server actions. + +## When It Fires + +After each `runActions()` call executes. It does not fire when [`htmx:before:actions`](/reference/events/htmx-before-actions) is cancelled. + +## Event Detail + +- `actions` - Actions that ran, e.g. `{trigger: "myEvent"}` +- `ctx` - Request context for HTTP response actions + +Other callers can add detail. For example, multipart actions also include `part`. + +## Example + +```javascript +htmx.on('htmx:after:actions', (evt) => { + console.log('Actions ran:', evt.detail.actions); +}); +``` + +## See Also + +- [`htmx:before:actions`](/reference/events/htmx-before-actions) From f5373b82b08211962d75de4b63efa10ccf8c2b62 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 21 Jul 2026 22:28:18 +0300 Subject: [PATCH 4/9] Resolve response status into canonical state --- src/htmx.js | 66 +++++---- test/test.html | 2 +- test/tests/unit/__handleStatusCodes.js | 178 ------------------------- test/tests/unit/__resolveStatusCode.js | 126 +++++++++++++++++ 4 files changed, 169 insertions(+), 203 deletions(-) delete mode 100644 test/tests/unit/__handleStatusCodes.js create mode 100644 test/tests/unit/__resolveStatusCode.js diff --git a/src/htmx.js b/src/htmx.js index a014c1dc3..d04939fc0 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -675,7 +675,13 @@ var htmx = (() => { if (ctx.status === "issuing") { ctx.status = "response received"; - this.__handleStatusCodes(ctx); + + let {swap: statusSwap, actions: statusActions} = this.__resolveStatusCode( + ctx.response, + ctx.sourceElement + ); + ctx.swap = {...ctx.swap, ...statusSwap}; + ctx.actions = {...ctx.actions, ...statusActions}; let {pushUrl, replaceUrl, ...otherActions} = ctx.actions; let historyAction = this.__resolveHistoryAction(ctx); @@ -2385,32 +2391,44 @@ var htmx = (() => { return persistentIds; } - __handleStatusCodes(ctx) { - let status = ctx.response.raw.status; - let noSwapStrings = this.config.noSwap.map(x => x + ""); - let str = status + "" - for (let pattern of [str, str.slice(0, 2) + 'x', str[0] + 'xx']) { - if (noSwapStrings.includes(pattern)) { - ctx.swap.style = "none"; - return + __resolveStatusCode(response, element) { + let statusCode = String(response.status); + + let statusCodePatterns = [ + statusCode, + statusCode.slice(0, 2) + 'x', + statusCode[0] + 'xx' + ]; + let noSwapPatterns = this.config.noSwap.map(String); + + for (let pattern of statusCodePatterns) { + if (noSwapPatterns.includes(pattern)) { + return {swap: {style: 'none'}, actions: {}}; } - let hxStatus = this.__attributeValue(ctx.sourceElement, "hx-status:" + pattern); - if (hxStatus) { - let {swap, push, replace, ...swapOverrides} = HCON.parse(hxStatus); - HCON.merge({ - ...this.__parseSwapSpec(swap), - ...swapOverrides - }, ctx.swap); - // HX-Push-Url / HX-Replace-Url headers outrank hx-status config - if ((push !== undefined || replace !== undefined) - && ctx.response.headers?.get('HX-Push-Url') == null - && ctx.response.headers?.get('HX-Replace-Url') == null) { - ctx.actions.pushUrl = push; - ctx.actions.replaceUrl = replace; - } - return; + + let hxStatus = this.__attributeValue(element, 'hx-status:' + pattern); + if (!hxStatus) continue; + + let {swap, push, replace, ...swapOptions} = HCON.parse(hxStatus); + let actions = {}; + let hasHistoryHeader = + response.headers?.get('HX-Push-Url') != null || + response.headers?.get('HX-Replace-Url') != null; + + if (!hasHistoryHeader && (push !== undefined || replace !== undefined)) { + actions = {pushUrl: push, replaceUrl: replace}; } + + return { + swap: { + ...this.__parseSwapSpec(swap), + ...swapOptions + }, + actions + }; } + + return {swap: {}, actions: {}}; } __submitTransitionTask(task) { diff --git a/test/test.html b/test/test.html index 16ed5fc25..a212196d0 100644 --- a/test/test.html +++ b/test/test.html @@ -95,7 +95,7 @@ - + diff --git a/test/tests/unit/__handleStatusCodes.js b/test/tests/unit/__handleStatusCodes.js deleted file mode 100644 index d7d3f922b..000000000 --- a/test/tests/unit/__handleStatusCodes.js +++ /dev/null @@ -1,178 +0,0 @@ -describe('__handleStatusCodes unit tests', function() { - - beforeEach(function() { - setupTest(); - }); - - afterEach(function() { - cleanupTest(); - }); - - it('sets swap to none for 204 status', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { style: 'innerHTML' }, - response: { - raw: { status: 204 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'none') - }) - - it('sets swap to none for 304 status', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { style: 'innerHTML' }, - response: { - raw: { status: 304 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'none') - }) - - it('does not change swap for 200 status', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { style: 'innerHTML' }, - response: { - raw: { status: 200 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'innerHTML') - }) - - it('applies hx-status:404 override', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { style: 'innerHTML' }, - response: { - raw: { status: 404 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'outerHTML') - }) - - it('applies hx-status:4xx pattern match', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { style: 'innerHTML' }, - response: { - raw: { status: 403 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'delete') - }) - - it('applies hx-status:5xx pattern match', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { style: 'innerHTML' }, - response: { - raw: { status: 500 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'none') - }) - - it('prefers exact match over pattern match', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { style: 'innerHTML' }, - response: { - raw: { status: 404 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'outerHTML') - }) - - it('parses target modifier in hx-status value', function () { - createProcessedHTML('
') - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { - style: 'outerHTML', - target: div - }, - response: { - raw: { status: 404 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'innerHTML') - assert.equal(ctx.swap.target, '#error-target') - }) - - it('can set multiple ctx properties with hx-status', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { - style: 'innerHTML', - select: null - }, - actions: { pushUrl: 'true' }, - response: { - raw: { status: 500 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.style, 'none') - assert.equal(ctx.swap.select, '#error') - assert.equal(ctx.actions.pushUrl, false) - }) - - it('hx-status can override canonical swap properties', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: { - style: 'innerHTML', - target: '#main', - transition: true - }, - response: { - raw: { status: 404 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap.target, '#alt') - assert.equal(ctx.swap.style, 'outerHTML') - assert.equal(ctx.swap.transition, false) - }) - -}); diff --git a/test/tests/unit/__resolveStatusCode.js b/test/tests/unit/__resolveStatusCode.js new file mode 100644 index 000000000..6ec918e55 --- /dev/null +++ b/test/tests/unit/__resolveStatusCode.js @@ -0,0 +1,126 @@ +describe('__resolveStatusCode unit tests', function() { + + beforeEach(function() { + setupTest(); + }); + + afterEach(function() { + cleanupTest(); + }); + + it('sets swap to none for 204 status', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 204, headers: new Headers()}, + div + ) + + assert.equal(result.swap.style, 'none') + }) + + it('sets swap to none for 304 status', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 304, headers: new Headers()}, + div + ) + + assert.equal(result.swap.style, 'none') + }) + + it('returns no overrides for 200 status', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 200, headers: new Headers()}, + div + ) + + assert.deepEqual(result, {swap: {}, actions: {}}) + }) + + it('applies hx-status:404 override', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 404, headers: new Headers()}, + div + ) + + assert.equal(result.swap.style, 'outerHTML') + }) + + it('applies hx-status:4xx pattern match', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 403, headers: new Headers()}, + div + ) + + assert.equal(result.swap.style, 'delete') + }) + + it('applies hx-status:5xx pattern match', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 500, headers: new Headers()}, + div + ) + + assert.equal(result.swap.style, 'none') + }) + + it('prefers exact match over pattern match', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 404, headers: new Headers()}, + div + ) + + assert.equal(result.swap.style, 'outerHTML') + }) + + it('parses target modifier in hx-status value', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 404, headers: new Headers()}, + div + ) + + assert.equal(result.swap.style, 'innerHTML') + assert.equal(result.swap.target, '#error-target') + }) + + it('returns swap and history overrides', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 500, headers: new Headers()}, + div + ) + + assert.equal(result.swap.style, 'none') + assert.equal(result.swap.select, '#error') + assert.equal(result.actions.pushUrl, false) + }) + + it('overrides canonical swap properties', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 404, headers: new Headers()}, + div + ) + + assert.equal(result.swap.target, '#alt') + assert.equal(result.swap.style, 'outerHTML') + assert.equal(result.swap.transition, false) + }) + + it('does not override response history headers', function () { + let div = createProcessedHTML('
') + let result = htmx.__resolveStatusCode( + {status: 500, headers: new Headers({'HX-Push-Url': '/header'})}, + div + ) + + assert.deepEqual(result.actions, {}) + }) + +}); From 86cc99ac3dceee1587f30ac224ccb95e8a02675d Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 21 Jul 2026 22:29:51 +0300 Subject: [PATCH 5/9] Return request queue admission results --- src/htmx.js | 127 ++++----- src/skills/htmx-extension-authoring.md | 1 - test/tests/unit/__getRequestQueue.js | 343 ++++++++----------------- test/tests/unit/__issueRequest.js | 18 -- www/src/content/docs.mdx | 1 - 5 files changed, 163 insertions(+), 327 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index d04939fc0..3c2e0890a 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -87,58 +87,46 @@ var htmx = (() => { }, }; - class ReqQ { - #c = null - #q = [] - - issue(ctx, queueStrategy) { - ctx.queueStrategy = queueStrategy - if (!this.#c) { - this.#c = ctx - return true + class RequestQueue { + #current = null // {strategy, abort} + #queue = [] // start callbacks for waiting requests + + // Returns "run", "queued", or "dropped". + issue(strategy, abort, start) { + if (!this.#current) { + this.#current = {strategy, abort} + return "run" + } + // Replace strategy OR current is abortable: abort current and run new + if (strategy === "replace" || (strategy !== "abort" && this.#current.strategy === "abort")) { + this.#queue = [] + this.#current.abort?.() + this.#current = {strategy, abort} + return "run" + } + if (strategy === "queue all") { + this.#queue.push(start) + } else if (strategy === "queue last") { + this.#queue = [start] + } else if (strategy !== "abort" && strategy !== "drop" && this.#queue.length === 0) { + // default queue first + this.#queue.push(start) } else { - // Replace strategy OR current is abortable: abort current and issue new - if (queueStrategy === "replace" || (queueStrategy !== "abort" && this.#c.queueStrategy === "abort")) { - this.#q.forEach(value => value.status = "dropped"); - this.#q = [] - this.#c.request?.abort?.(); - this.#c = ctx - return true - } else if (queueStrategy === "queue all") { - this.#q.push(ctx) - ctx.status = "queued"; - } else if (queueStrategy === "drop") { - // ignore the request - ctx.status = "dropped"; - } else if (queueStrategy === "queue last") { - this.#q.forEach(value => value.status = "dropped"); - this.#q = [ctx] - ctx.status = "queued"; - } else if (this.#q.length === 0 && queueStrategy !== "abort") { - // default queue first - this.#q.push(ctx) - ctx.status = "queued"; - } else { - ctx.status = "dropped"; - } - return false + return "dropped" } + return "queued" } finish() { - this.#c = null + this.#current = null } - next() { - return this.#q.shift() + startNext() { + this.#queue.shift()?.() } abort() { - this.#c?.request?.abort?.() - } - - more() { - return this.#q?.length + this.#current?.abort?.() } } @@ -437,7 +425,6 @@ var htmx = (() => { let ctx = { sourceElement, sourceEvent, - status: "created", confirm: hxConfirm, request: { validate: hxValidate === "true", @@ -617,9 +604,7 @@ var htmx = (() => { let syncStrategy = this.__determineSyncStrategy(elt); let requestQueue = this.__getRequestQueue(elt); - if (!requestQueue.issue(ctx, syncStrategy)) return - - ctx.status = "issuing" + if (requestQueue.issue(syncStrategy, () => ctx.request?.abort?.(), () => this.__issueRequest(ctx)) !== "run") return let indicators = []; let disableElements = []; @@ -673,34 +658,28 @@ var htmx = (() => { this.__trigger(elt, "htmx:response:error", {ctx}) } - if (ctx.status === "issuing") { - ctx.status = "response received"; - - let {swap: statusSwap, actions: statusActions} = this.__resolveStatusCode( - ctx.response, - ctx.sourceElement - ); - ctx.swap = {...ctx.swap, ...statusSwap}; - ctx.actions = {...ctx.actions, ...statusActions}; - - let {pushUrl, replaceUrl, ...otherActions} = ctx.actions; - let historyAction = this.__resolveHistoryAction(ctx); - ctx.actions = { - ...otherActions, - ...(historyAction && {[historyAction.type + 'Url']: historyAction.path}) - }; - - if (this.__runActions(ctx.actions, ctx.sourceElement, {ctx})) { - ctx.keepIndicators = true; - return - } + let {swap: statusSwap, actions: statusActions} = this.__resolveStatusCode( + ctx.response, + ctx.sourceElement + ); + ctx.swap = {...ctx.swap, ...statusSwap}; + ctx.actions = {...ctx.actions, ...statusActions}; + + let {pushUrl, replaceUrl, ...otherActions} = ctx.actions; + let historyAction = this.__resolveHistoryAction(ctx); + ctx.actions = { + ...otherActions, + ...(historyAction && {[historyAction.type + 'Url']: historyAction.path}) + }; - await this.__handleSwap(ctx); - ctx.status = "swapped"; + if (this.__runActions(ctx.actions, ctx.sourceElement, {ctx})) { + ctx.keepIndicators = true; + return } + await this.__handleSwap(ctx); + } catch (error) { - ctx.status = "error: " + error; this.__trigger(elt, "htmx:error", {ctx, error}) } finally { clearTimeout(ctx.requestTimeout); @@ -711,10 +690,8 @@ var htmx = (() => { } requestQueue.finish() - if (requestQueue.more()) { - // intentionally not awaited β€” __issueRequest has its own try/catch - this.__issueRequest(requestQueue.next()) - } + // start callbacks are intentionally not awaited; __issueRequest has its own try/catch + requestQueue.startNext() } } @@ -805,7 +782,7 @@ var htmx = (() => { : (/^(drop|abort|replace|queue)/.test(hxSync) ? null : hxSync); if (selector) syncElt = this.__findOrWarn(elt, selector, "hx-sync") || elt; } - return this.__htmxState(syncElt).rq ||= new ReqQ() + return this.__htmxState(syncElt).rq ||= new RequestQueue() } __isModifierKeyClick(evt) { diff --git a/src/skills/htmx-extension-authoring.md b/src/skills/htmx-extension-authoring.md index f9d883564..13f5c98db 100644 --- a/src/skills/htmx-extension-authoring.md +++ b/src/skills/htmx-extension-authoring.md @@ -185,7 +185,6 @@ The context object available via `detail.ctx` in hook callbacks: { sourceElement, // Element that triggered the request sourceEvent, // The triggering DOM event - status, // Request status string swap: { content, // Response text (after request) target, // Target element diff --git a/test/tests/unit/__getRequestQueue.js b/test/tests/unit/__getRequestQueue.js index ab0c4828f..b6e87d781 100644 --- a/test/tests/unit/__getRequestQueue.js +++ b/test/tests/unit/__getRequestQueue.js @@ -1,5 +1,7 @@ describe('__getRequestQueue / RequestQueue unit tests', function() { + const noop = () => {} + beforeEach(function() { setupTest(); }); @@ -10,171 +12,121 @@ describe('__getRequestQueue / RequestQueue unit tests', function() { it('allows first request when queue is empty', function () { let div = createProcessedHTML('
') - let ctx = htmx.__createRequestContext(div, new Event('click')) let queue = htmx.__getRequestQueue(div) - let result = queue.issue(ctx, 'queue first') - - assert.isTrue(result) + assert.equal(queue.issue('queue first', noop, noop), 'run') }) it('queues request with "queue all" strategy', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) - // Issue first request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx1, 'queue all') + queue.issue('queue all', noop, noop) + let result = queue.issue('queue all', noop, noop) - // Queue second request - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - let result = queue.issue(ctx2, 'queue all') - - assert.isFalse(result) - assert.equal(ctx2.status, 'queued') + assert.equal(result, 'queued') }) it('drops request with "drop" strategy', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) - // Issue first request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx1, 'drop') - - // Drop second request - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - let result = queue.issue(ctx2, 'drop') + queue.issue('drop', noop, noop) + let result = queue.issue('drop', noop, noop) - assert.isFalse(result) - assert.equal(ctx2.status, 'dropped') + assert.equal(result, 'dropped') }) it('queues only last with "queue last" strategy', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let started = [] - // Issue first request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx1, 'queue last') + queue.issue('queue last', noop, () => started.push(1)) + queue.issue('queue last', noop, () => started.push(2)) + let result = queue.issue('queue last', noop, () => started.push(3)) - // Queue second request - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx2, 'queue last') + assert.equal(result, 'queued') - // Queue third request (should drop ctx2) - let ctx3 = htmx.__createRequestContext(div, new Event('click')) - let result = queue.issue(ctx3, 'queue last') + queue.finish() + queue.startNext() + queue.startNext() - assert.isFalse(result) - assert.equal(ctx2.status, 'dropped') - assert.equal(ctx3.status, 'queued') + assert.deepEqual(started, [3]) }) it('replaces current request with "replace" strategy', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - // Issue first request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'replace') + queue.issue('replace', () => { aborted = true }, noop) + let result = queue.issue('replace', noop, noop) - // Replace with second request - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - let result = queue.issue(ctx2, 'replace') - - assert.isTrue(result) - assert.isTrue(ctx1.aborted) + assert.equal(result, 'run') + assert.isTrue(aborted) }) it('defaults to "queue first" when strategy not specified', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let started = [] - // Issue first request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx1, 'queue first') + queue.issue('queue first', noop, () => started.push(1)) + let second = queue.issue('queue first', noop, () => started.push(2)) + let third = queue.issue('queue first', noop, () => started.push(3)) - // Queue second request - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx2, 'queue first') + assert.equal(second, 'queued') + assert.equal(third, 'dropped') - // Third request should be dropped (not queued) - let ctx3 = htmx.__createRequestContext(div, new Event('click')) - let result = queue.issue(ctx3, 'queue first') + queue.finish() + queue.startNext() + queue.startNext() - assert.isFalse(result) - assert.equal(ctx2.status, 'queued') - assert.equal(ctx3.status, 'dropped') + assert.deepEqual(started, [2]) }) - it('hasMore returns truthy when queue has requests', function () { + it('startNext runs the next queued start callback once', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let started = [] - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx1, 'queue all') + queue.issue('queue all', noop, () => started.push(1)) + queue.issue('queue all', noop, () => started.push(2)) + queue.issue('queue all', noop, () => started.push(3)) - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx2, 'queue all') + queue.finish() + queue.startNext() - assert.isOk(queue.more()) + assert.deepEqual(started, [2]) }) - it('hasMore returns falsey when queue is empty', function () { + it('startNext does nothing when the queue is empty', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) - assert.isNotOk(queue.more()) + queue.startNext() }) - it('finish returns next queued request', function () { + it('finish clears the current request', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx1, 'queue all') - - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx2, 'queue all') + queue.issue('queue first', noop, noop) + queue.finish() - queue.finish(ctx1) - let next = queue.next() - - assert.equal(next, ctx2) + assert.equal(queue.issue('queue first', noop, noop), 'run') }) - it('nextRequest clears current request', function () { + it('abort calls abort on the current request', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx1, 'queue all') - - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx2, 'queue all') - - queue.finish(ctx1) - queue.next() - - // Should now allow a new request - let ctx3 = htmx.__createRequestContext(div, new Event('click')) - let result = queue.issue(ctx3, 'queue first') - - assert.isTrue(result) - }) - - it('abortCurrentRequest calls abort on current request', function () { - let div = createProcessedHTML('
') - let queue = htmx.__getRequestQueue(div) - - let ctx = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx, 'queue first') - + queue.issue('queue first', () => { aborted = true }, noop) queue.abort() - assert.isTrue(ctx.request.signal.aborted) + assert.isTrue(aborted) }) it('returns same queue for same element', function () { @@ -199,28 +151,23 @@ describe('__getRequestQueue / RequestQueue unit tests', function() { it('hx-sync="drop" without selector uses drop strategy', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx1, htmx.__determineSyncStrategy(div)) - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - let result = queue.issue(ctx2, htmx.__determineSyncStrategy(div)) + queue.issue(htmx.__determineSyncStrategy(div), noop, noop) + let result = queue.issue(htmx.__determineSyncStrategy(div), noop, noop) - assert.isFalse(result) - assert.equal(ctx2.status, 'dropped') + assert.equal(result, 'dropped') }) it('hx-sync="abort" without selector uses abort strategy', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, htmx.__determineSyncStrategy(div)) + let aborted = false - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - let result = queue.issue(ctx2, htmx.__determineSyncStrategy(div)) + queue.issue(htmx.__determineSyncStrategy(div), () => { aborted = true }, noop) + let result = queue.issue(htmx.__determineSyncStrategy(div), noop, noop) - assert.isFalse(result) - assert.equal(ctx2.status, 'dropped') + assert.equal(result, 'dropped') + assert.isFalse(aborted) }) it('hx-sync="selector:drop" uses drop strategy', function () { @@ -258,150 +205,91 @@ describe('__getRequestQueue / RequestQueue unit tests', function() { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) - let ctx = htmx.__createRequestContext(div, new Event('click')) - ctx.request = {abort: () => { ctx.aborted = true }} - let result = queue.issue(ctx, 'abort') - - assert.isTrue(result) - assert.equal(ctx.queueStrategy, 'abort') + assert.equal(queue.issue('abort', noop, noop), 'run') }) it('abort strategy: any request can abort an abortable request', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - // Issue abort request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'abort') - - // Issue drop request - should abort the abort request - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - ctx2.request = {abort: () => { ctx2.aborted = true }} - let result = queue.issue(ctx2, 'drop') + queue.issue('abort', () => { aborted = true }, noop) + let result = queue.issue('drop', noop, noop) - assert.isTrue(result) - assert.isTrue(ctx1.aborted) + assert.equal(result, 'run') + assert.isTrue(aborted) }) it('abort strategy: another abort request drops when abort request is in flight', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - // Issue abort request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.aborted = false - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'abort') + queue.issue('abort', () => { aborted = true }, noop) + let result = queue.issue('abort', noop, noop) - // Issue another abort request - should be dropped - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - ctx2.aborted = false - ctx2.request = {abort: () => { ctx2.aborted = true }} - let result = queue.issue(ctx2, 'abort') - - assert.isFalse(result) - assert.equal(ctx2.status, 'dropped') - assert.isFalse(ctx1.aborted) + assert.equal(result, 'dropped') + assert.isFalse(aborted) }) it('abort strategy: abort request drops itself if non-abortable request is in flight', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - // Issue drop request (not abortable) - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.aborted = false - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'drop') - - // Issue abort request - should be dropped - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - ctx2.aborted = false - ctx2.request = {abort: () => { ctx2.aborted = true }} - let result = queue.issue(ctx2, 'abort') + queue.issue('drop', () => { aborted = true }, noop) + let result = queue.issue('abort', noop, noop) - assert.isFalse(result) - assert.equal(ctx2.status, 'dropped') - assert.isFalse(ctx1.aborted) + assert.equal(result, 'dropped') + assert.isFalse(aborted) }) it('abort strategy: replace request can abort an abortable request', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - // Issue abort request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'abort') - - // Issue replace request - should abort the abort request - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - ctx2.request = {abort: () => { ctx2.aborted = true }} - let result = queue.issue(ctx2, 'replace') + queue.issue('abort', () => { aborted = true }, noop) + let result = queue.issue('replace', noop, noop) - assert.isTrue(result) - assert.isTrue(ctx1.aborted) + assert.equal(result, 'run') + assert.isTrue(aborted) }) it('abort strategy: queue-all request can abort an abortable request', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - // Issue abort request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'abort') + queue.issue('abort', () => { aborted = true }, noop) + let result = queue.issue('queue all', noop, noop) - // Issue queue-all request - should abort the abort request - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - ctx2.request = {abort: () => { ctx2.aborted = true }} - let result = queue.issue(ctx2, 'queue all') - - assert.isTrue(result) - assert.isTrue(ctx1.aborted) + assert.equal(result, 'run') + assert.isTrue(aborted) }) it('abort strategy: abort request drops itself when replace request is in flight', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - // Issue replace request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.aborted = false - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'replace') - - // Issue abort request - should be dropped - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - ctx2.aborted = false - ctx2.request = {abort: () => { ctx2.aborted = true }} - let result = queue.issue(ctx2, 'abort') + queue.issue('replace', () => { aborted = true }, noop) + let result = queue.issue('abort', noop, noop) - assert.isFalse(result) - assert.equal(ctx2.status, 'dropped') - assert.isFalse(ctx1.aborted) + assert.equal(result, 'dropped') + assert.isFalse(aborted) }) it('abort strategy: abort request drops itself when queue-first request is in flight', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false - // Issue queue-first request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.aborted = false - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'queue first') + queue.issue('queue first', () => { aborted = true }, noop) + let result = queue.issue('abort', noop, noop) - // Issue abort request - should be dropped - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - ctx2.aborted = false - ctx2.request = {abort: () => { ctx2.aborted = true }} - let result = queue.issue(ctx2, 'abort') - - assert.isFalse(result) - assert.equal(ctx2.status, 'dropped') - assert.isFalse(ctx1.aborted) + assert.equal(result, 'dropped') + assert.isFalse(aborted) }) // hx-sync value parsing tests @@ -456,34 +344,25 @@ describe('__getRequestQueue / RequestQueue unit tests', function() { assert.equal(htmx.__getRequestQueue(a), htmx.__getRequestQueue(b)) }) - it('abort strategy: clears queue when aborting current request', function () { + it('replace strategy clears queued requests when aborting current', function () { let div = createProcessedHTML('
') let queue = htmx.__getRequestQueue(div) + let aborted = false + let started = [] + + queue.issue('drop', () => { aborted = true }, noop) + queue.issue('queue all', noop, () => started.push(2)) + queue.issue('queue all', noop, () => started.push(3)) + + let result = queue.issue('replace', noop, noop) + + assert.equal(result, 'run') + assert.isTrue(aborted) + + queue.finish() + queue.startNext() - // Issue non-abortable request - let ctx1 = htmx.__createRequestContext(div, new Event('click')) - ctx1.aborted = false - ctx1.request = {abort: () => { ctx1.aborted = true }} - queue.issue(ctx1, 'drop') - - // Queue some requests - let ctx2 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx2, 'queue all') - - let ctx3 = htmx.__createRequestContext(div, new Event('click')) - queue.issue(ctx3, 'queue all') - - // Issue replace request - should clear queue - let ctx4 = htmx.__createRequestContext(div, new Event('click')) - ctx4.aborted = false - ctx4.request = {abort: () => { ctx4.aborted = true }} - let result = queue.issue(ctx4, 'replace') - - assert.isTrue(result) - assert.isTrue(ctx1.aborted) - assert.equal(ctx2.status, 'dropped') - assert.equal(ctx3.status, 'dropped') - assert.isNotOk(queue.more()) + assert.deepEqual(started, []) }) }); diff --git a/test/tests/unit/__issueRequest.js b/test/tests/unit/__issueRequest.js index 6d9850783..be048699c 100644 --- a/test/tests/unit/__issueRequest.js +++ b/test/tests/unit/__issueRequest.js @@ -190,24 +190,6 @@ describe('__issueRequest unit tests', function() { assert.isTrue(finallyFired) }) - it('updates ctx.status through request lifecycle', async function () { - let div = createProcessedHTML('
') - let ctx = htmx.__createRequestContext(div, new Event('click')) - - let statuses = [] - div.addEventListener('htmx:before:request', () => statuses.push(ctx.status)) - - ctx.fetch = async () => { - statuses.push(ctx.status) - return { status: 200, headers: new Headers(), text: async () => '' } - } - - await htmx.__issueRequest(ctx) - statuses.push(ctx.status) - - assert.include(statuses, 'issuing') - assert.include(statuses, 'swapped') - }) it('processes next queued request after completion', async function () { let div = createProcessedHTML('
') diff --git a/www/src/content/docs.mdx b/www/src/content/docs.mdx index 8affe5a5d..9aacee32c 100644 --- a/www/src/content/docs.mdx +++ b/www/src/content/docs.mdx @@ -2909,7 +2909,6 @@ The `detail.ctx` object contains request information: { sourceElement, // Element triggering request sourceEvent, // Event that triggered request - status, // Request status swap: { content, // Response content target, // Target element From d20bf63f9160278f58c7bb6cb2b634fc0d8648e5 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 21 Jul 2026 22:39:05 +0300 Subject: [PATCH 6/9] Canonicalize response lifecycle --- src/editors/jetbrains/htmx.web-types.json | 19 +++-- src/ext/htmx-2-compat.js | 2 +- src/ext/hx-alpine-compat.js | 2 +- src/ext/hx-browser-indicator.js | 2 +- src/ext/hx-csp.js | 2 +- src/htmx.d.ts | 20 +++-- src/htmx.js | 7 +- src/scripts/upgrade-check.py | 2 +- src/skills/htmx-debugging.md | 3 +- src/skills/htmx-extension-authoring.md | 17 ++-- src/skills/htmx-guidance.md | 7 +- src/skills/htmx-upgrade-from-htmx2.md | 4 +- test/lib/helpers.js | 2 +- test/manual/hx-redirect-indicator.html | 2 +- test/tests/ext/hx-csp.js | 2 +- test/tests/unit/__issueRequest.js | 82 ++++++++++++++++++- test/tests/unit/morph.js | 2 +- test/tests/unit/process.js | 4 +- www/src/content/docs.mdx | 34 ++++---- .../content/patterns/05-reset-on-submit.md | 8 +- .../reference/01-attributes/10-hx-on.md | 2 +- .../03-events/02-htmx-before-request.md | 4 +- .../03-events/03-htmx-after-request.md | 11 ++- .../03-events/04-htmx-finally-request.md | 25 ------ .../03-events/05-htmx-before-swap.md | 6 +- .../reference/03-events/06-htmx-after-swap.md | 8 +- .../03-events/35-htmx-before-response.md | 8 +- .../03-events/38-htmx-response-error.md | 9 +- .../03-events/41-htmx-after-response.md | 26 ++++++ .../reference/03-events/42-htmx-done.md | 26 ++++++ www/src/content/reference/index.mdx | 2 +- 31 files changed, 241 insertions(+), 109 deletions(-) delete mode 100644 www/src/content/reference/03-events/04-htmx-finally-request.md create mode 100644 www/src/content/reference/03-events/41-htmx-after-response.md create mode 100644 www/src/content/reference/03-events/42-htmx-done.md diff --git a/src/editors/jetbrains/htmx.web-types.json b/src/editors/jetbrains/htmx.web-types.json index 38df7d456..00a29f5f4 100644 --- a/src/editors/jetbrains/htmx.web-types.json +++ b/src/editors/jetbrains/htmx.web-types.json @@ -450,15 +450,20 @@ "description": "Fires immediately before `fetch()` is called. `detail.ctx` is the request context. Cancel to stop the request.", "doc-url": "https://four.htmx.org/reference/events/htmx-before-request" }, + { + "name": "after:request", + "description": "Fires immediately after `fetch()` resolves. `detail.ctx.response` is available, but the body is unconsumed.", + "doc-url": "https://four.htmx.org/reference/events/htmx-after-request" + }, { "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 before the response body is consumed. `detail.ctx.response.raw` is the unconsumed `Response`.", "doc-url": "https://four.htmx.org/reference/events/htmx-before-response" }, { - "name": "after:request", - "description": "Fires after the response body is consumed. `detail.ctx.swap.content` contains the response content.", - "doc-url": "https://four.htmx.org/reference/events/htmx-after-request" + "name": "after:response", + "description": "Fires after the response body is stored in `detail.ctx.swap.content`.", + "doc-url": "https://four.htmx.org/reference/events/htmx-after-response" }, { "name": "response:error", @@ -476,9 +481,9 @@ "doc-url": "https://four.htmx.org/reference/events/htmx-after-actions" }, { - "name": "finally:request", - "description": "Always fires at the end of the request lifecycle. `detail.ctx` is the request context.", - "doc-url": "https://four.htmx.org/reference/events/htmx-finally-request" + "name": "done", + "description": "Fires when the admitted request pipeline ends, whether it completes, fails, or is cancelled. `detail.ctx` is the request context.", + "doc-url": "https://four.htmx.org/reference/events/htmx-done" }, { "name": "error", diff --git a/src/ext/htmx-2-compat.js b/src/ext/htmx-2-compat.js index d2029ff54..305004bfb 100644 --- a/src/ext/htmx-2-compat.js +++ b/src/ext/htmx-2-compat.js @@ -42,7 +42,7 @@ maybeRetriggerEvent(elt, "htmx:afterProcessNode", detail); maybeRetriggerEvent(elt, "htmx:load", detail); }, - htmx_after_request: function (elt, detail) { + htmx_after_response: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:afterRequest", detail); }, htmx_after_swap: function (elt, detail) { diff --git a/src/ext/hx-alpine-compat.js b/src/ext/hx-alpine-compat.js index 6559bb3de..857223116 100644 --- a/src/ext/hx-alpine-compat.js +++ b/src/ext/hx-alpine-compat.js @@ -103,7 +103,7 @@ maybeFlush(); }, - htmx_finally_request: (elt, detail) => { + htmx_done: (elt, detail) => { if (!detail.ctx._alpineFlushed) maybeFlush(); } }); diff --git a/src/ext/hx-browser-indicator.js b/src/ext/hx-browser-indicator.js index 524a0d675..f6627c399 100644 --- a/src/ext/hx-browser-indicator.js +++ b/src/ext/hx-browser-indicator.js @@ -76,7 +76,7 @@ if (detail.ctx.request?.abort) activeAborts.add(detail.ctx.request.abort); }, - htmx_finally_request: (elt, detail) => { + htmx_done: (elt, detail) => { if (!detail.ctx._browserIndicator) return; if (detail.ctx.request?.abort) activeAborts.delete(detail.ctx.request.abort); if (activeCount === 0) return; diff --git a/src/ext/hx-csp.js b/src/ext/hx-csp.js index 91cd0e16a..7b3a9a8e1 100644 --- a/src/ext/hx-csp.js +++ b/src/ext/hx-csp.js @@ -171,7 +171,7 @@ // Rewrites response nonces to pageNonce in raw HTML before fragment parsing. // Always scrubs stolen pageNonce. Only promotes response nonce for verified same-origin. - htmx_after_request: (elt, detail) => { + htmx_after_response: (elt, detail) => { if (!pageNonce) return false; let ctx = detail.ctx; diff --git a/src/htmx.d.ts b/src/htmx.d.ts index f94d0a3d8..a5a95d20d 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -353,17 +353,11 @@ export interface HtmxEventMap { 'htmx:before:request': { ctx: HtmxRequestCtx }; /** - * Fires after the fetch resolves and the response is received, before swapping. - * `ctx.response` is available with status and headers. + * Fires immediately after `fetch()` resolves. + * `ctx.response` is available, but the body has not been consumed. */ 'htmx:after:request': { ctx: HtmxRequestCtx }; - /** - * Fires when request completes, fails, or is cancelled. - * Does not run if processing stops before the request begins issuing. - */ - 'htmx:finally:request': { ctx: HtmxRequestCtx }; - /** * Fires after the network response arrives but before htmx reads the response body. * `ctx.response.raw` is the unconsumed Fetch `Response`. @@ -371,6 +365,16 @@ export interface HtmxEventMap { */ 'htmx:before:response': { ctx: HtmxRequestCtx }; + /** + * Fires after the response body is stored in `ctx.swap.content`. + */ + 'htmx:after:response': { ctx: HtmxRequestCtx }; + + /** + * Fires when the admitted request pipeline ends, whether it completes, fails, or is cancelled. + */ + 'htmx:done': { ctx: HtmxRequestCtx }; + /** * Fires after response content is parsed but before it is inserted into the DOM. * Cancel to prevent the swap from occurring. diff --git a/src/htmx.js b/src/htmx.js index 3c2e0890a..33debf905 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -636,6 +636,8 @@ var htmx = (() => { status: response.status, headers: response.headers, } + this.__trigger(elt, "htmx:after:request", {ctx}); + // Swap directives update ctx.swap; the rest are actions. let {retarget, reswap, reselect, ...headerActions} = this.__extractResponseActions(ctx.response); ctx.actions = {...ctx.actions, ...headerActions}; @@ -650,9 +652,10 @@ var htmx = (() => { ...this.__parseSwapSpec(reswap) }; } + if (!this.__trigger(elt, "htmx:before:response", {ctx})) return; ctx.swap.content = await response.text(); - if (!this.__trigger(elt, "htmx:after:request", {ctx})) return; + this.__trigger(elt, "htmx:after:response", {ctx}); if (ctx.response.status >= 400) { this.__trigger(elt, "htmx:response:error", {ctx}) @@ -683,13 +686,13 @@ var htmx = (() => { this.__trigger(elt, "htmx:error", {ctx, error}) } finally { clearTimeout(ctx.requestTimeout); - this.__trigger(elt, "htmx:finally:request", {ctx}) if (!ctx.keepIndicators) { this.__hideIndicators(indicators); this.__enableElements(disableElements); } requestQueue.finish() + this.__trigger(elt, "htmx:done", {ctx}) // start callbacks are intentionally not awaited; __issueRequest has its own try/catch requestQueue.startNext() } diff --git a/src/scripts/upgrade-check.py b/src/scripts/upgrade-check.py index 33eae8612..f5a4e3f7b 100755 --- a/src/scripts/upgrade-check.py +++ b/src/scripts/upgrade-check.py @@ -51,7 +51,7 @@ EVENT_RENAMES = { "htmx:afterOnLoad": "htmx:after:init", "htmx:afterProcessNode": "htmx:after:init", - "htmx:afterRequest": "htmx:after:request", + "htmx:afterRequest": "htmx:after:response", "htmx:afterSettle": "htmx:after:swap", "htmx:afterSwap": "htmx:after:swap", "htmx:beforeCleanupElement": "htmx:before:cleanup", diff --git a/src/skills/htmx-debugging.md b/src/skills/htmx-debugging.md index 54d536dfb..976609f01 100644 --- a/src/skills/htmx-debugging.md +++ b/src/skills/htmx-debugging.md @@ -42,7 +42,8 @@ Paste this in the console to monitor the request/swap lifecycle: ```js ['htmx:config:request', 'htmx:before:request', 'htmx:after:request', - 'htmx:before:swap', 'htmx:after:swap', 'htmx:finally:swap', 'htmx:error', 'htmx:finally:request'] + 'htmx:before:response', 'htmx:after:response', 'htmx:response:error', + 'htmx:before:swap', 'htmx:after:swap', 'htmx:finally:swap', 'htmx:error', 'htmx:done'] .forEach(evt => document.body.addEventListener(evt, e => { console.log(evt, e.detail?.ctx?.request?.action, e.detail?.ctx?.response?.status, e.detail); })); diff --git a/src/skills/htmx-extension-authoring.md b/src/skills/htmx-extension-authoring.md index 13f5c98db..d372af359 100644 --- a/src/skills/htmx-extension-authoring.md +++ b/src/skills/htmx-extension-authoring.md @@ -34,9 +34,13 @@ Extensions are global -- they apply page-wide, activated by custom attributes wh }, htmx_after_request: (elt, detail) => { - // After request completes + // After fetch resolves + // detail.ctx.response has status and headers + }, + + htmx_after_response: (elt, detail) => { + // After the response body is read // detail.ctx.swap.content has response text - // detail.ctx.response has status, headers }, htmx_before_swap: (elt, detail) => { @@ -88,8 +92,9 @@ Hook names use underscores (not colons). All hooks receive `(elt, detail)` unles | `htmx_config_request` | `htmx:config:request` | Configure request (modify headers, body, URL) | | `htmx_before_request` | `htmx:before:request` | Before request is sent | | `htmx_before_response` | `htmx:before:response` | After fetch response, before body consumed | -| `htmx_after_request` | `htmx:after:request` | After request completes | -| `htmx_finally_request` | `htmx:finally:request` | When request completes, fails, or is cancelled | +| `htmx_after_request` | `htmx:after:request` | After fetch resolves, before the body is read | +| `htmx_after_response` | `htmx:after:response` | After the response body is read | +| `htmx_done` | `htmx:done` | When the admitted request pipeline ends | | `htmx_error` | `htmx:error` | On any error | ### Actions @@ -225,7 +230,7 @@ The context object available via `detail.ctx` in hook callbacks: **Modifying the request:** Change `detail.ctx.request` properties in `htmx_config_request` or `htmx_before_request`. -**Modifying the response:** Change `detail.ctx.swap.content` in `htmx_after_request` (before swap). +**Modifying the response:** Change `detail.ctx.swap.content` in `htmx_after_response` (before swap). **Handling custom actions:** Unknown `HX-*` response headers become entries in `detail.ctx.actions` (`HX-Toast` becomes `toast`). Consume them in `htmx_before_actions` through `detail.actions`. Run transport actions with `api.runActions(actions, element, detail)`. @@ -344,7 +349,7 @@ Key patterns: |-----------|---------|-------| | `htmx.defineExtension()` | `htmx.registerExtension()` | Different function name | | `onEvent(name, evt)` | Specific hooks (`htmx_before_request`, etc.) | Use underscored hook names | -| `transformResponse(text, xhr, elt)` | `htmx_after_request` | Modify `detail.ctx.swap.content` | +| `transformResponse(text, xhr, elt)` | `htmx_after_response` | Modify `detail.ctx.swap.content` | | `handleSwap(style, target, fragment)` | `handle_swap(style, target, fragment, swapSpec)` | Extra `swapSpec` param, return truthy | | `encodeParameters(xhr, params, elt)` | `htmx_before_request` | Modify the final `detail.ctx.request.body` and `.headers` | | `getSelectors()` | `htmx_after_init` | Check `api.attributeValue(elt, "attr")` instead | diff --git a/src/skills/htmx-guidance.md b/src/skills/htmx-guidance.md index 27533d29c..d8f2c5fc9 100644 --- a/src/skills/htmx-guidance.md +++ b/src/skills/htmx-guidance.md @@ -211,9 +211,10 @@ htmx 4 naming convention: `htmx:phase:action` - `htmx:config:request` -- configure request (modify headers, body, URL). Cancel with `evt.preventDefault()` - `htmx:before:request` -- just before fetch. Cancel with `evt.preventDefault()` -- `htmx:before:response` -- after fetch response received, before body consumed -- `htmx:after:request` -- after request completes -- `htmx:finally:request` -- when request completes, fails, or is cancelled +- `htmx:after:request` -- after fetch resolves, before the body is consumed +- `htmx:before:response` -- before the response body is consumed. Cancel to stop response handling +- `htmx:after:response` -- after the body is stored in `ctx.swap.content` +- `htmx:done` -- when the admitted request pipeline ends - `htmx:error` -- on any error (network, response, swap) **Actions:** diff --git a/src/skills/htmx-upgrade-from-htmx2.md b/src/skills/htmx-upgrade-from-htmx2.md index 9b2a8a185..a3c2e0312 100644 --- a/src/skills/htmx-upgrade-from-htmx2.md +++ b/src/skills/htmx-upgrade-from-htmx2.md @@ -127,7 +127,7 @@ htmx 2 uses camelCase event names. htmx 4 uses colon-separated names. |-----------------------------|-----------------------------------| | `htmx:configRequest` | `htmx:config:request` | | `htmx:beforeRequest` | `htmx:before:request` | -| `htmx:afterRequest` | `htmx:after:request` | +| `htmx:afterRequest` | `htmx:after:response` | | `htmx:beforeSwap` | `htmx:before:swap` | | `htmx:afterSwap` | `htmx:after:swap` | | `htmx:afterSettle` | `htmx:after:swap` | @@ -270,7 +270,7 @@ htmx.registerExtension('my-ext', { htmx_config_request(elt, detail) { // detail.ctx has request context }, - htmx_after_request(elt, detail) { + htmx_after_response(elt, detail) { // detail.ctx.swap.content has response text } }); diff --git a/test/lib/helpers.js b/test/lib/helpers.js index 48975fa05..a16e749a0 100644 --- a/test/lib/helpers.js +++ b/test/lib/helpers.js @@ -204,7 +204,7 @@ function waitForEvent(eventName, timeout = 200) { } function forRequest(timeout = 200) { - return waitForEvent("htmx:finally:request", timeout); + return waitForEvent("htmx:done", timeout); } function forRequestWithDelay(timeout = 200) { diff --git a/test/manual/hx-redirect-indicator.html b/test/manual/hx-redirect-indicator.html index 8569c175b..9df2c29e3 100644 --- a/test/manual/hx-redirect-indicator.html +++ b/test/manual/hx-redirect-indicator.html @@ -48,7 +48,7 @@

HX-Redirect indicator preservation

hx-post="/redirect" hx-disabled-elt="#btn2" hx-indicator="#spinner2" - hx-on:htmx:finally:request="event.detail.ctx.keepIndicators = false"> + hx-on:htmx:done="event.detail.ctx.keepIndicators = false"> Click me (indicators cleared on redirect) diff --git a/test/tests/ext/hx-csp.js b/test/tests/ext/hx-csp.js index 8ee6b3938..12a286deb 100644 --- a/test/tests/ext/hx-csp.js +++ b/test/tests/ext/hx-csp.js @@ -42,7 +42,7 @@ describe('hx-csp extension', function() { mockResponse('GET', '/test', '') let button = createProcessedHTML('
') let responseContent - button.addEventListener('htmx:after:request', event => { + button.addEventListener('htmx:after:response', event => { responseContent = event.detail.ctx.swap.content }) diff --git a/test/tests/unit/__issueRequest.js b/test/tests/unit/__issueRequest.js index be048699c..6f4fc88ec 100644 --- a/test/tests/unit/__issueRequest.js +++ b/test/tests/unit/__issueRequest.js @@ -8,6 +8,42 @@ describe('__issueRequest unit tests', function() { cleanupTest(); }); + it('orders the request, response, and swap lifecycle', async function () { + let div = createProcessedHTML('
') + let ctx = htmx.__createRequestContext(div, new Event('click')) + let events = [] + + for (let name of ['before:request', 'after:request', 'before:response', 'after:response', 'before:swap', 'after:swap', 'finally:swap', 'done']) { + div.addEventListener(`htmx:${name}`, () => events.push(name)) + } + ctx.fetch = async () => { + events.push('fetch') + return { + status: 200, + headers: new Headers(), + text: async () => { + events.push('read body') + return '' + } + } + } + + await htmx.__issueRequest(ctx) + + assert.deepEqual(events, [ + 'before:request', + 'fetch', + 'after:request', + 'before:response', + 'read body', + 'after:response', + 'before:swap', + 'after:swap', + 'finally:swap', + 'done' + ]) + }) + it('triggers htmx:before:request event', async function () { let div = createProcessedHTML('
') let ctx = htmx.__createRequestContext(div, new Event('click')) @@ -44,6 +80,44 @@ describe('__issueRequest unit tests', function() { assert.isTrue(afterRequestFired) }) + it('continues response processing when htmx:after:request is cancelled', async function () { + let div = createProcessedHTML('
') + let ctx = htmx.__createRequestContext(div, new Event('click')) + let bodyRead = false + + div.addEventListener('htmx:after:request', event => event.preventDefault()) + ctx.fetch = async () => ({ + status: 200, + headers: new Headers(), + text: async () => { + bodyRead = true + return '' + } + }) + + await htmx.__issueRequest(ctx) + + assert.isTrue(bodyRead) + }) + + it('continues swap processing when htmx:after:response is cancelled', async function () { + let div = createProcessedHTML('
') + let ctx = htmx.__createRequestContext(div, new Event('click')) + let beforeSwapFired = false + + div.addEventListener('htmx:after:response', event => event.preventDefault()) + div.addEventListener('htmx:before:swap', () => beforeSwapFired = true) + ctx.fetch = async () => ({ + status: 200, + headers: new Headers(), + text: async () => 'Response' + }) + + await htmx.__issueRequest(ctx) + + assert.isTrue(beforeSwapFired) + }) + it('calls custom fetch implementation', async function () { let div = createProcessedHTML('
') let ctx = htmx.__createRequestContext(div, new Event('click')) @@ -177,17 +251,17 @@ describe('__issueRequest unit tests', function() { assert.equal(capturedError, testError) }) - it('always triggers htmx:finally:request', async function () { + it('always triggers htmx:done', async function () { let div = createProcessedHTML('
') let ctx = htmx.__createRequestContext(div, new Event('click')) - let finallyFired = false - div.addEventListener('htmx:finally:request', () => finallyFired = true) + let doneFired = false + div.addEventListener('htmx:done', () => doneFired = true) ctx.fetch = async () => { throw new Error('fail') } await htmx.__issueRequest(ctx) - assert.isTrue(finallyFired) + assert.isTrue(doneFired) }) diff --git a/test/tests/unit/morph.js b/test/tests/unit/morph.js index 70af79e2f..f77bb1102 100644 --- a/test/tests/unit/morph.js +++ b/test/tests/unit/morph.js @@ -710,7 +710,7 @@ describe('Morph Swap Styles Tests', function() { await new Promise(r => setTimeout(r, 20)); assert.equal(fired, 0, 'click should no longer fire after morph'); b.dispatchEvent(new KeyboardEvent('keyup')); - await waitForEvent('htmx:after:request', 100); + await waitForEvent('htmx:done', 100); assert.equal(fired, 1, 'keyup should fire after morph'); }); diff --git a/test/tests/unit/process.js b/test/tests/unit/process.js index dc8f433d6..251fb3109 100644 --- a/test/tests/unit/process.js +++ b/test/tests/unit/process.js @@ -119,7 +119,7 @@ describe('process() unit tests', function() { btn.addEventListener('htmx:before:request', () => fired++) btn.click() - await waitForEvent('htmx:after:request', 100) + await waitForEvent('htmx:done', 100) assert.equal(fired, 1, 'baseline click fires') btn.setAttribute('hx-trigger', 'keyup') @@ -130,7 +130,7 @@ describe('process() unit tests', function() { assert.equal(fired, 1, 'click no longer fires after trigger change') btn.dispatchEvent(new KeyboardEvent('keyup')) - await waitForEvent('htmx:after:request', 100) + await waitForEvent('htmx:done', 100) assert.equal(fired, 2, 'keyup fires after force reprocess') }) diff --git a/www/src/content/docs.mdx b/www/src/content/docs.mdx index 9aacee32c..402c8dc64 100644 --- a/www/src/content/docs.mdx +++ b/www/src/content/docs.mdx @@ -280,7 +280,7 @@ All events follow a new pattern: `htmx:phase:action[:sub-action]`. Most error ev |-----------------------------|-----------------------------------------------------------------------------------|---------|-------------------------------------| | `htmx:afterOnLoad` | [`htmx:after:init`](/reference/events/htmx-after-init) | renamed | β€” | | `htmx:afterProcessNode` | [`htmx:after:init`](/reference/events/htmx-after-init) | renamed | β€” | -| `htmx:afterRequest` | [`htmx:after:request`](/reference/events/htmx-after-request) | renamed | β€” | +| `htmx:afterRequest` | [`htmx:after:response`](/reference/events/htmx-after-response) | renamed | β€” | | `htmx:afterSettle` | [`htmx:after:settle`](/reference/events/htmx-after-settle) | renamed | β€” | | `htmx:afterSwap` | [`htmx:after:swap`](/reference/events/htmx-after-swap) | renamed | β€” | | `htmx:beforeCleanupElement` | [`htmx:before:cleanup`](/reference/events/htmx-before-cleanup) | renamed | β€” | @@ -306,7 +306,7 @@ All events follow a new pattern: `htmx:phase:action[:sub-action]`. Most error ev | `htmx:validation:failed` | β€” | removed | Use native browser form validation. | | `htmx:validation:halted` | β€” | removed | Use native browser form validation. | | `htmx:xhr:loadstart` | β€” | removed | htmx uses `fetch()` now. | -| `htmx:xhr:loadend` | [`htmx:finally:request`](/reference/events/htmx-finally-request) | removed | htmx uses `fetch()` now. | +| `htmx:xhr:loadend` | [`htmx:done`](/reference/events/htmx-done) | removed | htmx uses `fetch()` now. | | `htmx:xhr:progress` | β€” | removed | htmx uses `fetch()` now. | | `htmx:xhr:abort` | [`htmx:error`](/reference/events/htmx-error) | removed | htmx uses `fetch()` now. | @@ -530,12 +530,14 @@ All events provide a consistent `ctx` object with request/response information. | [`htmx:after:cleanup`](/reference/events/htmx-after-cleanup) | After element cleanup | | [`htmx:after:history:update`](/reference/events/htmx-after-history-update) | After history update | | [`htmx:after:process`](/reference/events/htmx-after-process) | After element processing | +| [`htmx:after:request`](/reference/events/htmx-after-request) | After fetch resolves | | [`htmx:before:response`](/reference/events/htmx-before-response) | Before response body is read (cancellable) | +| [`htmx:after:response`](/reference/events/htmx-after-response) | After response body is read | | [`htmx:before:settle`](/reference/events/htmx-before-settle) | Before settle phase | | [`htmx:after:settle`](/reference/events/htmx-after-settle) | After settle phase | | [`htmx:before:viewTransition`](/reference/events/htmx-before-viewTransition) | Before a view transition starts (cancellable) | | [`htmx:after:viewTransition`](/reference/events/htmx-after-viewTransition) | After a view transition completes | -| [`htmx:finally:request`](/reference/events/htmx-finally-request) | When request completes, fails, or is cancelled | +| [`htmx:done`](/reference/events/htmx-done) | When the admitted request pipeline ends | #### Config keys @@ -650,7 +652,7 @@ Instead of a single `onEvent` callback that switches on event names, each event |---|---| | `htmx:configRequest` | `htmx_config_request` | | `htmx:beforeRequest` | `htmx_before_request` | -| `htmx:afterRequest` | `htmx_after_request` | +| `htmx:afterRequest` | `htmx_after_response` | | `htmx:beforeSwap` | `htmx_before_swap` | | `htmx:afterSwap` | `htmx_after_swap` | @@ -677,7 +679,7 @@ All hooks receive `detail.ctx` with full request/response context: - `detail.ctx.request.body` (FormData in `htmx_config_request`) - `detail.ctx.request.headers` (plain mutable object) - `detail.ctx.response.status` -- `detail.ctx.swap.content` (response body, modifiable in `htmx_after_request`) +- `detail.ctx.swap.content` (response body, modifiable in `htmx_after_response`) - `detail.ctx.swap.target` - `detail.ctx.swap.style` @@ -755,7 +757,7 @@ htmx_before_swap: (elt, detail) => { ##### `transformResponse` -Removed. Modify `detail.ctx.swap.content` in `htmx_after_request`: +Removed. Modify `detail.ctx.swap.content` in `htmx_after_response`: ```javascript // htmx 2.x @@ -770,7 +772,7 @@ transformResponse: function(text, xhr, elt) { } // htmx 4 -htmx_after_request: (elt, detail) => { +htmx_after_response: (elt, detail) => { var tpl = elt.closest('[mustache-template]'); if (tpl) { var data = JSON.parse(detail.ctx.swap.content); @@ -780,7 +782,7 @@ htmx_after_request: (elt, detail) => { } ``` -Event flow: response received, `ctx.swap.content` set, `htmx:after:request` fires, content consumed into a fragment, `htmx:before:swap`. +Event flow: fetch resolves, `htmx:after:request` fires, the body is stored in `ctx.swap.content`, `htmx:after:response` fires, then `htmx:before:swap`. ##### `encodeParameters` @@ -858,7 +860,7 @@ Return truthy if handled, falsy otherwise. Can return an array of elements for s |-----------------------------------------|---------------------------------------------------------------| | `getSelectors()` | `htmx_after_init` hook | | `onEvent(name, evt)` | Individual `htmx_*` hooks | -| `transformResponse(text, xhr, elt)` | `htmx_after_request` hook (modify `detail.ctx.swap.content`) | +| `transformResponse(text, xhr, elt)` | `htmx_after_response` hook (modify `detail.ctx.swap.content`) | | `encodeParameters(xhr, params, elt)` | `htmx_before_request` hook (modify final `detail.ctx.request.body`) | | `isInlineSwap(swapStyle)` | `handle_swap` or name swap style with "outer" prefix | | `handleSwap(style, target, frag, info)` | `handle_swap(style, target, frag, spec)` | @@ -867,7 +869,7 @@ Return truthy if handled, falsy otherwise. Can return an array of elements for s 1. Rename `defineExtension` to `registerExtension` 2. Replace `onEvent` with individual `htmx_*` hooks -3. Replace `transformResponse` with `htmx_after_request` +3. Replace `transformResponse` with `htmx_after_response` 4. Replace `encodeParameters` with `htmx_before_request` 5. Merge `isInlineSwap` and `handleSwap` into `handle_swap` 6. Replace `getSelectors` with `htmx_after_init` @@ -1673,7 +1675,7 @@ event: ```html ``` @@ -2786,8 +2788,8 @@ htmx.registerExtension("my-ext", { // Return false to cancel }, - htmx_after_request: (elt, detail) => { - // Called after each request + htmx_after_response: (elt, detail) => { + // Called after each response body is read }, }); ``` @@ -2814,8 +2816,10 @@ Extensions hook into htmx lifecycle events. Event names use underscores instead | `htmx_config_request` | [`htmx:config:request`](/reference/events/htmx-config-request) | `(elt, detail)` | Configure request before sending | | `htmx_before_request` | [`htmx:before:request`](/reference/events/htmx-before-request) | `(elt, detail)` | Before request is sent | | `htmx_before_response` | `htmx:before:response` | `(elt, detail)` | After fetch, before body consumed | -| `htmx_after_request` | [`htmx:after:request`](/reference/events/htmx-after-request) | `(elt, detail)` | After request completes | -| `htmx_finally_request` | [`htmx:finally:request`](/reference/events/htmx-finally-request) | `(elt, detail)` | When request completes, fails, or is cancelled | +| `htmx_after_request` | [`htmx:after:request`](/reference/events/htmx-after-request) | `(elt, detail)` | After fetch resolves | +| `htmx_before_response` | [`htmx:before:response`](/reference/events/htmx-before-response) | `(elt, detail)` | Before the body is read | +| `htmx_after_response` | [`htmx:after:response`](/reference/events/htmx-after-response) | `(elt, detail)` | After the body is read | +| `htmx_done` | [`htmx:done`](/reference/events/htmx-done) | `(elt, detail)` | When the admitted request pipeline ends | | `htmx_error` | [`htmx:error`](/reference/events/htmx-error) | `(elt, detail)` | On request error | ##### Action Events diff --git a/www/src/content/patterns/05-reset-on-submit.md b/www/src/content/patterns/05-reset-on-submit.md index 318a4101b..9187ba896 100644 --- a/www/src/content/patterns/05-reset-on-submit.md +++ b/www/src/content/patterns/05-reset-on-submit.md @@ -17,7 +17,7 @@ server.get("/demo", () => {
@@ -59,7 +59,7 @@ On the client, wrap your inputs in a `` and use [`hx-on`](/reference/attri + hx-on:htmx:done="this.reset()"> @@ -69,7 +69,7 @@ On the client, wrap your inputs in a `
` and use [`hx-on`](/reference/attri - [`hx-post`](/reference/attributes/hx-post) submits the form to `/chat`. - [`hx-target`](/reference/attributes/hx-target) points at the `#messages` container. - [`hx-swap`](/reference/attributes/hx-swap)=[`"beforeend"`](/reference/attributes/hx-swap#beforeend) appends each new message to the bottom. -- [`hx-on:htmx:after:request`](/reference/attributes/hx-on) listens for the [`htmx:after:request`](/reference/events/htmx-after-request) event and calls `this.reset()` to clear the form. +- [`hx-on:htmx:done`](/reference/attributes/hx-on) listens for the [`htmx:done`](/reference/events/htmx-done) event and calls `this.reset()` to clear the form. On the server, respond with the new message: @@ -90,7 +90,7 @@ The `reset()` method is only available on `` elements. For standalone inpu hx-target="#messages" hx-swap="beforeend" hx-include="#chat-input" - hx-on:htmx:after:request="document.getElementById('chat-input').value = ''"> + hx-on:htmx:done="document.getElementById('chat-input').value = ''"> Send ``` diff --git a/www/src/content/reference/01-attributes/10-hx-on.md b/www/src/content/reference/01-attributes/10-hx-on.md index 7ab033f09..bfa5b22da 100644 --- a/www/src/content/reference/01-attributes/10-hx-on.md +++ b/www/src/content/reference/01-attributes/10-hx-on.md @@ -24,7 +24,7 @@ For htmx events, use `::` as shorthand for `htmx:`: ```html '); + + button.click(); + await forRequest(); + + assert.equal( + lastFetch().request.headers.Accept, + 'text/html, text/event-stream, multipart/mixed, multipart/parallel' + ); + }); + + async function waitUntil(condition, timeout = 200) { + let start = Date.now(); + while (Date.now() - start < timeout) { + if (condition()) return true; + await htmx.timeout(5); + } + return false; + } + + it('applies request, envelope, and part swap settings in order', async function() { + let content = [ + 'request', + 'envelope', + 'part', + 're' + ].join(''); + let response = (partHeaders = [], envelopeHeaders = {}) => new Response([ + '--updates\r\n', + 'Content-Type: text/html\r\n', + ...partHeaders.map(header => `${header}\r\n`), + '\r\n', + content, + '\r\n--updates--\r\n' + ].join(''), { + headers: { + 'Content-Type': 'multipart/mixed; boundary=updates', + ...envelopeHeaders + } + }); + + fetchMock.mockResponse('GET', '/request-defaults', response()); + fetchMock.mockResponse('GET', '/envelope-defaults', response([], { + 'HX-Target': '#envelope-target', + 'HX-Swap': 'beforeend', + 'HX-Select': '.envelope-choice' + })); + fetchMock.mockResponse('GET', '/envelope-re', response([], { + 'HX-Target': '#request-target', + 'HX-Swap': 'innerHTML', + 'HX-Select': '.envelope-choice', + 'HX-Retarget': '#envelope-re-target', + 'HX-Reswap': 'beforeend', + 'HX-Reselect': '.re-choice' + })); + fetchMock.mockResponse('GET', '/part-direct', response([ + 'HX-Target: #part-target', + 'HX-Swap: innerHTML', + 'HX-Select: .part-choice' + ], { + 'HX-Retarget': '#envelope-target', + 'HX-Reswap': 'beforeend', + 'HX-Reselect': '.envelope-choice' + })); + fetchMock.mockResponse('GET', '/part-re', response([ + 'HX-Target: #part-target', + 'HX-Swap: innerHTML', + 'HX-Select: .part-choice', + 'HX-Retarget: #re-target', + 'HX-Reswap: beforeend', + 'HX-Reselect: .re-choice' + ])); + + createProcessedHTML([ + '', + '', + '', + '', + '', + '
existing
', + '
existing
', + '
existing
', + '
existing
', + '
existing
' + ].join('')); + + find('#request').click(); + await forRequest(); + assertTextContentIs('#request-target', 'request'); + + find('#envelope').click(); + await forRequest(); + assertTextContentIs('#envelope-target', 'existingenvelope'); + + find('#envelope-re').click(); + await forRequest(); + assertTextContentIs('#envelope-re-target', 'existingre'); + + find('#part').click(); + await forRequest(); + assertTextContentIs('#part-target', 'part'); + + find('#re').click(); + await forRequest(); + assertTextContentIs('#re-target', 'existingre'); + }); + + it('reconnects hx-multipart:connect after clean EOF and stops on removal', async function() { + let requestCount = 0; + let closeReason; + document.addEventListener( + 'htmx:multipart:close', + event => closeReason = event.detail.reason, + {once: true} + ); + + fetchMock.mockResponse('GET', '/connect', () => { + requestCount++; + return new Response([ + '--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Target: #connection-target\r\n', + 'HX-Swap: innerHTML\r\n', + '\r\n', + `${requestCount}`, + '\r\n--updates--\r\n' + ].join(''), { + headers: {'Content-Type': 'multipart/mixed; boundary=updates'} + }); + }); + + createProcessedHTML([ + '
', + '
' + ].join('')); + + assert.isTrue(await waitUntil(() => requestCount >= 2, 500)); + await htmx.swap('', '#source', {style: 'delete'}); + + let stoppedAt = requestCount; + await htmx.timeout(20); + + assert.equal(requestCount, stoppedAt); + assert.equal(closeReason, 'removed'); + }); + + it('reconnects hx-multipart:connect after a broken stream', async function() { + let requestCount = 0; + let encoder = new TextEncoder(); + fetchMock.mockResponse('GET', '/broken', () => { + requestCount++; + let content = [ + '--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Target: #broken-target\r\n', + '\r\n', + `${requestCount}` + ].join(''); + let body = requestCount === 1 + ? content + : new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`${content}\r\n--updates\r\nContent-Type: text/html\r\n\r\n`)); + } + }); + return new Response(body, { + headers: {'Content-Type': 'multipart/mixed; boundary=updates'} + }); + }); + + createProcessedHTML([ + '
', + '
' + ].join('')); + + assert.isTrue(await waitUntil(() => requestCount >= 2, 500)); + assert.isTrue(await waitUntil(() => htmx.find('#broken-target').textContent === '2', 500)); + await htmx.swap('', '#broken-source', {style: 'delete'}); + }); + + it('keeps hx-multipart:connect alive after HX-Location', async function() { + let requestCount = 0; + let locationCount = 0; + fetchMock.mockResponse('GET', '/location-stream', () => { + requestCount++; + let body = requestCount === 1 + ? [ + '--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Location: path:"/destination" target:"#location-target" push:false\r\n', + '\r\n', + 'ignored', + '\r\n--updates--\r\n' + ].join('') + : new ReadableStream(); + return new Response(body, { + headers: {'Content-Type': 'multipart/mixed; boundary=updates'} + }); + }); + fetchMock.mockResponse('GET', '/destination', () => { + locationCount++; + return new Response('done'); + }); + + createProcessedHTML([ + '
', + '
' + ].join('')); + + assert.isTrue(await waitUntil(() => locationCount >= 1, 500), `HX-Location count: ${locationCount}`); + assert.isTrue(await waitUntil(() => requestCount >= 2, 500), `stream request count: ${requestCount}`); + await htmx.swap('', '#location-source', {style: 'delete'}); + + assertTextContentIs('#location-target', 'done'); + }); + + it('closes hx-multipart:connect when a part triggers hx-multipart:close', async function() { + let requestCount = 0; + fetchMock.mockResponse('GET', '/close-stream', () => { + requestCount++; + return new Response([ + '--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Trigger: done\r\n', + '\r\n', + 'final update', + '\r\n--updates--\r\n' + ].join(''), { + headers: {'Content-Type': 'multipart/mixed; boundary=updates'} + }); + }); + + let source = createProcessedHTML([ + '
', + '
' + ].join('')); + let doneFired = false; + let closeReason; + source.addEventListener('done', () => doneFired = true); + source.addEventListener('htmx:multipart:close', event => closeReason = event.detail.reason); + + source.click(); + assert.isTrue(await waitUntil(() => closeReason != null, 500)); + await htmx.timeout(20); + + assert.isTrue(doneFired); + assert.equal(closeReason, 'part'); + assert.equal(requestCount, 1); + assertTextContentIs('#close-target', 'final update'); + }); + + it('runs envelope and part actions', async function() { + let button = createProcessedHTML('
one
two
'); + let partTriggered = false; + let envelopeTriggered = false; + let actionDetail; + let afterRequestCount = 0; + button.addEventListener('partEvent', () => partTriggered = true); + button.addEventListener('envelopeEvent', () => envelopeTriggered = true); + button.addEventListener('htmx:before:actions', event => { + if (event.detail.part) actionDetail = event.detail; + }); + button.addEventListener('htmx:after:request', () => afterRequestCount++); + + let body = [ + '--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Retarget: #one\r\n', + '\r\n', + 'First', + '\r\n--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Retarget: #two\r\n', + 'HX-Trigger: partEvent\r\n', + '\r\n', + 'Second', + '\r\n--updates--\r\n' + ].join(''); + + fetchMock.mockResponse('GET', '/stream', new Response(body, { + headers: { + 'Content-Type': 'multipart/mixed; boundary=updates', + 'HX-Trigger': 'envelopeEvent' + } + })); + + button.click(); + await forRequest(); + + assertTextContentIs('#one', 'First'); + assertTextContentIs('#two', 'Second'); + assert.isTrue(partTriggered); + assert.isTrue(envelopeTriggered); + assert.equal(actionDetail.ctx.sourceElement, button); + assert.equal(actionDetail.part.headers.get('HX-Trigger'), 'partEvent'); + assert.equal(afterRequestCount, 1); + assert.equal(button.textContent, 'Go'); + }); + + it('lets listeners take over a part before HTML handling', async function() { + let button = createProcessedHTML('
'); + let json; + let handledParts = 0; + + button.addEventListener('htmx:multipart:before:part', event => { + if (event.detail.part.headers.get('Content-Type') !== 'application/json') return; + + event.preventDefault(); + event.detail.waitUntil(event.detail.part.json().then(value => json = value)); + }); + button.addEventListener('htmx:multipart:after:part', () => handledParts++); + + fetchMock.mockResponse('GET', '/mixed-data', new Response([ + '--updates\r\n', + 'Content-Type: application/json\r\n', + '\r\n', + '{"unread":3}', + '\r\n--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Target: #result\r\n', + '\r\n', + '

Done

', + '\r\n--updates--\r\n' + ].join(''), { + headers: {'Content-Type': 'multipart/mixed; boundary=updates'} + })); + + button.click(); + await forRequest(); + + assert.deepEqual(json, {unread: 3}); + assertTextContentIs('#result', 'Done'); + assert.equal(button.textContent, 'Go'); + assert.equal(handledParts, 1); + }); + + it('lets another extension take over a part', async function() { + let received; + let approvedExt = htmx.__approvedExt; + htmx.__approvedExt = `${approvedExt},multipart-consumer-test`; + htmx.registerExtension('multipart-consumer-test', { + htmx_multipart_before_part(element, detail) { + if (detail.part.headers.get('Content-Type') !== 'application/x.test') return; + + detail.waitUntil(new Response(detail.part.body).text().then(value => received = value)); + return false; + } + }); + htmx.__approvedExt = approvedExt; + + let button = createProcessedHTML('
'); + fetchMock.mockResponse('GET', '/extension-data', new Response([ + '--updates\r\n', + 'Content-Type: application/x.test\r\n', + '\r\n', + 'custom data', + '\r\n--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Target: #result\r\n', + '\r\n', + 'Done', + '\r\n--updates--\r\n' + ].join(''), { + headers: {'Content-Type': 'multipart/mixed; boundary=updates'} + })); + + button.click(); + await forRequest(); + + assert.equal(received, 'custom data'); + assertTextContentIs('#result', 'Done'); + assert.equal(button.textContent, 'Go'); + }); + + it('swaps multipart/mixed parts before the response stream closes', async function() { + let button = createProcessedHTML('
one
two
'); + let controller; + let encoder = new TextEncoder(); + let stream = new ReadableStream({ + start(c) { controller = c; } + }); + + fetchMock.mockResponse('GET', '/stream', new Response(stream, { + headers: {'Content-Type': 'multipart/mixed; boundary=updates'} + })); + + button.click(); + await htmx.timeout(0); + + controller.enqueue(encoder.encode([ + '--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Retarget: #one\r\n', + '\r\n', + 'First', + '\r\n--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Retarget: #two\r\n', + '\r\n' + ].join(''))); + + assert.isTrue(await waitUntil(() => htmx.find('#one').textContent === 'First', 500)); + assertTextContentIs('#two', 'two'); + + let requestFinished = false; + let done = forRequest(500).then(() => requestFinished = true); + await htmx.timeout(20); + assert.isFalse(requestFinished); + + controller.enqueue(encoder.encode('Second\r\n--updates--\r\n')); + controller.close(); + await done; + + assertTextContentIs('#one', 'First'); + assertTextContentIs('#two', 'Second'); + }); + + it('reads delayed multipart/parallel bodies before handling them', async function() { + let button = createProcessedHTML('
'); + let encoder = new TextEncoder(); + let stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode([ + '--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Target: #one\r\n', + '\r\n', + 'Fir' + ].join(''))); + setTimeout(() => { + controller.enqueue(encoder.encode([ + 'st', + '\r\n--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Target: #two\r\n', + '\r\n', + 'Second', + '\r\n--updates--\r\n' + ].join(''))); + controller.close(); + }, 20); + } + }); + + fetchMock.mockResponse('GET', '/parallel', new Response(stream, { + headers: {'Content-Type': 'multipart/parallel; boundary=updates'} + })); + + button.click(); + let done = await forRequest(500); + + assert.isNotNull(done, 'parallel request did not finish'); + assertTextContentIs('#one', 'First'); + assertTextContentIs('#two', 'Second'); + }); + + it('waits for mixed swaps and overlaps parallel swaps', async function() { + let response = (type, prefix) => new Response([ + '--updates\r\n', + 'Content-Type: text/html\r\n', + `HX-Target: #${prefix}-one\r\n`, + 'HX-Swap: innerHTML swap:100ms\r\n', + '\r\n', + 'First', + '\r\n--updates\r\n', + 'Content-Type: text/html\r\n', + `HX-Target: #${prefix}-two\r\n`, + '\r\n', + 'Second', + '\r\n--updates--\r\n' + ].join(''), { + headers: {'Content-Type': `multipart/${type}; boundary=updates`} + }); + + fetchMock.mockResponse('GET', '/mixed', response('mixed', 'mixed')); + fetchMock.mockResponse('GET', '/parallel', response('parallel', 'parallel')); + createProcessedHTML([ + '', + '
one
two
', + '', + '
one
two
' + ].join('')); + + let mixedDone = forRequest(500); + find('#mixed').click(); + await htmx.timeout(20); + assertTextContentIs('#mixed-one', 'one'); + assertTextContentIs('#mixed-two', 'two'); + assert.isNotNull(await mixedDone, 'mixed request did not finish'); + assertTextContentIs('#mixed-one', 'First'); + assertTextContentIs('#mixed-two', 'Second'); + + let parallelDone = forRequest(500); + find('#parallel').click(); + assert.isTrue(await waitUntil(() => htmx.find('#parallel-two').textContent === 'Second', 500)); + assertTextContentIs('#parallel-one', 'one'); + assert.isNotNull(await parallelDone, 'parallel request did not finish'); + assertTextContentIs('#parallel-one', 'First'); + }); + + it('handles the next parallel part while an earlier part settles', async function() { + let button = createProcessedHTML([ + '', + '
one
', + '
two
' + ].join('')); + fetchMock.mockResponse('GET', '/parallel', new Response([ + '--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Target: #one\r\n', + 'HX-Swap: innerHTML settle:100ms\r\n', + '\r\n', + 'First', + '\r\n--updates\r\n', + 'Content-Type: text/html\r\n', + 'HX-Target: #two\r\n', + '\r\n', + 'Second', + '\r\n--updates--\r\n' + ].join(''), { + headers: {'Content-Type': 'multipart/parallel; boundary=updates'} + })); + + let done = forRequest(500); + button.click(); + + assert.isTrue(await waitUntil(() => htmx.find('#two').textContent === 'Second', 500)); + assertTextContentIs('#state', 'First'); + assert.equal(find('#state').getAttribute('data-phase'), 'old'); + assert.isNotNull(await done, 'parallel request did not finish'); + assert.equal(find('#state').getAttribute('data-phase'), 'new'); + }); +}); diff --git a/www/astro.config.mjs b/www/astro.config.mjs index aaa3b526a..025b292eb 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -6,7 +6,7 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings"; import rehypeExternalLinks from "rehype-external-links"; import {rehypeSections} from "./src/lib/rehype-sections.js"; import {remarkCdnVersion} from "./src/lib/remark-cdn-version.js"; -import {codeBlockTransformer} from "./src/lib/shiki-transformers.js"; +import {codeBlockTransformer, multipartHttpTransformer} from "./src/lib/shiki-transformers.js"; import {readdirSync, readFileSync} from "node:fs"; // Single source of truth for the version shown in CDN/npm snippets. @@ -108,7 +108,7 @@ export default defineConfig({ ], shikiConfig: { theme: "css-variables", - transformers: [codeBlockTransformer] + transformers: [multipartHttpTransformer, codeBlockTransformer] }, }, diff --git a/www/src/content/extensions/01-hx-multipart.md b/www/src/content/extensions/01-hx-multipart.md new file mode 100644 index 000000000..00e014e0f --- /dev/null +++ b/www/src/content/extensions/01-hx-multipart.md @@ -0,0 +1,787 @@ +--- +title: "hx-multipart" +description: "Stream HTML with `multipart/mixed`" +category: "Networking" +icon: "icon-[mdi--call-split]" +keywords: ["multipart", "streaming", "mixed", "parallel", "Response.parts"] +--- + +The `hx-multipart` extension lets one [`multipart/mixed`](https://www.rfc-editor.org/rfc/rfc2046#section-5.1.3) HTTP response stream multiple parts. + +## Installing + +```html + + +``` + +## Usage + +### Update an Element + +Start with a typical htmx request using [`hx-get`](/reference/attributes/hx-get): + +```html + +``` + +Instead of `text/html`, respond with [`multipart/mixed`](#content-type): + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=... + +--... +Content-Type: text/html + +Pong +--...-- +``` + +
+Backend libraries + +- **Python:** [`scriptogre/multipart-response`](https://github.com/scriptogre/multipart-response) for [FastAPI](https://fastapi.tiangolo.com/) and [Starlette](https://www.starlette.io/) + +Example: + +```python +from fastapi import FastAPI +from multipart_response.fastapi import MultipartResponse, Part + +app = FastAPI() + +@app.get("/ping", response_class=MultipartResponse) +async def ping(): + yield Part("Pong", media_type="text/html") +``` + +More backends are planned. + +
+ +The part replaces the button's content: + +```html + +``` + +htmx uses the same rules as with a `text/html` response: + +- [`hx-target="this"`](/reference/attributes/hx-target#this) +- [`hx-swap="innerHTML"`](/reference/attributes/hx-swap#innerhtml) (from [`htmx.config.defaultSwap`](/reference/config/htmx-config-defaultSwap)) + +**Stream an Update** + +The server can stream HTML using multiple parts: + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=... + +--... +Content-Type: text/html + +P +--... +Content-Type: text/html + +Po +--... +Content-Type: text/html + +Pon +--... +Content-Type: text/html + +Pong +--...-- +``` + +The button changes as each part arrives: + +`Ping` β†’ `P` β†’ `Po` β†’ `Pon` β†’ `Pong` + +**Choose the Swap** + +Use [`hx-swap`](/reference/attributes/hx-swap) and [`hx-target`](/reference/attributes/hx-target) to choose how and where updates swap: + +```html + + + + +``` + +The server streams three parts: + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=... + +--... +Content-Type: text/html + +Hello +--... +Content-Type: text/html + +, world +--... +Content-Type: text/html + +! +--...-- +``` + +[`hx-swap="beforeend"`](/reference/attributes/hx-swap#beforeend) accumulates them in ``: + +```html +Hello, world! +``` + +Every part inherits the request's [`hx-target`](/reference/attributes/hx-target), [`hx-swap`](/reference/attributes/hx-swap), and [`hx-select`](/reference/attributes/hx-select). + +### Update Elements + +Use a normal htmx request to update several elements: + +```html + + +
+
Offline
+``` + +Part headers choose where each body swaps: + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=... + +--... +Content-Type: text/html +HX-Target: #status + +Online +--... +Content-Type: text/html +HX-Target: #feed + +

New

+--...-- +``` + +The page becomes: + +```html + + +
+

New

+
+
Online
+``` + +Like [`hx-swap-oob`](/reference/attributes/hx-swap-oob) and [``](/reference/tags/hx-partial), part headers let one response update multiple elements. + +### Persistent Connections + +Persistent connections reconnect after a response ends. + +#### Open Connections + +Use [`hx-multipart:connect`](#hx-multipartconnect) for a persistent GET connection: + +```html +
+
Offline
+``` + +The server sends a part: + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=... + +--... +Content-Type: text/html +HX-Target: #status + +Online +--...-- +``` + +The page becomes: + +```html +
+
Online
+``` + +The server may hold the response open and send more parts. If it ends, `hx-multipart:connect` reconnects. + +Connections open on `load`. Use [`hx-trigger`](/reference/attributes/hx-trigger) to connect later: + +```html + + +
+
+``` + +All [`hx-trigger` modifiers](/reference/attributes/hx-trigger#event-modifiers) are supported. + +#### Close Connections + +Close a connection when a part fires a named event: + +```html +
+ +
Working
+``` + +The server sends [`HX-Trigger`](/reference/headers/HX-Trigger) with the final part: + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=... + +--... +Content-Type: text/html +HX-Trigger: done + +Complete +--...-- +``` + +The part still swaps, then the connection stops: + +```html +
Complete
+``` + +#### Configure Connections + +You can configure `hx-multipart` in three places: + +- **[``](/reference/config/htmx-config#configure-via-meta-tag)** sets global defaults from HTML. + + ```html + + ``` + +- **[`htmx.config.multipart`](#config)** sets global defaults from JavaScript. + + ```js + htmx.config.multipart.reconnectDelay = '1s' + htmx.config.multipart.reconnectMaxAttempts = 5 + ``` + +- **[`hx-config`](/reference/attributes/hx-config)** overrides the defaults for one connection. + + ```html +
+
+ ``` + +These values are read when multipart handling begins. + +### Overlap Swaps + +Use `multipart/parallel` so a [`swap`](/reference/attributes/hx-swap#swap) or [`settle`](/reference/attributes/hx-swap#settle) delay does not block later parts: + +```http +Content-Type: multipart/parallel; boundary=... + +--... +HX-Target: #one +HX-Swap: innerHTML swap:1s + +First +--... +HX-Target: #two + +Second +--...-- +``` + +**With `multipart/parallel`:** + +- `Second` swaps now. +- One second later, `First` swaps. + +**With `multipart/mixed`:** + +- Nothing swaps for one second. +- Then `First` and `Second` swap in order. + +Use `multipart/parallel` only when either swap can finish first. + +### Mix Content Types + +With `multipart/mixed`, each part can use any [media type](https://www.iana.org/assignments/media-types/media-types.xhtml) in its `Content-Type`, including: + +- JSON: `application/json` +- Audio: `audio/mpeg` +- Video: `video/mp4` +- Generic binary: `application/octet-stream` +- Custom: `application/vnd.example.binary` + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=... + +--... +Content-Type: text/html + +

Done

+--... +Content-Type: application/json + +{"sentiment":"positive","confidence":0.94} +--... +Content-Type: audio/mpeg + +