diff --git a/src/editors/jetbrains/htmx.web-types.json b/src/editors/jetbrains/htmx.web-types.json index 64af559bd..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` contains request, response, and text data.", - "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", @@ -466,9 +471,19 @@ "doc-url": "https://four.htmx.org/reference/events/htmx-response-error" }, { - "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": "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": "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", @@ -566,18 +581,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 +601,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/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 21d57da36..7b3a9a8e1 100644 --- a/src/ext/hx-csp.js +++ b/src/ext/hx-csp.js @@ -171,13 +171,13 @@ // 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; // Always scrub stolen pageNonce from any response — the server cannot know the // page nonce, so its presence indicates a stolen-nonce injection attempt. - ctx.text = rewriteNoncesInText(ctx.text, pageNonce, ''); + ctx.swap.content = rewriteNoncesInText(ctx.swap.content, pageNonce, ''); // Only promote response nonce for verified same-origin responses let responseURL = ctx?.response?.raw?.url; @@ -186,9 +186,9 @@ catch (_) { return; } let responseNonce = extractNonceFromCSP(ctx?.response?.headers?.get('Content-Security-Policy')) - ?? extractNonceFromMetaTag(ctx?.text); + ?? extractNonceFromMetaTag(ctx.swap.content); if (responseNonce && responseNonce !== pageNonce) { - ctx.text = rewriteNoncesInText(ctx.text, responseNonce); + ctx.swap.content = rewriteNoncesInText(ctx.swap.content, responseNonce); } }, diff --git a/src/ext/hx-download.js b/src/ext/hx-download.js index 2e28aa740..9a4b16f95 100644 --- a/src/ext/hx-download.js +++ b/src/ext/hx-download.js @@ -34,7 +34,7 @@ return; } let cd = ctx.response.headers.get('Content-Disposition'); - if (ctx.swap !== 'download' && !cd?.includes('attachment')) return; + if (ctx.swap.style !== 'download' && !cd?.includes('attachment')) return; streamDownload(ctx.sourceElement, ctx.response.raw, ctx.request.action); return false; } diff --git a/src/ext/hx-head.js b/src/ext/hx-head.js index 4409de525..baf403996 100644 --- a/src/ext/hx-head.js +++ b/src/ext/hx-head.js @@ -6,6 +6,7 @@ (function () { let api + let deferredHeadScripts = new WeakMap() // Appends a new head node, returning a promise for render-critical resources // (blocking scripts, stylesheets) or null for fire-and-forget resources. @@ -157,31 +158,34 @@ }, htmx_before_response: (elt, detail) => { let ctx = detail.ctx - let target = ctx.target + let target = ctx.swap.target // TODO - is there a better way to handle this? it used to be based on if the element was boosted let defaultMergeStrategy = target === document.body ? "merge" : "append"; if (htmx.trigger(document.body, "htmx:before:head:merge", detail)) { let realText = ctx.response.raw.text.bind(ctx.response.raw) ctx.response.raw.text = async () => { let text = await realText() - ctx._deferredHeadScripts = await mergeHead(text, defaultMergeStrategy) + deferredHeadScripts.set(ctx, await mergeHead(text, defaultMergeStrategy)) return text } } }, htmx_after_swap: (elt, detail) => { - for (const node of detail.ctx._deferredHeadScripts || []) appendNode(node) + let deferred = deferredHeadScripts.get(detail.ctx) || [] + deferredHeadScripts.delete(detail.ctx) + for (const node of deferred) appendNode(node) }, htmx_history_cache_before_restore: (elt, detail) => { if (detail.head) { - // mergeHead awaits stylesheets/blocking scripts, returns deferred scripts. - // Set detail.ready so history-cache awaits before swapping body. - // Stash deferred scripts on detail — history-cache copies them onto the swap ctx - // so htmx_after_swap picks them up. detail.ready = mergeHead(detail.head, 'merge').then(deferred => { - detail._deferredHeadScripts = deferred; - }); + deferredHeadScripts.set(detail, deferred) + }) } + }, + htmx_history_cache_after_restore: (elt, detail) => { + let deferred = deferredHeadScripts.get(detail) || [] + deferredHeadScripts.delete(detail) + for (const node of deferred) appendNode(node) } }) diff --git a/src/ext/hx-history-cache.js b/src/ext/hx-history-cache.js index 61d9418a1..d3b06da1d 100644 --- a/src/ext/hx-history-cache.js +++ b/src/ext/hx-history-cache.js @@ -169,21 +169,19 @@ let cachedHTML = item.content; let restoreSwapTarget = getHistoryTarget(); let restoreSwapStyle = cfg().swapStyle; - let ctx = { - sourceElement: document.body, - target: restoreSwapTarget, - swap: restoreSwapStyle, - text: cachedHTML, - transition: false, - _deferredHeadScripts: detail._deferredHeadScripts - }; - await htmx.swap(ctx); + + await htmx.swap(cachedHTML, restoreSwapTarget, { + source: document.body, + style: restoreSwapStyle, + transition: false + }); document.title = item.title || document.title; requestAnimationFrame(() => { window.scrollTo(0, item.scroll || 0); restoreAnnotations(getHistoryTarget()); - api.triggerHtmxEvent(document, 'htmx:history:cache:after:restore', { item }); + detail.item = item; + api.triggerHtmxEvent(document, 'htmx:history:cache:after:restore', detail); }); } diff --git a/src/ext/hx-multipart.js b/src/ext/hx-multipart.js new file mode 100644 index 000000000..99c28ce06 --- /dev/null +++ b/src/ext/hx-multipart.js @@ -0,0 +1,1128 @@ +//========================================================== +// hx-multipart.js +// +// Handles multipart htmx responses entirely from the extension. +// Includes the fetch-multipart parser prollyfill so +// Response.prototype.parts() is available when the extension loads. +//========================================================== +(() => { + let api; + + function getReconnectDelay(config, attempt) { + let baseDelay = htmx.parseInterval(config.reconnectDelay) ?? config.reconnectDelay; + let maxDelay = htmx.parseInterval(config.reconnectMaxDelay) ?? config.reconnectMaxDelay; + let delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay); + + if (config.reconnectJitter > 0) { + let jitterRange = delay * config.reconnectJitter; + delay = Math.max(0, delay + (Math.random() * 2 - 1) * jitterRange); + } + + return delay; + } + + function cleanup(element, reason) { + let connection = element?._htmx?.multipart; + if (!connection) return; + + connection.cancelled = true; + connection.abort?.(); + connection.abortController?.abort(); + connection.iterator?.return?.().catch?.(() => { + }); + connection.delayCanceller?.(); + if (connection.visibilityHandler) { + document.removeEventListener('visibilitychange', connection.visibilityHandler); + } + api.triggerHtmxEvent(element, 'htmx:multipart:close', { + connection, + reason: reason || 'cleanup' + }); + delete element._htmx.multipart; + } + + async function handleMultipartResponse(ctx, type) { + let element = ctx.sourceElement; + let hasConnect = api.attributeValue(element, 'hx-multipart:connect') != null; + let hxConfig = api.HCON.parse(api.attributeValue(element, 'hx-config')).multipart || {}; + let config = { + reconnect: hasConnect, + reconnectDelay: 500, + reconnectMaxDelay: 60000, + reconnectMaxAttempts: Infinity, + reconnectJitter: 0.3, + pauseOnBackground: hasConnect, + ...htmx.config.multipart, + ...hxConfig + }; + let connection = { + url: ctx.request.action, + config, + abort: ctx.request.abort, + abortController: null, + iterator: null, + delayCanceller: null, + visibilityHandler: null, + unpauseResolver: null, + attempt: 0, + cancelled: false, + reconnectRequested: false, + status: ctx.response.status + }; + api.htmxProp(element).multipart = connection; + + if (!api.triggerHtmxEvent(element, 'htmx:multipart:before:connection', {connection}) || connection.cancelled) { + cleanup(element, 'cancelled'); + return; + } + api.triggerHtmxEvent(element, 'htmx:multipart:after:connection', {connection}); + + if (config.pauseOnBackground) { + connection.visibilityHandler = () => { + if (document.hidden) { + connection.reconnectRequested = true; + connection.iterator?.return?.().catch?.(() => { + }); + connection.abort?.(); + connection.abortController?.abort(); + } else { + connection.unpauseResolver?.(); + } + }; + document.addEventListener('visibilitychange', connection.visibilityHandler); + } + + let currentResponse = ctx.response.raw; + let { + target: envelopeTarget, + swap: envelopeSwap, + select: envelopeSelect, + retarget: envelopeRetarget, + reswap: envelopeReswap, + reselect: envelopeReselect + } = extractPartActions(currentResponse.headers); + let { + content, + target: requestTarget, + ...defaultSwap + } = ctx.swap; + let defaultTarget = envelopeRetarget ?? envelopeTarget ?? requestTarget; + let defaultSwapValue = envelopeReswap ?? envelopeSwap; + let defaultSelect = envelopeReselect ?? envelopeSelect ?? defaultSwap.select; + + try { + while (element.isConnected && !connection.cancelled) { + if (connection.attempt > 0) { + if (!config.reconnect || connection.attempt > config.reconnectMaxAttempts) break; + + if (config.pauseOnBackground && document.hidden) { + await new Promise(resolve => connection.unpauseResolver = resolve); + connection.unpauseResolver = null; + if (!element.isConnected || connection.cancelled) break; + } + + connection.cancelled = false; + if (!api.triggerHtmxEvent(element, 'htmx:multipart:before:connection', {connection}) || connection.cancelled) break; + + await new Promise(resolve => { + let done = () => { + connection.delayCanceller = null; + resolve(); + }; + let timer = setTimeout(done, getReconnectDelay(config, connection.attempt)); + connection.delayCanceller = () => { + clearTimeout(timer); + done(); + }; + }); + if (!element.isConnected || connection.cancelled) break; + + let ac = new AbortController(); + connection.abortController = ac; + try { + currentResponse = await fetch(ctx.request.action, { + ...ctx.request, + signal: ac.signal + }); + } catch (error) { + if (!ac.signal.aborted) { + api.triggerHtmxEvent(element, 'htmx:multipart:error', { + error, + url: ctx.request.action + }); + } + connection.attempt++; + continue; + } + + if (!currentResponse.ok) { + api.triggerHtmxEvent(element, 'htmx:multipart:error', { + error: new Error(`Multipart reconnect failed with status ${currentResponse.status}`), + status: currentResponse.status, + url: ctx.request.action + }); + connection.attempt++; + continue; + } + + let contentType = currentResponse.headers.get('Content-Type') || ''; + let nextType = contentType.split(';', 1)[0].trim().toLowerCase(); + if (nextType !== type) { + api.triggerHtmxEvent(element, 'htmx:multipart:error', { + error: new Error(`Multipart reconnect returned ${nextType || 'no Content-Type'}`), + status: currentResponse.status, + url: ctx.request.action + }); + connection.attempt++; + continue; + } + + connection.status = currentResponse.status; + connection.reconnectRequested = false; + connection.attempt = 0; + api.triggerHtmxEvent(element, 'htmx:multipart:after:connection', {connection}); + } + + let pending = new Set(); + let iterator = currentResponse.parts()[Symbol.asyncIterator](); + connection.iterator = iterator; + + try { + while (true) { + let {done, value: part} = await iterator.next(); + if (done) break; + + let pendingWork = []; + let detail = { + ctx, + part, + cancelled: false, + waitUntil: promise => pendingWork.push(Promise.resolve(promise)) + }; + let shouldProcess = api.triggerHtmxEvent( + ctx.sourceElement, + 'htmx:multipart:before:part', + detail + ); + + await Promise.all(pendingWork); + if (!shouldProcess || detail.cancelled) continue; + + let { + swap, // HX-Swap + target, // HX-Target + select, // HX-Select + reswap, // HX-Reswap + retarget, // HX-Retarget + reselect, // HX-Reselect + ...actions // other HX-* headers in camelCase + } = extractPartActions(part.headers); + + // Let part headers override envelope and request defaults. + swap = reswap ?? swap ?? defaultSwapValue; + target = retarget ?? target ?? defaultTarget; + select = reselect ?? select ?? defaultSelect; + + let text = await part.text(); + let handling = (async () => { + let skipSwap = api.runActions(actions, ctx.sourceElement, {ctx, part}); + + if (!skipSwap) { + let options = {source: ctx.sourceElement}; + if (swap) { + options.swap = swap; + if (defaultSwap.select !== undefined) options.select = defaultSwap.select; + if (defaultSwap.selectOOB !== undefined) options.selectOOB = defaultSwap.selectOOB; + } else { + for (let key in defaultSwap) { + if (defaultSwap[key] !== undefined) options[key] = defaultSwap[key]; + } + } + if (select) options.select = select; + await htmx.swap(text, target, options); + } + + api.triggerHtmxEvent(ctx.sourceElement, 'htmx:multipart:after:part', {ctx, part}); + })(); + + if (type === 'multipart/parallel') { + pending.add(handling); + handling.then( + () => pending.delete(handling), + () => {} + ); + } else { + await handling; + } + } + await Promise.all(pending); + } catch (error) { + if (!connection.cancelled) { + api.triggerHtmxEvent(element, 'htmx:multipart:error', { + error, + url: ctx.request.action + }); + } + } finally { + connection.iterator = null; + } + + if (!config.reconnect && !connection.reconnectRequested) break; + if (!element.isConnected || connection.cancelled) break; + connection.reconnectRequested = false; + connection.attempt++; + } + } finally { + cleanup(element, element.isConnected ? 'ended' : 'removed'); + } + } + + function extractPartActions(headers) { + let actions = {}; + for (let [name, value] of headers) { + name = name.toLowerCase(); + if (name.startsWith('hx-')) { + actions[name.slice(3).replace(/-(\w)/g, (_, c) => c.toUpperCase())] = value; + } + } + return actions; + } + + htmx.registerExtension('hx-multipart', { + init: (internalAPI) => { + api = internalAPI; + }, + + /** + * Add `multipart/mixed` and `multipart/parallel` to every htmx request's `Accept` header. + */ + htmx_config_request: (element, {ctx: {request}}) => { + request.headers['Accept'] = `${request.headers['Accept'] ?? request.headers['accept'] ?? 'text/html'}, multipart/mixed, multipart/parallel`; + }, + + /** + * Connect each `hx-multipart:connect` element on `hx-trigger`, or on load. + */ + htmx_after_process: (element) => { + let metaCharacter = htmx.config.metaCharacter || ':'; + let selector = [ + `hx-multipart${metaCharacter}connect`, + htmx.config.prefix && `${htmx.config.prefix}multipart${metaCharacter}connect` + ] + .filter(Boolean) + .map(name => `[${CSS.escape(name)}]`) + .join(','); + + // Find elements with hx-multipart:connect + let connectElements = [ + element, + ...element.querySelectorAll(selector) + ].filter(elt => elt.matches?.(selector)); + + for (let connectElt of connectElements) { + let hxMultipartConnect = api.attributeValue(connectElt, 'hx-multipart:connect'); + let hxMultipartClose = api.attributeValue(connectElt, 'hx-multipart:close'); + let hxTrigger = api.attributeValue(connectElt, 'hx-trigger'); + + let url = hxMultipartConnect; + + api.onTrigger( + connectElt, + hxTrigger || 'load', + () => { + if (connectElt._htmx?.multipart) return; + + htmx.ajax( + 'GET', + url, + { + source: connectElt, + request: {timeout: 0} + }); + } + ); + + if (hxMultipartClose) { + api.onTrigger(connectElt, hxMultipartClose, () => cleanup(connectElt, 'part')); + } + } + }, + + htmx_before_response: (element, detail) => { + let ctx = detail.ctx; + let response = ctx.response.raw; + let contentType = response.headers.get('Content-Type') || ''; + let type = contentType.split(';', 1)[0].trim().toLowerCase(); + if (type !== 'multipart/mixed' && type !== 'multipart/parallel') return; + + let handled = false; + response.text = async () => { + if (handled) return ''; + handled = true; + await handleMultipartResponse(ctx, type); + ctx.swap.style = 'none'; + return ''; + }; + }, + + htmx_before_cleanup: (element) => { + cleanup(element, 'removed'); + } + }); + +// BEGIN vendored fetch-multipart parser from https://github.com/scriptogre/fetch-multipart @ e08a100de2 +// Copied so this extension can parse multipart responses without requiring core htmx changes. +// @ts-self-types="./fetch-multipart.d.ts" +// Streaming multipart parser for the browser. +// +// Public API: +// Response.prototype.parts() -> AsyncIterable +// BodyPart.prototype.parts() -> AsyncIterable +// getMultipartBoundary(contentType) -> string | null +// parseContentDisposition(header) -> { type, name, filename } +// class BodyPart implements Body +// class MultipartParser +// class MultipartParseError extends Error +// +// Parser engine ported from @remix-run/multipart-parser (MIT, Shopify Inc). +// https://github.com/remix-run/remix/tree/main/packages/multipart-parser + +/** + * Thrown when a multipart stream cannot be parsed. + */ +class MultipartParseError extends TypeError { + constructor(message) { + super(message) + this.name = 'MultipartParseError' + } +} + +// ---------- byte search ---------- + +const utf8Encoder = new TextEncoder() +const utf8Decoder = new TextDecoder() + +// Boyer-Moore-Horspool over a Uint8Array. +function createSearch(pattern) { + const needle = utf8Encoder.encode(pattern) + const needleEnd = needle.length - 1 + const skipTable = new Uint8Array(256).fill(needle.length) + for (let i = 0; i < needleEnd; ++i) skipTable[needle[i]] = needleEnd - i + + return (haystack, start = 0) => { + const haystackLength = haystack.length + let i = start + needleEnd + while (i < haystackLength) { + for (let j = needleEnd, k = i; j >= 0 && haystack[k] === needle[j]; --j, --k) { + if (j === 0) return k + } + i += skipTable[haystack[i]] + } + return -1 + } +} + +// Find the start index (within `haystack[from..]`) where a suffix of haystack +// matches a prefix of `pattern`. Used to detect a boundary split across chunks. +function createPartialTailSearch(pattern) { + const needle = utf8Encoder.encode(pattern) + const byteIndexes = Object.create(null) + for (let i = 0; i < needle.length; ++i) { + const byte = needle[i] + if (byteIndexes[byte] === undefined) byteIndexes[byte] = [] + byteIndexes[byte].push(i) + } + + return (haystack, from = 0) => { + const haystackEnd = haystack.length - 1 + if (haystackEnd < from) return -1 + const indexes = byteIndexes[haystack[haystackEnd]] + if (indexes) { + for (let i = indexes.length - 1; i >= 0; --i) { + for (let j = indexes[i], k = haystackEnd; j >= 0 && k >= from && haystack[k] === needle[j]; --j, --k) { + if (j === 0) return k + } + } + } + return -1 + } +} + +// ---------- parser state machine ---------- + +const State = Object.freeze({ + // Scan for the opening "--boundary", discarding any preamble bytes. + START: 0, + // After a boundary, read "\r\n" or "--". + READING_BOUNDARY_SUFFIX: 1, + // Read part headers through "\r\n\r\n". + READING_HEADERS: 2, + // No Content-Length. Scan for the next boundary. + READING_BODY_UNTIL_BOUNDARY: 3, + // Read exactly the declared Content-Length bytes. + READING_BODY_WITH_CONTENT_LENGTH: 4, + // Content-Length body is complete. Validate the following boundary. + EXPECTING_BOUNDARY: 5, + // Final "--" after a boundary was read. + DONE: 6, +}) + +const findDoubleNewline = createSearch('\r\n\r\n') +const contentLengthRegex = /^content-length:\s*(\d+)/im + +function extractContentLength(headerBytes) { + const match = contentLengthRegex.exec(utf8Decoder.decode(headerBytes)) + return match ? Number(match[1]) : -1 +} + +class MultipartParser { + #findOpeningBoundary + #openingBoundaryLength + #findBoundary + #findPartialTailBoundary + #boundaryLength + #boundaryBytes + + #state = State.START + #buffer = null + #currentHeader = null + #remainingBodyBytes = 0 + #activePart = null + + /** + * Driver hook: called when the active part's body stream wants more bytes. + * The driver should pump from its source until the controller is satisfied. + * + * @type {((part: BodyPart) => Promise) | null} + */ + onPull = null + + constructor(boundary) { + // RFC 2046 §5.1.1 limits the boundary to 1-70 ASCII characters from a + // small subset. Real-world implementations stick to printable ASCII; we + // enforce that broader range so non-ASCII boundaries fail loudly instead + // of silently misaligning the parser's char-length arithmetic. + if (!/^[\x20-\x7E]{1,70}$/.test(boundary)) { + throw new MultipartParseError( + 'Invalid boundary: must be 1-70 printable ASCII characters', + ) + } + + this.boundary = boundary + this.#findOpeningBoundary = createSearch(`--${boundary}`) + this.#openingBoundaryLength = 2 + boundary.length + const boundaryPattern = `\r\n--${boundary}` + this.#findBoundary = createSearch(boundaryPattern) + this.#findPartialTailBoundary = createPartialTailSearch(boundaryPattern) + this.#boundaryLength = 4 + boundary.length + this.#boundaryBytes = utf8Encoder.encode(boundaryPattern) + } + + /** The part currently receiving body bytes, or null. */ + get activePart() { + return this.#activePart + } + + /** + * Feed a chunk to the parser. Invokes `onPart` (if provided) for each + * BodyPart that opens during this call. Body bytes for the active part + * are routed to its body stream synchronously. + * + * @param {Uint8Array} chunk + * @param {((part: BodyPart) => void) | null} [onPart] + */ + write(chunk, onPart = null) { + // Discard epilogue bytes after the closing boundary (RFC 2046 §5.1.1). + if (this.#state === State.DONE) return + + let index = 0 + let chunkLength = chunk.length + + if (this.#buffer !== null) { + if (this.#state === State.READING_BODY_UNTIL_BOUNDARY) { + const carry = this.#buffer + this.#buffer = null + const carryResult = this.#analyzeCarryBoundary(carry, chunk) + + if (carryResult.kind === 'none') { + this.#routeBody(carry) + } else if (carryResult.kind === 'partial') { + if (carryResult.start > 0) this.#routeBody(carry.subarray(0, carryResult.start)) + const tailLength = carry.length + chunk.length - carryResult.start + const tail = new Uint8Array(tailLength) + const carryTail = carry.subarray(carryResult.start) + tail.set(carryTail, 0) + tail.set(chunk, carryTail.length) + this.#buffer = tail + return + } else { + if (carryResult.start > 0) this.#routeBody(carry.subarray(0, carryResult.start)) + this.#finalizeActivePart() + this.#state = State.READING_BOUNDARY_SUFFIX + const carryAfterStart = carry.length - carryResult.start + index = this.#boundaryLength - carryAfterStart + } + } else { + const newChunk = new Uint8Array(this.#buffer.length + chunkLength) + newChunk.set(this.#buffer, 0) + newChunk.set(chunk, this.#buffer.length) + chunk = newChunk + chunkLength = chunk.length + this.#buffer = null + } + } + + while (true) { + if (this.#state === State.READING_BODY_UNTIL_BOUNDARY) { + const boundaryIndex = this.#findBoundary(chunk, index) + if (boundaryIndex === -1) { + const partialTailIndex = this.#findPartialTailBoundary(chunk, index) + if (partialTailIndex === -1) { + this.#routeBody(index === 0 ? chunk : chunk.subarray(index)) + } else { + if (partialTailIndex > index) this.#routeBody(chunk.subarray(index, partialTailIndex)) + this.#buffer = chunk.subarray(partialTailIndex) + } + break + } + + if (boundaryIndex > index) this.#routeBody(chunk.subarray(index, boundaryIndex)) + this.#finalizeActivePart() + index = boundaryIndex + this.#boundaryLength + this.#state = State.READING_BOUNDARY_SUFFIX + } + + if (this.#state === State.READING_BOUNDARY_SUFFIX) { + if (chunkLength - index < 2) { + this.#buffer = chunk.subarray(index) + break + } + // Closing boundary is followed by '--'. + if (chunk[index] === 45 && chunk[index + 1] === 45) { + this.#state = State.DONE + break + } + index += 2 // skip \r\n + this.#state = State.READING_HEADERS + } + + if (this.#state === State.READING_HEADERS) { + if (chunkLength - index < 4) { + this.#buffer = chunk.subarray(index) + break + } + const headerEndIndex = findDoubleNewline(chunk, index) + if (headerEndIndex === -1) { + this.#buffer = chunk.subarray(index) + break + } + this.#currentHeader = chunk.subarray(index, headerEndIndex) + index = headerEndIndex + 4 // skip \r\n\r\n + const contentLength = extractContentLength(this.#currentHeader) + this.#activePart = new BodyPart(this.#currentHeader, this.onPull) + if (onPart) onPart(this.#activePart) + if (contentLength >= 0) { + this.#remainingBodyBytes = contentLength + this.#state = State.READING_BODY_WITH_CONTENT_LENGTH + } else { + this.#state = State.READING_BODY_UNTIL_BOUNDARY + } + continue + } + + // Fast path: the part declared its size, so read exactly that many + // body bytes and close its stream without waiting for more wire data. + if (this.#state === State.READING_BODY_WITH_CONTENT_LENGTH) { + const bodyBytes = Math.min(this.#remainingBodyBytes, chunkLength - index) + if (bodyBytes > 0) this.#routeBody(chunk.subarray(index, index + bodyBytes)) + this.#remainingBodyBytes -= bodyBytes + index += bodyBytes + + if (this.#remainingBodyBytes > 0) { + this.#buffer = chunk.subarray(index) + break + } + this.#finalizeActivePart() + this.#state = State.EXPECTING_BOUNDARY + } + + if (this.#state === State.EXPECTING_BOUNDARY) { + if (chunkLength - index < this.#boundaryLength) { + this.#buffer = chunk.subarray(index) + break + } + for (let i = 0; i < this.#boundaryLength; i++) { + if (chunk[index + i] !== this.#boundaryBytes[i]) { + throw new MultipartParseError( + 'Content-Length body is not followed by boundary', + ) + } + } + + index += this.#boundaryLength + this.#state = State.READING_BOUNDARY_SUFFIX + } + + if (this.#state === State.START) { + if (chunkLength < this.#openingBoundaryLength) { + this.#buffer = chunk + break + } + // Discard preamble bytes before the opening boundary (RFC 2046 §5.1.1). + const openingIndex = this.#findOpeningBoundary(chunk) + if (openingIndex === -1) { + const tailStart = chunkLength - (this.#openingBoundaryLength - 1) + this.#buffer = chunk.subarray(tailStart) + break + } + index = openingIndex + this.#openingBoundaryLength + this.#state = State.READING_BOUNDARY_SUFFIX + } + } + } + + finish() { + // Flush any body bytes still in the carry buffer. + if (this.#buffer && this.#state === State.READING_BODY_UNTIL_BOUNDARY) { + this.#routeBody(this.#buffer) + this.#buffer = null + } + if (this.#state !== State.DONE) { + const message = this.#state === State.READING_BODY_WITH_CONTENT_LENGTH + ? 'Stream ended before Content-Length body completed' + : 'Stream ended before final boundary' + const err = new MultipartParseError(message) + this.abortActive(err) + throw err + } + } + + /** Errors the active part's body stream. Used when the source stream errors. */ + abortActive(err) { + if (this.#activePart) { + this.#activePart._error(err) + this.#activePart = null + } + } + + #routeBody(chunk) { + if (chunk.length === 0) return + if (this.#activePart) this.#activePart._enqueue(chunk) + } + + #finalizeActivePart() { + if (this.#activePart) { + this.#activePart._close() + this.#activePart = null + } + } + + // Detect a boundary whose start lies inside the carry buffer (from the + // previous chunk) and continues into the current chunk. + #analyzeCarryBoundary(carry, chunk) { + const totalLength = carry.length + chunk.length + + for (let start = 0; start < carry.length; ++start) { + const availableLength = totalLength - start + const compareLength = Math.min(this.#boundaryLength, availableLength) + + let matched = true + for (let i = 0; i < compareLength; ++i) { + const sourceIndex = start + i + const sourceByte = + sourceIndex < carry.length ? carry[sourceIndex] : chunk[sourceIndex - carry.length] + if (sourceByte !== this.#boundaryBytes[i]) { + matched = false + break + } + } + if (!matched) continue + + if (availableLength >= this.#boundaryLength) return { kind: 'full', start } + return { kind: 'partial', start } + } + + return { kind: 'none' } + } +} + +// ---------- BodyPart (implements Body) ---------- + +function parseHeaderBytes(raw) { + const headers = new Headers() + const text = utf8Decoder.decode(raw) + for (const line of text.split('\r\n')) { + const match = line.match(/^([^:]+):(.*)/) + if (match) headers.append(match[1].trim(), match[2].trim()) + } + return headers +} + +/** + * A MIME body part. Implements the WHATWG Fetch `Body` interface plus a + * `parts()` method for recursing into nested `multipart/*` bodies. + * + * The body is a live `ReadableStream` that receives bytes as the + * parser sees them. Callers must consume each part's body (or cancel it) + * before iterating to the next part; iterating past an unread body + * auto-drains it. + */ +class BodyPart { + /** @type {Uint8Array} */ #headerBytes + /** @type {Headers | null} */ #headers = null + #bodyUsed = false + #closed = false + /** @type {Error | null} */ #error = null + + // Body bytes accumulate here until something accesses `body` or `bytes()` etc. + // If the parser finishes the part before the consumer touches it, the bytes + // are returned directly (no ReadableStream construction). + /** @type {Uint8Array[] | null} */ #pendingChunks = [] + + /** @type {ReadableStream | null} */ #body = null + /** @type {ReadableStreamDefaultController | null} */ #controller = null + /** @type {((part: BodyPart) => Promise) | null} */ #pullHook + + /** + * @param {Uint8Array} headerBytes + * @param {((part: BodyPart) => Promise) | null} pullHook + */ + constructor(headerBytes, pullHook) { + this.#headerBytes = headerBytes + this.#pullHook = pullHook + } + + /** @returns {Headers} */ + get headers() { + if (this.#headers === null) this.#headers = parseHeaderBytes(this.#headerBytes) + return this.#headers + } + + /** @returns {boolean} */ + get bodyUsed() { + return this.#bodyUsed + } + + /** @returns {ReadableStream} */ + get body() { + if (this.#body === null) this.#materializeBody() + return this.#body + } + + /** @returns {Promise} */ + async bytes() { + if (this.#bodyUsed) throw new TypeError('Body already used') + this.#bodyUsed = true + if (this.#body === null && this.#closed) { + if (this.#error) throw this.#error + const out = concatChunks(this.#pendingChunks) + this.#pendingChunks = null + return out + } + return new Response(this.body).bytes() + } + + /** @returns {Promise} */ + async arrayBuffer() { + return /** @type {ArrayBuffer} */ ((await this.bytes()).buffer) + } + + /** @returns {Promise} */ + async text() { + return utf8Decoder.decode(await this.bytes()) + } + + /** @returns {Promise} */ + async json() { + return JSON.parse(await this.text()) + } + + /** @returns {Promise} */ + async blob() { + if (this.#bodyUsed) throw new TypeError('Body already used') + this.#bodyUsed = true + const type = this.headers.get('content-type') ?? '' + if (this.#body === null && this.#closed) { + if (this.#error) throw this.#error + const blob = new Blob([concatChunks(this.#pendingChunks)], { type }) + this.#pendingChunks = null + return blob + } + return new Response(this.body, { headers: { 'content-type': type } }).blob() + } + + /** + * Parse this part's body as a nested `multipart/*` message. + * + * @returns {AsyncGenerator} + */ + async *parts() { + if (this.#bodyUsed) throw new TypeError('Body already used') + this.#bodyUsed = true + const contentType = this.headers.get('content-type') + if (!contentType || !contentType.toLowerCase().startsWith('multipart/')) { + throw new MultipartParseError('Content-Type is not multipart/*') + } + const boundary = getMultipartBoundary(contentType) + if (!boundary) { + throw new MultipartParseError('Content-Type has no boundary parameter') + } + yield* iterateStreamParts(this.body, boundary) + } + + #materializeBody() { + const self = this + const pending = this.#pendingChunks + this.#pendingChunks = null + this.#body = new ReadableStream({ + start(controller) { + self.#controller = controller + for (const chunk of pending) controller.enqueue(chunk) + if (self.#error) controller.error(self.#error) + else if (self.#closed) controller.close() + }, + async pull() { + if (self.#pullHook && !self.#closed) await self.#pullHook(self) + }, + cancel() { + self.#closed = true + self.#bodyUsed = true + }, + }) + } + + // ---- internal: parser ---- + + _enqueue(chunk) { + if (this.#closed) return + if (this.#controller) this.#controller.enqueue(chunk) + else this.#pendingChunks.push(chunk) + } + + _close() { + if (this.#closed) return + this.#closed = true + if (this.#controller) this.#controller.close() + } + + _error(err) { + if (this.#closed) return + this.#closed = true + if (this.#controller) this.#controller.error(err) + else this.#error = err + } + + /** Drop incoming bytes; used when the iterator advances past an unread body. */ + _drain() { + this.#closed = true + this.#bodyUsed = true + this.#pendingChunks = null + } + + _wantsMore() { + if (this.#closed) return false + if (this.#controller) return (this.#controller.desiredSize ?? 0) > 0 + return true + } +} + +function concatChunks(chunks) { + // Always copy. A returned Uint8Array's `.buffer` should be sized to the body, + // not the source chunk it was subarray'd from. + let total = 0 + for (const c of chunks) total += c.length + const out = new Uint8Array(total) + let offset = 0 + for (const c of chunks) { + out.set(c, offset) + offset += c.length + } + return out +} + +// ---------- public API ---------- + +/** + * @param {string} contentType + * @returns {string | null} + */ +function getMultipartBoundary(contentType) { + const match = /boundary\s*=\s*(?:"([^"]+)"|([^;]+))/i.exec(contentType) + return match ? (match[1] ?? match[2].trim()) : null +} + +/** + * @typedef {Object} ContentDispositionParts + * @property {string | null} type - 'form-data', 'attachment', 'inline', etc. + * @property {string | null} name - form field name from the `name=` parameter + * @property {string | null} filename - decoded filename (`filename*=` wins over `filename=`) + */ + +/** + * Parse a `Content-Disposition` header into its components. + * + * @param {string | null} header + * @returns {ContentDispositionParts} + */ +function parseContentDisposition(header) { + if (typeof header !== 'string') return { type: null, name: null, filename: null } + + const segments = splitOnUnquotedSemicolon(header) + const type = segments[0].trim().toLowerCase() || null + + const params = Object.create(null) + for (let i = 1; i < segments.length; i++) { + const eq = segments[i].indexOf('=') + if (eq === -1) continue + const key = segments[i].slice(0, eq).trim().toLowerCase() + let value = segments[i].slice(eq + 1).trim() + if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') { + value = value.slice(1, -1) + } + params[key] = value + } + + const filenameStar = params['filename*'] + const filename = filenameStar != null + ? decodeRfc5987(filenameStar) + : (params.filename ?? null) + + return { type, name: params.name ?? null, filename } +} + +// Split on `;` but ignore semicolons inside a quoted-string. +function splitOnUnquotedSemicolon(input) { + const parts = [] + let inQuotes = false + let start = 0 + for (let i = 0; i < input.length; i++) { + const ch = input.charCodeAt(i) + if (ch === 34 /* " */) inQuotes = !inQuotes + else if (ch === 59 /* ; */ && !inQuotes) { + parts.push(input.slice(start, i)) + start = i + 1 + } + } + parts.push(input.slice(start)) + return parts +} + +// Decode an RFC 5987 ext-value: charset'language'percent-encoded. +// https://www.rfc-editor.org/rfc/rfc5987#section-3.2.1 +function decodeRfc5987(value) { + const firstQuote = value.indexOf("'") + if (firstQuote === -1) return null + const secondQuote = value.indexOf("'", firstQuote + 1) + if (secondQuote === -1) return null + const charset = value.slice(0, firstQuote).toLowerCase() + const encoded = value.slice(secondQuote + 1) + if (charset !== 'utf-8') return null + try { + return decodeURIComponent(encoded) + } catch { + return null + } +} + +// Iterate the parts of a `Response` whose Content-Type is `multipart/*`. +async function* iterateResponseParts(response) { + const contentType = response.headers.get('content-type') + if (!contentType || !contentType.toLowerCase().startsWith('multipart/')) { + throw new MultipartParseError('Content-Type is not multipart/*') + } + if (!response.body) { + throw new MultipartParseError('Response body is null') + } + const boundary = getMultipartBoundary(contentType) + if (!boundary) { + throw new MultipartParseError('Content-Type has no boundary parameter') + } + yield* iterateStreamParts(response.body, boundary) +} + +// Drive the parser over a `ReadableStream` with a known boundary. +// Reads source bytes only when the active part's body controller wants more, +// propagating backpressure from consumer to source. +async function* iterateStreamParts(stream, boundary) { + const parser = new MultipartParser(boundary) + const reader = stream.getReader() + + // Head-pointer queue. shift() is O(1) on Arrays in V8 for queue-like usage, + // but head-pointer avoids index walking entirely and lets us hoist the + // common "queue has next part" fast path. + const queue = [] + let head = 0 + let sourceDone = false + let sourceError = null + let pumpInflight = null + + const enqueuePart = (part) => queue.push(part) + + async function pump() { + if (pumpInflight) return pumpInflight + pumpInflight = (async () => { + try { + const { done, value } = await reader.read() + if (done) { + sourceDone = true + try { parser.finish() } catch (err) { sourceError = err } + return + } + if (value.length > 0) parser.write(value, enqueuePart) + } catch (err) { + sourceError = err + sourceDone = true + parser.abortActive(err) + } finally { + pumpInflight = null + } + })() + return pumpInflight + } + + parser.onPull = async (part) => { + while (!sourceDone && !sourceError && part._wantsMore()) await pump() + } + + try { + while (true) { + if (head < queue.length) { + const part = queue[head++] + if (head === queue.length) { queue.length = 0; head = 0 } + yield part + continue + } + if (sourceError) throw sourceError + if (sourceDone) return + + // No queued parts and source still running. If the caller iterated past + // an unread body, drop subsequent bytes for it while we scan for the + // next boundary. + const active = parser.activePart + if (active) active._drain() + + await pump() + } + } finally { + reader.releaseLock() + parser.abortActive(new MultipartParseError('Iterator exited before stream ended')) + } +} + +// ---------- prollyfill: Response.prototype.parts() ---------- +// +// Speculative install of a `parts()` method on Response. Mirrors the shape of +// `Response.prototype.formData()`. Conditional so a future native version wins +// automatically. + +if (typeof Response !== 'undefined' && typeof Response.prototype.parts !== 'function') { + Object.defineProperty(Response.prototype, 'parts', { + value: function parts() { + return iterateResponseParts(this) + }, + writable: true, + configurable: true, + }) +} +// END vendored fetch-multipart parser +})(); diff --git a/src/ext/hx-optimistic.js b/src/ext/hx-optimistic.js index 8f1f5f7d5..e6693ebfe 100644 --- a/src/ext/hx-optimistic.js +++ b/src/ext/hx-optimistic.js @@ -18,11 +18,7 @@ let sourceElt = document.querySelector(ctx.optimistic); if (!sourceElt) return; - let target = ctx.target; - - if (typeof target === 'string') { - target = document.querySelector(target); - } + let target = ctx.swap.target; if (!target) return; // Create optimistic div with reset styling @@ -49,7 +45,7 @@ } } - let swapStyle = normalizeSwapStyle(ctx.swap); + let swapStyle = normalizeSwapStyle(ctx.swap.style); ctx.optHidden = []; if (swapStyle === 'innerHTML') { diff --git a/src/ext/hx-prompt.js b/src/ext/hx-prompt.js index 443080369..4e6371402 100644 --- a/src/ext/hx-prompt.js +++ b/src/ext/hx-prompt.js @@ -16,7 +16,7 @@ if (question == null) return; let answer = (window.htmxPrompt || window.prompt)(question); if (answer === null) return false; - if (!htmx.trigger(ctx.sourceElement, 'htmx:prompt', { prompt: answer, target: ctx.target })) return false; + if (!htmx.trigger(ctx.sourceElement, 'htmx:prompt', { prompt: answer, target: ctx.swap.target })) return false; ctx.request.headers['HX-Prompt'] = encodeURI(answer); } }); diff --git a/src/ext/hx-sse.js b/src/ext/hx-sse.js index 02c1cd192..c979e40a3 100644 --- a/src/ext/hx-sse.js +++ b/src/ext/hx-sse.js @@ -256,10 +256,10 @@ continue; } - // Swap content using the ctx from core (target/swap already resolved) - ctx.text = detail.message.data; - if (!ctx.swap.includes('swapEmpty')) ctx.swap += ' swapEmpty:false'; - await htmx.swap(ctx); + ctx.swap.content = detail.message.data; + ctx.swap.swapEmpty ??= false; + let {content, target, ...options} = ctx.swap; + await htmx.swap(content, target, {...options, source: ctx.sourceElement}); delete detail.message.cancelled; api.triggerHtmxEvent(element, 'htmx:after:sse:message', detail); } diff --git a/src/ext/hx-ws.js b/src/ext/hx-ws.js index 3138c6bad..0c9d420b3 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,112 @@ // 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); + + // Route associated incoming messages through their sender. + let element = pending?.element; + if (!element?.isConnected) element = findConnectedElement(connection.url); - if (!connectionElement) { + 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'); - - htmx.swap({ - sourceElement: connectionElement, - target: target || connectionElement, - swap: swap || (target ? htmx.config.defaultSwap : 'none'), - text: html, + let target = json?.target || api.attributeValue(element, 'hx-target'); + let swap = json?.swap || api.attributeValue(element, 'hx-swap') || htmx.config.defaultSwap; + let options = { + swap, + select: json?.select ?? api.attributeValue(element, 'hx-select'), + selectOOB: api.attributeValue(element, 'hx-select-oob'), + source: element, transition: false - }); + }; + if (!/(?:^|\s)swapEmpty(?::(?:true|false))?(?=\s|$)/.test(swap)) options.swapEmpty = false; + + htmx.swap(html, target || element, options); } - delete detail.message.cancelled; - api.triggerHtmxEvent(connectionElement, 'htmx:after:ws:message', detail); + api.triggerHtmxEvent(element, 'htmx:ws:after:message:incoming', {message}); } // ======================================== @@ -637,6 +696,7 @@ connection.socket.close(); } connection.pendingRequests.clear(); + connection.queue.length = 0; }); }, get: (key) => connections.get(normalizeWebSocketUrl(key)), diff --git a/src/htmx.d.ts b/src/htmx.d.ts index a22b0d10d..593778d4a 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -32,7 +32,7 @@ export interface HtmxConfig { * Default swap style when `hx-swap` is not specified. * @default "innerHTML" */ - defaultSwap: string; + defaultSwap: string | HtmxSwap; /** * Scroll the focused element into view after each swap. * @default false @@ -120,28 +120,36 @@ export interface HtmxConfig { defaultSwapEmpty?: boolean; } -/** Context object passed to `htmx.swap()` */ -export interface HtmxSwapContext { - /** HTML string to swap into the DOM */ - text: string; - /** Element that triggered the swap — used for history and event firing */ - sourceElement?: Element; - /** Swap style (e.g. `'innerHTML'`, `'outerHTML'`). Defaults to `htmx.config.defaultSwap` */ - swap?: string; - /** CSS selector to extract content from the response */ +export interface HtmxSwap { + /** HTML content to swap. */ + content?: string; + /** Target selector or element. */ + target?: string | Element; + /** Swap style (e.g. `'innerHTML'`, `'outerHTML'`). */ + style?: string; + /** CSS selector to extract content from the response. */ select?: string; - /** Selector for out-of-band swaps */ + /** Selector for out-of-band swaps. */ selectOOB?: string; - /** Target element to swap into. Defaults to `document.body` */ - target?: Element; - /** Whether to use the View Transitions API for this swap */ + /** Whether to use the View Transitions API. */ transition?: boolean; - /** `hx-push-url` value — push a URL into history after the swap */ - push?: string | boolean; - /** `hx-replace-url` value — replace the current history entry after the swap */ - replace?: string | boolean; - /** URL fragment to scroll into view after the swap */ - anchor?: string; + swapDelay?: string | number; + settleDelay?: string | number; + scroll?: 'top' | 'bottom'; + scrollTarget?: string; + show?: 'top' | 'bottom' | 'none'; + showTarget?: string; + ignoreTitle?: boolean; + focusScroll?: boolean; + swapEmpty?: boolean; + strip?: boolean; +} + +export interface HtmxSwapOptions extends HtmxSwap { + /** Serialized or structured swap specification. */ + swap?: string | HtmxSwap; + /** Element used for relative selectors and lifecycle events. */ + source?: Element | string; } export interface QProxy { @@ -284,32 +292,42 @@ 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]: unknown; +} + /** Request context passed as evt.detail.ctx on most htmx request lifecycle events */ export interface HtmxRequestCtx { /** Element that triggered the request */ sourceElement: Element; /** Event that triggered the request */ sourceEvent: Event | null; - /** Target element where the response will be swapped */ - target: Element; - /** hx-select value */ - select: string; - /** hx-select-oob value */ - selectOOB: string; - /** hx-swap value */ - swap: string; - /** hx-push-url value */ - push: string | boolean; - /** hx-replace-url value */ - replace: string | boolean; - /** Whether to use view transitions */ - transition: boolean; + /** Swap fields */ + swap: HtmxSwap; /** Fetch request options — modify here in htmx:config:request */ request: HtmxRequestOptions; /** Response object, available after fetch resolves */ response?: HtmxResponse; - /** Response body text, available during htmx:after:request */ - text?: string; + /** 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 */ @@ -335,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`. @@ -353,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. @@ -420,6 +442,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') @@ -470,12 +504,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). @@ -510,27 +544,31 @@ export interface HtmxEventMap { export type HtmxEvent = CustomEvent; -/** Context object accepted by `htmx.ajax()` */ -export interface HtmxAjaxContext { +/** Options object accepted by `htmx.ajax()` */ +export interface HtmxAjaxOptions { /** Element to use as the request source (for headers, inheritance, etc.) */ source?: Element | string; /** Event that triggered the request */ event?: Event; /** Target element or CSS selector to swap the response into */ target?: Element | string; - /** Swap style (e.g. `'innerHTML'`, `'outerHTML'`) */ - swap?: string; + /** Serialized or structured swap specification */ + swap?: string | HtmxSwap; /** Additional values to include in the request body */ values?: Record; /** Additional request headers */ headers?: Record; + /** Fetch, timeout, validation, and extension request options */ + request?: Partial; /** CSS selector to extract content from the response */ 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; } @@ -551,7 +589,7 @@ export interface Htmx { * htmx.ajax('GET', '/items', '#list') * htmx.ajax('POST', '/save', { target: '#result', swap: 'outerHTML' }) */ - ajax(verb: string, path: string, context?: Element | string | HtmxAjaxContext): Promise; + ajax(verb: string, path: string, options?: Element | string | HtmxAjaxOptions): Promise; /** * Find the first element matching `selector` in the document. */ @@ -622,7 +660,7 @@ export interface Htmx { * Perform an HTML content swap into the DOM. * Primarily used by extensions and advanced integrations — prefer `htmx.ajax()` for most use cases. */ - swap(ctx: HtmxSwapContext): Promise; + swap(content: string, target: Element | string, options?: string | HtmxSwapOptions): Promise; } declare const htmx: Htmx; diff --git a/src/htmx.js b/src/htmx.js index f586bd3e9..c7df3b6ae 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?.() } } @@ -181,6 +169,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) @@ -414,24 +403,31 @@ var htmx = (() => { }; } - __createRequestContext(sourceElement, sourceEvent) { + __createRequestContext(sourceElement, sourceEvent, overrides = {}) { let {action, method} = this.__determineMethodAndAction(sourceElement, sourceEvent); let [fullAction, anchor] = (action || '').split('#'); + + let defaultHxSwap = this.config.defaultSwap; + let hxSwap = this.__attributeValue(sourceElement, "hx-swap"); + let hxTarget = this.__attributeValue(sourceElement, "hx-target"); + let hxSelect = this.__attributeValue(sourceElement, "hx-select"); + let hxSelectOOB = this.__attributeValue(sourceElement, "hx-select-oob"); + let hxPushUrl = this.__attributeValue(sourceElement, "hx-push-url"); + let hxReplaceUrl = this.__attributeValue(sourceElement, "hx-replace-url"); + let hxConfirm = this.__attributeValue(sourceElement, "hx-confirm"); + let hxValidate = this.__attributeValue(sourceElement, "hx-validate", sourceElement.matches('form') && !sourceElement.noValidate && !sourceEvent.submitter?.formNoValidate ? "true" : "false"); + + // Convert "innerHTML transition:true ..." -> { style: "innerHTML", transition: true, ... }. + defaultHxSwap = this.__parseSwapSpec(defaultHxSwap); + hxSwap = this.__parseSwapSpec(hxSwap); + let ac = new AbortController(); let ctx = { sourceElement, sourceEvent, - status: "created", - select: this.__attributeValue(sourceElement, "hx-select"), - selectOOB: this.__attributeValue(sourceElement, "hx-select-oob"), - target: this.__attributeValue(sourceElement, "hx-target"), - swap: this.__attributeValue(sourceElement, "hx-swap") ?? this.config.defaultSwap, - push: this.__attributeValue(sourceElement, "hx-push-url"), - replace: this.__attributeValue(sourceElement, "hx-replace-url"), - transition: this.config.transitions, - confirm: this.__attributeValue(sourceElement, "hx-confirm"), + confirm: hxConfirm, request: { - validate: "true" === this.__attributeValue(sourceElement, "hx-validate", sourceElement.matches('form') && !sourceElement.noValidate && !sourceEvent.submitter?.formNoValidate ? "true" : "false"), + validate: hxValidate === "true", action: fullAction, anchor, method, @@ -440,16 +436,42 @@ var htmx = (() => { credentials: "same-origin", signal: ac.signal, mode: this.config.mode + }, + swap: { + content: undefined, // Populated from the response. + target: undefined, + style: undefined, + select: undefined, + selectOOB: undefined, + transition: this.config.transitions, + ...defaultHxSwap, + ...(hxTarget !== undefined && {target: hxTarget}), + ...(hxSelect !== undefined && {select: hxSelect}), + ...(hxSelectOOB !== undefined && {selectOOB: hxSelectOOB}), + ...hxSwap + }, + actions: { + pushUrl: hxPushUrl, + replaceUrl: hxReplaceUrl } }; - // Apply boost config overrides - if (sourceElement._htmx?.boosted) { - HCON.merge(sourceElement._htmx.boosted, ctx); + + let hxBoost = sourceElement._htmx?.boosted; + if (hxBoost && hxBoost !== "true") { + let {swap, ...swapOverrides} = HCON.parse(hxBoost); + HCON.merge({ + ...this.__parseSwapSpec(swap), + ...swapOverrides + }, ctx.swap); } - ctx.target = this.__resolveTarget(sourceElement, ctx.target); - ctx.request.headers["HX-Request-Type"] = (ctx.target === document.body || ctx.select) ? "full" : "partial"; - if (ctx.target) { - ctx.request.headers["HX-Target"] = this.__buildIdentifier(ctx.target); + + let {request: requestOverrides, ...contextOverrides} = overrides; + HCON.merge(contextOverrides, ctx); + + ctx.swap.target = this.__resolveTarget(sourceElement, ctx.swap.target); + ctx.request.headers["HX-Request-Type"] = (ctx.swap.target === document.body || ctx.swap.select) ? "full" : "partial"; + if (ctx.swap.target) { + ctx.request.headers["HX-Target"] = this.__buildIdentifier(ctx.swap.target); } // Apply hx-config overrides @@ -458,6 +480,7 @@ var htmx = (() => { HCON.merge(hxConfig, ctx.request); ctx.request.mode = this.config.mode; // mode is security-sensitive, never allow per-element override } + if (requestOverrides) HCON.merge(requestOverrides, ctx.request); return ctx; } @@ -581,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 = []; @@ -615,85 +636,127 @@ var htmx = (() => { status: response.status, headers: response.headers, } - this.__extractHxHeaders(ctx); + 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}; + 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.text = await response.text(); - if (!this.__trigger(elt, "htmx:after:request", {ctx})) return; + ctx.swap.content = await response.text(); + this.__trigger(elt, "htmx:after:response", {ctx}); if (ctx.response.status >= 400) { this.__trigger(elt, "htmx:response:error", {ctx}) } - if(this.__handleHeadersAndMaybeReturnEarly(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); + ctx.actions = { + ...otherActions, + ...(historyAction && {[historyAction.type + 'Url']: historyAction.path}) + }; + + if (this.__runActions(ctx.actions, ctx.sourceElement, {ctx})) { ctx.keepIndicators = true; return } - if (ctx.status === "issuing") { - if (ctx.hx.retarget) ctx.target = ctx.hx.retarget; // HX-Retarget - if (ctx.hx.reswap) ctx.swap = ctx.hx.reswap; // HX-Reswap - if (ctx.hx.reselect) ctx.select = ctx.hx.reselect; // HX-Reselect - ctx.status = "response received"; - this.__handleStatusCodes(ctx); - await this.swap(ctx); - ctx.status = "swapped"; - } + await this.__handleSwap(ctx); } catch (error) { - ctx.status = "error: " + error; 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() - if (requestQueue.more()) { - // intentionally not awaited — __issueRequest has its own try/catch - this.__issueRequest(requestQueue.next()) - } + this.__trigger(elt, "htmx:done", {ctx}) + // start callbacks are intentionally not awaited; __issueRequest has its own try/catch + requestQueue.startNext() } } - // 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); - } - if (ctx.hx.refresh === 'true') { // HX-Refresh + // 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); + + 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; delete opts.path; } - opts.push ??= 'true'; + if (opts.replace == null) opts.push ??= 'true'; this.ajax('GET', path, opts); - return true + } else { + terminal = false; + 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); + } + } } + + this.__trigger(element, "htmx:after:actions", detail); + return terminal; } __initTimeout(ctx) { @@ -721,7 +784,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) { @@ -1110,7 +1173,10 @@ var htmx = (() => { } if (oobValue === 'true' || !oobValue) oobValue = 'outerHTML'; - let swapSpec = this.__parseSwapSpec(oobValue); + let swapSpec = { + ...this.__parseSwapSpec(this.config.defaultSwap), + ...this.__parseSwapSpec(oobValue) + }; targetSelector = swapSpec.target || targetSelector; swapSpec.strip ??= !swapSpec.style.startsWith('outer'); if (!targetSelector) return; @@ -1154,15 +1220,24 @@ var htmx = (() => { } } - __parseSwapSpec(swapStr) { - swapStr = swapStr.trim(); - let style = this.config.defaultSwap + __parseSwapSpec(value) { + if (!value) return {}; + if (value.constructor === Object) return {...value}; + + let swapStr = value.trim(); + let style; if (swapStr && !/^\S*:/.test(swapStr)) { let m = swapStr.match(/^(\S+)\s*(.*)$/); style = m[1]; swapStr = m[2]; } - return {style: this.__normalizeSwapStyle(style), ...HCON.parse(swapStr)}; + let {swap: swapDelay, settle: settleDelay, ...modifiers} = HCON.parse(swapStr); + return { + ...(style !== undefined && {style: this.__normalizeSwapStyle(style)}), + ...(swapDelay !== undefined && {swapDelay}), + ...(settleDelay !== undefined && {settleDelay}), + ...modifiers + }; } __processPartials(fragment, ctx) { @@ -1175,7 +1250,10 @@ var htmx = (() => { let targetSelector = this.__attr(templateElt, 'hx-target') || (templateElt.id ? '#' + CSS.escape(templateElt.id) : null); if (targetSelector) { this.__processScripts(templateElt.content); - let swapSpec = this.__parseSwapSpec(this.__attr(templateElt, 'hx-swap') || this.config.defaultSwap); + let swapSpec = { + ...this.__parseSwapSpec(this.config.defaultSwap), + ...this.__parseSwapSpec(this.__attr(templateElt, 'hx-swap')) + }; for (let target of document.querySelectorAll(targetSelector)) { tasks.push({ type: 'partial', @@ -1255,15 +1333,48 @@ var htmx = (() => { // Public JS API //============================================================================================ - async swap(ctx) { + async swap(content, target, options = {}) { + if (typeof options === 'string') options = {swap: options}; + + let {source, swap, ...flatSwapOptions} = options; + let sourceElement = typeof source === 'string' ? document.querySelector(source) : source; + if (typeof source === 'string' && !sourceElement) { + throw new Error('Source not found'); + } + + let targetElement = this.__resolveTarget(sourceElement || document.body, target); + if (!targetElement) { + throw new Error('Target not found'); + } + sourceElement ||= targetElement; + + return this.__handleSwap({ + sourceElement, + swap: { + content: undefined, + target: undefined, + style: undefined, + select: undefined, + selectOOB: undefined, + transition: this.config.transitions, + ...this.__parseSwapSpec(this.config.defaultSwap), + ...this.__parseSwapSpec(swap), + ...flatSwapOptions, + // positional arguments win + content, + target: targetElement + } + }); + } + + async __handleSwap(ctx) { try { - this.__handleHistoryUpdate(ctx); - let {fragment, title} = this.__makeFragment(ctx.text); + let {fragment, title} = this.__makeFragment(ctx.swap.content); ctx.title = title; let tasks = []; // Process OOB and partials - let oobTasks = this.__processOOB(fragment, ctx.sourceElement, ctx.selectOOB); + let oobTasks = this.__processOOB(fragment, ctx.sourceElement, ctx.swap.selectOOB); let partialTasks = this.__processPartials(fragment, ctx); tasks.push(...oobTasks, ...partialTasks); @@ -1280,7 +1391,7 @@ var htmx = (() => { let swapPromises = []; let transitionTasks = []; for (let task of tasks) { - if (task.swapSpec?.transition ?? mainSwap?.transition ?? ctx.transition) { + if (task.swapSpec?.transition ?? mainSwap?.transition ?? ctx.swap.transition) { transitionTasks.push(task); } else { swapPromises.push(this.__insertContent(task)); @@ -1309,7 +1420,7 @@ var htmx = (() => { __processMainSwap(ctx, fragment, partialTasks) { // Create main task if needed - let swapSpec = this.__parseSwapSpec(ctx.swap || this.config.defaultSwap); + let swapSpec = {...ctx.swap}; // skip main swap if fragment is empty after hx-partial removal but respect empty modifier if ( swapSpec.style === 'delete' || // delete always runs regardless of content @@ -1317,8 +1428,8 @@ var htmx = (() => { fragment.textContent.trim() || // or fragment has text (swapSpec.swapEmpty ?? this.config.defaultSwapEmpty ?? !partialTasks.length) // swapEmpty:true/false overrides, default: allow if no partials ) { - if (ctx.select) { - let selected = fragment.querySelectorAll(ctx.select); + if (ctx.swap.select) { + let selected = fragment.querySelectorAll(ctx.swap.select); fragment = document.createDocumentFragment(); fragment.append(...selected); } @@ -1328,10 +1439,10 @@ var htmx = (() => { let mainSwap = { type: 'main', fragment, - target: this.__resolveTarget(ctx.sourceElement || document.body, swapSpec.target || ctx.target), + target: this.__resolveTarget(ctx.sourceElement || document.body, ctx.swap.target), swapSpec, sourceElement: ctx.sourceElement, - transition: ctx.transition && swapSpec.transition !== false + transition: ctx.swap.transition && swapSpec.transition !== false }; return mainSwap; } @@ -1344,7 +1455,10 @@ var htmx = (() => { } if (!target) return; if (typeof swapSpec === 'string') { - swapSpec = this.__parseSwapSpec(swapSpec); + swapSpec = { + ...this.__parseSwapSpec(this.config.defaultSwap), + ...this.__parseSwapSpec(swapSpec) + }; } let swapStyle = swapSpec.style; if (swapStyle === 'none') return; @@ -1359,8 +1473,8 @@ var htmx = (() => { } this.__addClass(target, "htmx-swapping") - if (cssTransition && task.swapSpec?.swap) { - await this.timeout(task.swapSpec?.swap) + if (cssTransition && task.swapSpec?.swapDelay) { + await this.timeout(task.swapSpec.swapDelay) } if (swapStyle === 'delete') { @@ -1374,7 +1488,7 @@ var htmx = (() => { // innerHTML/outerHTML swaps backup focus and handle CSS transitions let focusInfo; let settleTasks = [] - let settleDelay = swapSpec.settle ?? this.config.defaultSettleDelay; + let settleDelay = swapSpec.settleDelay ?? this.config.defaultSettleDelay; let parentNode = target.parentNode; if (swapStyle === 'innerHTML' || (swapStyle === 'outerHTML' && parentNode)) { let activeElt = document.activeElement; @@ -1573,36 +1687,68 @@ var htmx = (() => { let result = !detail.cancelled && target.dispatchEvent(evt); return result } - ajax(verb, path, context) { - // Normalize context to object - if (!context || context instanceof Element || typeof context === 'string') { - context = {target: context}; + ajax(verb, path, options) { + if (!options || options instanceof Element || typeof options === 'string') { + options = {target: options}; } - let sourceElt = typeof context.source === 'string' ? - document.querySelector(context.source) : context.source; + let { + source, + event, + target, + swap, + select, + selectOOB, + transition, + headers, + request, + push, + replace, + ...contextOverrides + } = options; - // If source selector was provided but didn't match, reject - if (typeof context.source === 'string' && !sourceElt) { + // push and replace are shorthands for the pushUrl and replaceUrl actions + if (push !== undefined || replace !== undefined) { + contextOverrides.actions = {pushUrl: push, replaceUrl: replace, ...contextOverrides.actions}; + } + + let sourceElement = typeof source === 'string' + ? document.querySelector(source) + : source; + + if (typeof source === 'string' && !sourceElement) { return Promise.reject(new Error('Source not found')); } - // Resolve explicit target if provided; otherwise __createRequestContext - // will resolve from hx-target on the source element - if (context.target) { - let target = this.__resolveTarget(document.body, context.target); - if (!target) { - return Promise.reject(new Error('Target not found')); - } - sourceElt ||= target; + let targetElement = target != null + ? this.__resolveTarget(sourceElement || document.body, target) + : null; + + if (target != null && !targetElement) { + return Promise.reject(new Error('Target not found')); } - sourceElt ||= document.body; - let ctx = this.__createRequestContext(sourceElt, context.event || {}); - Object.assign(ctx, context); - if (context.target) ctx.target = this.__resolveTarget(document.body, context.target); - Object.assign(ctx.request, {action: path, method: verb.toUpperCase()}); - if (context.headers) Object.assign(ctx.request.headers, context.headers); + sourceElement ||= targetElement || document.body; + + let ctx = this.__createRequestContext(sourceElement, event || {}, { + ...contextOverrides, + swap: { + ...(targetElement && {target: targetElement}), + ...(select !== undefined && {select}), + ...(selectOOB !== undefined && {selectOOB}), + ...(transition !== undefined && {transition}), + ...this.__parseSwapSpec(swap) + }, + request: { + ...request, + action: path, + method: verb.toUpperCase(), + headers: { + ...request?.headers, + ...headers + } + } + }); return this.__handleTriggerEvent(ctx); } @@ -1658,13 +1804,8 @@ var htmx = (() => { } __resolveHistoryAction(ctx) { - let {sourceElement, push, replace, hx, response} = ctx; - - // allow response headers to override history action - if (hx?.pushurl || hx?.replaceurl) { // HX-Push-Url, HX-Replace-Url - push = hx.pushurl; - replace = hx.replaceurl; - } + let {sourceElement, response} = ctx; + let {pushUrl: push, replaceUrl: replace} = ctx.actions; // if this is a boosted element, default to pushing if (push == null && replace == null && this.__isBoosted(sourceElement)) { @@ -1679,7 +1820,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 : ''); @@ -1689,24 +1830,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) { @@ -2247,21 +2370,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 = "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 statusValue = this.__attributeValue(ctx.sourceElement, "hx-status:" + pattern); - if (statusValue) { - HCON.merge(statusValue, ctx); - 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/src/scripts/upgrade-check.py b/src/scripts/upgrade-check.py index a91f939c8..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", @@ -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/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 a310f663a..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 - // detail.ctx.text has response text - // detail.ctx.response has status, headers + // 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 }, htmx_before_swap: (elt, detail) => { @@ -88,10 +92,18 @@ 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 + +| 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 +176,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 | @@ -177,14 +190,24 @@ 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 - target, // Target element for swap - swap, // Swap strategy string - select, // hx-select value - selectOOB, // hx-select-oob value - push, // hx-push-url value - replace, // hx-replace-url value - transition, // Whether view transitions enabled + swap: { + content, // Response text (after request) + target, // Target element + style, // Swap strategy + select, // hx-select value + selectOOB, // hx-select-oob value + transition, // Whether view transitions are enabled + ...modifiers, + }, + actions: { + 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 method, // HTTP method (GET, POST, etc.) @@ -202,14 +225,14 @@ The context object available via `detail.ctx` in hook callbacks: status, // HTTP status code headers, // Response headers }, - text, // Response text (after request) - hx, // Parsed HX-* response headers } ``` **Modifying the request:** Change `detail.ctx.request` properties in `htmx_config_request` or `htmx_before_request`. -**Modifying the response:** Change `detail.ctx.text` 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)`. **Overriding fetch:** Set `detail.ctx.fetch` to a function returning a Response or Promise. @@ -326,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.text` | +| `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 2d46e1cde..d8f2c5fc9 100644 --- a/src/skills/htmx-guidance.md +++ b/src/skills/htmx-guidance.md @@ -211,11 +211,17 @@ 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:** + +- `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 @@ -240,8 +246,10 @@ Events expose `detail.ctx` with the full request context: document.body.addEventListener('htmx:config:request', (evt) => { let ctx = evt.detail.ctx; // ctx.sourceElement -- element that triggered request - // ctx.target -- swap target element - // ctx.swap -- hx-swap value + // 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 @@ -444,7 +452,7 @@ htmx.process(element) // Initialize htmx on dynamic c htmx.find("closest .container") // Extended CSS selector query htmx.findAll(".items") // Find all matching htmx.trigger(elt, "myEvent", {detail: ...}) // Fire custom event -htmx.swap(ctx) // Manual swap +htmx.swap(content, target, options) // Manual swap htmx.timeout(1000) // Promise that resolves after delay htmx.live.take(elt, "active", ".tab") // Take class — provided by hx-live htmx.live.forEvent(elt, "click", 5000) // Race events/timeouts — provided by hx-live @@ -627,7 +635,7 @@ Most error events (`htmx:sendError`, `htmx:swapError`, `htmx:targetError`, `htmx | `htmx.addClass()`, `htmx.removeClass()`, etc. | Native DOM methods | Removed; use `element.classList` | | `htmx.off()` | `removeEventListener()` | Removed; use native | | `htmx.remove()` | `element.remove()` | Removed; use native | -| `htmx.swap(target, content, spec)` | `htmx.swap(ctx)` | Signature changed | +| `htmx.swap(target, content, spec)` | `htmx.swap(content, target, options)` | Signature changed | htmx 4 adds: `htmx.timeout()`. Logging now goes directly to `console.error` / `console.warn` / `console.log` (gated by `config.logAll` for events). `htmx.takeClass()` is **removed**; use `htmx.live.take()` (provided by the `hx-live` extension) or the unprefixed `take` helper inside expression scope. The `hx-live` extension also exposes `htmx.live.forEvent()`, `htmx.live.nextFrame()`, `htmx.live.q()`, `htmx.live.debounce()`, `htmx.live.refresh()`. diff --git a/src/skills/htmx-upgrade-from-htmx2.md b/src/skills/htmx-upgrade-from-htmx2.md index 21327c20c..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,8 +270,8 @@ htmx.registerExtension('my-ext', { htmx_config_request(elt, detail) { // detail.ctx has request context }, - htmx_after_request(elt, detail) { - // detail.ctx.text has response text + 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/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/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/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/test.html b/test/test.html index 6b126751a..a212196d0 100644 --- a/test/test.html +++ b/test/test.html @@ -90,13 +90,12 @@ - + - - + - + diff --git a/test/tests/attributes/hx-boost.js b/test/tests/attributes/hx-boost.js index 28f16e24a..8c905af33 100644 --- a/test/tests/attributes/hx-boost.js +++ b/test/tests/attributes/hx-boost.js @@ -130,6 +130,30 @@ describe('hx-boost attribute', async function() { should.equal(document.querySelector('#a1'), null) }) + it('boost target config overrides hx-swap target modifier', async function() { + mockResponse('GET', '/test', 'New Content') + createProcessedHTML('
Swap Target
Boost Target
Click') + find('a').click() + await forRequest() + find('#boost-target').innerHTML.should.equal('New Content') + find('#swap-target').innerHTML.should.equal('Swap Target') + }) + + it('boost transition config overrides hx-swap transition modifier', async function() { + if (!document.startViewTransition) { + this.skip() + return + } + + let transitioned = false + htmx.on('htmx:before:viewTransition', () => transitioned = true) + mockResponse('GET', '/test', 'New Content') + createProcessedHTML('
Click') + find('a').click() + await forRequest() + transitioned.should.be.true + }) + it('hx-boost true still works as before', async function() { mockResponse('GET', '/test', 'Clicked') createProcessedHTML('
Click Me') diff --git a/test/tests/attributes/hx-status.js b/test/tests/attributes/hx-status.js index 0c825ef11..fbdbca70d 100644 --- a/test/tests/attributes/hx-status.js +++ b/test/tests/attributes/hx-status.js @@ -104,4 +104,13 @@ describe('hx-status attribute tests', function() { assert.equal(find('#target').innerText, 'Original'); }); + it('applies flat swap modifiers', async function() { + mockResponse('GET', '/test', '
Updated
', {status: 500}); + createProcessedHTML('
Original
Old
'); + find('button').click(); + await forRequest(); + assert.equal(find('#target').innerText, 'Original'); + assert.equal(find('#oob').innerText, 'Updated'); + }); + }); diff --git a/test/tests/attributes/hx-swap.js b/test/tests/attributes/hx-swap.js index 97234d843..6821c1603 100644 --- a/test/tests/attributes/hx-swap.js +++ b/test/tests/attributes/hx-swap.js @@ -10,15 +10,15 @@ describe('hx-swap modifiers', function() { it('properly parses various swap specifications', function() { assert.equal(htmx.__parseSwapSpec('innerHTML').style, 'innerHTML') - assert.equal(htmx.__parseSwapSpec('innerHTML').swap, undefined) - assert.equal(htmx.__parseSwapSpec('innerHTML swap:10').swap, '10') - assert.equal(htmx.__parseSwapSpec('innerHTML swap:0').swap, '0') - assert.equal(htmx.__parseSwapSpec('innerHTML swap:0ms').swap, '0ms') + assert.equal(htmx.__parseSwapSpec('innerHTML').swapDelay, undefined) + assert.equal(htmx.__parseSwapSpec('innerHTML swap:10').swapDelay, '10') + assert.equal(htmx.__parseSwapSpec('innerHTML swap:0').swapDelay, '0') + assert.equal(htmx.__parseSwapSpec('innerHTML swap:0ms').swapDelay, '0ms') - assert.equal(htmx.__parseSwapSpec('swap:10').style, 'innerHTML') - assert.equal(htmx.__parseSwapSpec('swap:10').swap, '10') - assert.equal(htmx.__parseSwapSpec('swap:0').swap, '0') - assert.equal(htmx.__parseSwapSpec('swap:0s').swap, '0s') + assert.equal(htmx.__parseSwapSpec('swap:10').style, undefined) + assert.equal(htmx.__parseSwapSpec('swap:10').swapDelay, '10') + assert.equal(htmx.__parseSwapSpec('swap:0').swapDelay, '0') + assert.equal(htmx.__parseSwapSpec('swap:0s').swapDelay, '0s') assert.equal(htmx.__parseSwapSpec('transition:true').transition, true) assert.equal(htmx.__parseSwapSpec('strip:true').strip, true) diff --git a/test/tests/end2end/basic-history.js b/test/tests/end2end/basic-history.js index 9cb97a749..de43413e2 100644 --- a/test/tests/end2end/basic-history.js +++ b/test/tests/end2end/basic-history.js @@ -183,12 +183,11 @@ describe('outerSync swap into document.body', function() { it('syncs body attributes from response and replaces children', async function() { document.body.setAttribute('data-original', 'yes'); - await htmx.swap({ - target: document.body, - swap: 'outerSync', - text: '
', - sourceElement: document.body - }); + await htmx.swap( + '
', + document.body, + { style: 'outerSync', source: document.body } + ) document.body.classList.contains('injected').should.equal(true); document.body.getAttribute('data-test-x').should.equal('1'); (document.body.getAttribute('data-original') === null).should.equal(true); @@ -197,12 +196,11 @@ describe('outerSync swap into document.body', function() { it('outerHTML on body auto-upgrades to outerSync and syncs attributes', async function() { document.body.setAttribute('data-original', 'yes'); - await htmx.swap({ - target: document.body, - swap: 'outerHTML', - text: '
', - sourceElement: document.body - }); + await htmx.swap( + '
', + document.body, + { style: 'outerHTML', source: document.body } + ) document.body.classList.contains('injected').should.equal(true); document.body.getAttribute('data-test-x').should.equal('1'); (document.body.getAttribute('data-original') === null).should.equal(true); @@ -217,12 +215,11 @@ describe('full-page response strip auto-upgrade', function() { it('innerHTML on full-page response strips body wrapper', async function() { playground().innerHTML = '
old
'; - await htmx.swap({ - target: '#target', - swap: 'innerHTML', - text: 'new', - sourceElement: playground() - }); + await htmx.swap( + 'new', + '#target', + { style: 'innerHTML', source: playground() } + ) let target = playground().querySelector('#target'); target.should.not.equal(null); (target.querySelector('body') === null).should.equal(true); @@ -232,12 +229,11 @@ describe('full-page response strip auto-upgrade', function() { it('innerMorph on full-page response strips body wrapper', async function() { playground().innerHTML = '
old
'; - await htmx.swap({ - target: '#target', - swap: 'innerMorph', - text: 'new', - sourceElement: playground() - }); + await htmx.swap( + 'new', + '#target', + { style: 'innerMorph', source: playground() } + ) let target = playground().querySelector('#target'); target.should.not.equal(null); (target.querySelector('body') === null).should.equal(true); @@ -246,12 +242,11 @@ describe('full-page response strip auto-upgrade', function() { it('beforeend on full-page response strips body wrapper', async function() { playground().innerHTML = '
old
'; - await htmx.swap({ - target: '#target', - swap: 'beforeend', - text: 'added', - sourceElement: playground() - }); + await htmx.swap( + 'added', + '#target', + { style: 'beforeend', source: playground() } + ) let target = playground().querySelector('#target'); (target.querySelector('body') === null).should.equal(true); target.querySelector('#orig').should.not.equal(null); @@ -260,12 +255,11 @@ describe('full-page response strip auto-upgrade', function() { it('partial response is unaffected by strip auto-upgrade', async function() { playground().innerHTML = '
old
'; - await htmx.swap({ - target: '#target', - swap: 'innerHTML', - text: 'partial', - sourceElement: playground() - }); + await htmx.swap( + 'partial', + '#target', + { style: 'innerHTML', source: playground() } + ) let target = playground().querySelector('#target'); target.querySelector('#partial-child').should.not.equal(null); target.querySelector('#partial-child').textContent.should.equal('partial'); @@ -285,12 +279,11 @@ describe('outerSync processes inserted nodes correctly', function() { let target = createProcessedHTML('

old content

'); - await htmx.swap({ - target: '#sync-target', - swap: 'outerSync', - text: '
loading...
', - sourceElement: target - }); + await htmx.swap( + '
loading...
', + '#sync-target', + { style: 'outerSync', source: target } + ) // The load trigger should have fired and issued a request await forRequest(); @@ -303,12 +296,11 @@ describe('outerSync processes inserted nodes correctly', function() { it('initializes htmx attributes on nodes inserted via outerSync', async function() { let target = createProcessedHTML('

old

'); - await htmx.swap({ - target: '#sync-target', - swap: 'outerSync', - text: '
', - sourceElement: target - }); + await htmx.swap( + '
', + '#sync-target', + { style: 'outerSync', source: target } + ) let btn = document.getElementById('btn'); btn.should.not.equal(null); diff --git a/test/tests/ext/hx-csp.js b/test/tests/ext/hx-csp.js index 99a0cbd04..12a286deb 100644 --- a/test/tests/ext/hx-csp.js +++ b/test/tests/ext/hx-csp.js @@ -37,19 +37,19 @@ describe('hx-csp extension', function() { cleanupTest() }) - // CSP rewrites response text before core builds the swap fragment. - it('scrubs the page nonce from ctx.text', async function() { + // CSP rewrites canonical response content before core builds the swap fragment. + it('scrubs the page nonce from ctx.swap.content', async function() { mockResponse('GET', '/test', '') let button = createProcessedHTML('
') - let responseText - button.addEventListener('htmx:after:request', event => { - responseText = event.detail.ctx.text + let responseContent + button.addEventListener('htmx:after:response', event => { + responseContent = event.detail.ctx.swap.content }) button.click() await forRequest() - assert.notInclude(responseText, 'test-nonce') + assert.notInclude(responseContent, 'test-nonce') assert.isFalse(find('#target script').hasAttribute('nonce')) }) }) diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 73b2fcf89..c4d2408de 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1962,12 +1962,11 @@ describe('hx-live extension', function () { // outerMorph the element with a changed hx-live expression — morph will // detect the attribute change, cleanup the old registration, and re-process. - await htmx.swap({ - target: '#wrap', - text: '
', - swap: 'outerMorph', - sourceElement: playground() - }); + await htmx.swap( + '
', + '#wrap', + { style: 'outerMorph', source: playground() } + ) await htmx.timeout(5); // Should have incremented by 10 (new code), not 1 (old code). @@ -1984,12 +1983,11 @@ describe('hx-live extension', function () { await htmx.timeout(5); // outerMorph to a version with hx-live removed — morph cleans up the old fn. - await htmx.swap({ - target: '#wrap', - text: '
', - swap: 'outerMorph', - sourceElement: playground() - }); + await htmx.swap( + '
', + '#wrap', + { style: 'outerMorph', source: playground() } + ) let countAfterMorph = window.__morphRemovedCount; // Trigger a recompute cycle — the old fn should no longer be in fns. @@ -2008,12 +2006,11 @@ describe('hx-live extension', function () { await htmx.timeout(5); playground().querySelector('#o').textContent.should.equal('original'); - await htmx.swap({ - target: '#wrap', - text: '
', - swap: 'outerMorph', - sourceElement: playground() - }); + await htmx.swap( + '
', + '#wrap', + { style: 'outerMorph', source: playground() } + ) await htmx.timeout(5); playground().querySelector('#o').textContent.should.equal('updated'); @@ -2024,12 +2021,11 @@ describe('hx-live extension', function () { htmx.process(playground()); await htmx.timeout(5); - await htmx.swap({ - target: '#wrap', - text: '
', - swap: 'outerMorph', - sourceElement: playground() - }); + await htmx.swap( + '
', + '#wrap', + { style: 'outerMorph', source: playground() } + ) await htmx.timeout(5); playground().querySelector('#o').dataset.extra.should.equal('added'); @@ -2041,12 +2037,11 @@ describe('hx-live extension', function () { htmx.process(playground()); await htmx.timeout(5); - await htmx.swap({ - target: '#wrap', - text: '
', - swap: 'outerMorph', - sourceElement: playground() - }); + await htmx.swap( + '
', + '#wrap', + { style: 'outerMorph', source: playground() } + ) let countAfterMorph = window.__morphAttrCount; document.body.setAttribute('data-morph-attr-trigger', '1'); @@ -2066,12 +2061,11 @@ describe('hx-live extension', function () { // 3 morph cycles with identical content — each should cleanup and re-register once. for (let i = 0; i < 3; i++) { - await htmx.swap({ - target: '#wrap', - text: '
', - swap: 'outerMorph', - sourceElement: playground() - }); + await htmx.swap( + '
', + '#wrap', + { style: 'outerMorph', source: playground() } + ) await htmx.timeout(5); } diff --git a/test/tests/ext/hx-multipart.js b/test/tests/ext/hx-multipart.js new file mode 100644 index 000000000..b01285bfc --- /dev/null +++ b/test/tests/ext/hx-multipart.js @@ -0,0 +1,615 @@ +describe('hx-multipart extension', function() { + + let extBackup; + let configExtensions; + let partsDescriptor; + + before(async () => { + extBackup = backupExtensions(); + configExtensions = htmx.config.extensions; + partsDescriptor = Object.getOwnPropertyDescriptor(Response.prototype, 'parts'); + clearExtensions(); + + htmx.config.extensions = 'hx-multipart'; + htmx.__approvedExt = 'hx-multipart'; + + let script = document.createElement('script'); + script.src = '../src/ext/hx-multipart.js'; + await new Promise(resolve => { + script.onload = resolve; + document.head.appendChild(script); + }); + + if (!htmx.__registeredExt.has('hx-multipart')) { + throw new Error('hx-multipart extension failed to register - check approval'); + } + }); + + after(() => { + restoreExtensions(extBackup); + htmx.config.extensions = configExtensions; + if (partsDescriptor) { + Object.defineProperty(Response.prototype, 'parts', partsDescriptor); + } else { + delete Response.prototype.parts; + } + }); + + beforeEach(function() { + setupTest(); + }); + + afterEach(function() { + cleanupTest(); + }); + + it('installs Response.prototype.parts', function() { + assert.equal(typeof Response.prototype.parts, 'function'); + }); + + it('closes Content-Length bodies before the next boundary arrives', async function() { + let controller; + let response = new Response(new ReadableStream({ + start(value) { + controller = value; + } + }), { + headers: {'Content-Type': 'multipart/mixed; boundary=updates'} + }); + let encode = value => new TextEncoder().encode(value); + + controller.enqueue(encode('--updates\r\nContent-Length: 5\r\n\r\nhello')); + let iterator = response.parts()[Symbol.asyncIterator](); + let {value: part} = await iterator.next(); + let result = await Promise.race([ + part.text().then(value => ({value})), + htmx.timeout(20).then(() => ({pending: true})) + ]); + + controller.enqueue(encode('\r\n--updates--')); + controller.close(); + await iterator.next(); + + assert.deepEqual(result, {value: 'hello'}); + }); + + it('appends multipart types to an existing Accept header', async function() { + mockResponse('GET', '/test', 'OK'); + let button = createProcessedHTML(''); + + 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/test/tests/ext/hx-sse.js b/test/tests/ext/hx-sse.js index f324cd884..d58af64b0 100644 --- a/test/tests/ext/hx-sse.js +++ b/test/tests/ext/hx-sse.js @@ -62,6 +62,26 @@ describe('hx-sse SSE extension', function() { stream.close(); }); + // SSE messages pass canonical swap state through the positional swap API. + it('uses canonical swap state for messages', async function() { + const stream = mockStreamResponse('/canonical'); + createProcessedHTML('
'); + let messageSwap; + onDoc('htmx:before:swap', event => messageSwap = event.detail.ctx.swap); + + find('button').click(); + await htmx.timeout(1); + stream.send('message'); + await waitForEvent('htmx:after:sse:message'); + + assert.equal(messageSwap.content, 'message'); + assert.equal(messageSwap.target.id, 'target'); + assert.equal(messageSwap.style, 'innerHTML'); + assert.isFalse(messageSwap.transition); + assert.isFalse(messageSwap.swapEmpty); + stream.close(); + }); + it('continuous stream reconnects with exponential backoff', async function() { const stream = mockStreamResponse('/reconnect'); createProcessedHTML(''); diff --git a/test/tests/ext/hx-ws.js b/test/tests/ext/hx-ws.js index 0821f7670..d87021535 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('', target, {style: 'innerHTML', source: 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('', target, {style: 'delete', source: 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('', target, {style: 'delete', source: 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.swap.target, result); + assert.equal(mainTask.target, result); + assert.equal(mainTask.swapSpec.style, 'beforeend'); + assert.equal(mainTask.swapSpec.swapDelay, '10ms'); + assert.equal(mainTask.swapSpec.settleDelay, 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,11 +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) - await htmx.swap({ - text: '
Replaced — no hx-ws:connect
', - target: document.getElementById('outer'), - swap: 'innerHTML' - }); + let target = document.getElementById('outer'); + await htmx.swap( + '
Replaced — no hx-ws:connect
', + target, + {style: 'innerHTML', source: target} + ); await htmx.timeout(50); assert.equal(ws.readyState, mockWebSocket.CLOSED, 'WebSocket should be closed after element removal'); @@ -1929,15 +2184,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 - await htmx.swap({ - text: '
No more WS
', - target: document.getElementById('outer'), - swap: 'innerHTML' - }); + let target = document.getElementById('outer'); + await htmx.swap( + '
No more WS
', + target, + {style: 'innerHTML', source: target} + ); await htmx.timeout(250); // Reconnect timer should have fired but found no element, so no new socket @@ -2047,53 +2303,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 +2331,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 +2385,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('', target, {style: 'innerHTML', source: target}); await htmx.timeout(50); assert.isTrue(ac.signal.aborted, 'AbortController should be aborted on close'); diff --git a/test/tests/unit/__createRequestContext.js b/test/tests/unit/__createRequestContext.js new file mode 100644 index 000000000..3233db630 --- /dev/null +++ b/test/tests/unit/__createRequestContext.js @@ -0,0 +1,126 @@ +describe('__createRequestContext unit tests', function() { + + beforeEach(function() { + setupTest() + }) + + afterEach(function() { + cleanupTest() + }) + + it('constructs canonical swap and action state', function() { + createProcessedHTML('
') + let source = find('button') + let ctx = htmx.__createRequestContext(source, new Event('click')) + + assert.deepEqual(Object.keys(ctx.swap), [ + 'content', + 'target', + 'style', + 'select', + 'selectOOB', + 'transition', + 'settleDelay' + ]) + assert.equal(ctx.swap.content, 'Initial content') + assert.equal(ctx.swap.target?.id, 'target') + assert.equal(ctx.swap.style, 'outerHTML') + assert.equal(ctx.swap.select, '#selected') + assert.equal(ctx.swap.selectOOB, '#oob') + assert.isTrue(ctx.swap.transition) + assert.equal(ctx.swap.settleDelay, '200ms') + assert.deepEqual(ctx.actions, { + pushUrl: '/pushed', + replaceUrl: '/replaced' + }) + }) + + // Modifier-only hx-swap currently treats the full default string as its style. + // Layer defaults separately to preserve both the style and its modifiers. + it('layers modifier-only hx-swap over global swap defaults', function() { + let originalDefaultSwap = htmx.config.defaultSwap + try { + htmx.config.defaultSwap = 'outerHTML settle:200ms' + let source = createProcessedHTML('') + let ctx = htmx.__createRequestContext(source, new Event('click')) + + assert.equal(ctx.swap.style, 'outerHTML') + assert.equal(ctx.swap.settleDelay, '200ms') + assert.isTrue(ctx.swap.transition) + } finally { + htmx.config.defaultSwap = originalDefaultSwap + } + }) + + // An explicit style currently replaces the default string and drops its modifiers. + // Layer hx-swap over parsed defaults to retain unspecified global fields. + it('preserves global swap modifiers when hx-swap overrides style', function() { + let originalDefaultSwap = htmx.config.defaultSwap + try { + htmx.config.defaultSwap = 'innerHTML settle:200ms' + let source = createProcessedHTML('') + let ctx = htmx.__createRequestContext(source, new Event('click')) + + assert.equal(ctx.swap.style, 'outerHTML') + assert.equal(ctx.swap.settleDelay, '200ms') + } finally { + htmx.config.defaultSwap = originalDefaultSwap + } + }) + + it('accepts structured global swap defaults', function() { + let originalDefaultSwap = htmx.config.defaultSwap + try { + htmx.config.defaultSwap = {style: 'outerHTML', settleDelay: '200ms'} + let source = createProcessedHTML('') + let ctx = htmx.__createRequestContext(source, new Event('click')) + + assert.equal(ctx.swap.style, 'outerHTML') + assert.equal(ctx.swap.settleDelay, '200ms') + } finally { + htmx.config.defaultSwap = originalDefaultSwap + } + }) + + // Post-construction AJAX mutation leaves derived headers stale. + // Canonical overrides apply before target and header derivation. + it('applies canonical overrides during construction', function() { + createProcessedHTML('
') + let source = find('button') + let target = find('#override-target') + let ctx = htmx.__createRequestContext(source, new Event('click'), { + swap: { + target, + select: '#selection' + }, + actions: { + pushUrl: '/override' + } + }) + + assert.equal(ctx.swap.target?.id, 'override-target') + assert.equal(ctx.actions.pushUrl, '/override') + assert.equal(ctx.request.headers['HX-Target'], 'div#override-target') + assert.equal(ctx.request.headers['HX-Request-Type'], 'full') + }) + + // Replacing request options currently discards generated defaults. + // Merge plain request records while replacing opaque platform values. + it('merges request overrides with generated defaults', function() { + let source = createProcessedHTML('') + let signal = new AbortController().signal + let ctx = htmx.__createRequestContext(source, new Event('click'), { + request: { + signal, + headers: { + 'X-Test': 'true' + } + } + }) + + assert.equal(ctx.request.headers['HX-Request'], 'true') + assert.equal(ctx.request.headers['X-Test'], 'true') + assert.strictEqual(ctx.request.signal, signal) + assert.equal(ctx.request.mode, htmx.config.mode) + }) +}) 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/__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/__handleHistoryUpdate.js b/test/tests/unit/__handleHistoryUpdate.js deleted file mode 100644 index ddc024b2c..000000000 --- a/test/tests/unit/__handleHistoryUpdate.js +++ /dev/null @@ -1,93 +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, - push: 'false', - replace: '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, - push: '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, - replace: '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, - push: '/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, - push: '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/__handleStatusCodes.js b/test/tests/unit/__handleStatusCodes.js deleted file mode 100644 index 4fb778a7d..000000000 --- a/test/tests/unit/__handleStatusCodes.js +++ /dev/null @@ -1,173 +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: 'innerHTML', - response: { - raw: { status: 204 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap, 'none') - }) - - it('sets swap to none for 304 status', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'innerHTML', - response: { - raw: { status: 304 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap, 'none') - }) - - it('does not change swap for 200 status', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'innerHTML', - response: { - raw: { status: 200 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap, 'innerHTML') - }) - - it('applies hx-status:404 override', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'innerHTML', - response: { - raw: { status: 404 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap, 'outerHTML') - }) - - it('applies hx-status:4xx pattern match', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'innerHTML', - response: { - raw: { status: 403 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap, 'delete') - }) - - it('applies hx-status:5xx pattern match', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'innerHTML', - response: { - raw: { status: 500 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap, 'none') - }) - - it('prefers exact match over pattern match', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'innerHTML', - response: { - raw: { status: 404 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap, 'outerHTML') - }) - - it('parses target modifier in hx-status value', function () { - createProcessedHTML('
') - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'outerHTML', - target: div, - response: { - raw: { status: 404 } - } - } - - htmx.__handleStatusCodes(ctx) - - // Object.assign sets both swap and target on ctx - assert.equal(ctx.swap, 'innerHTML') - assert.equal(ctx.target, '#error-target') - }) - - it('can set multiple ctx properties with hx-status', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'innerHTML', - select: null, - push: 'true', - response: { - raw: { status: 500 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.swap, 'none') - assert.equal(ctx.select, '#error') - assert.equal(ctx.push, false) - }) - - it('hx-status can override any ctx property', function () { - let div = createProcessedHTML('
') - let ctx = { - sourceElement: div, - swap: 'innerHTML', - target: '#main', - transition: true, - response: { - raw: { status: 404 } - } - } - - htmx.__handleStatusCodes(ctx) - - assert.equal(ctx.target, '#alt') - assert.equal(ctx.swap, 'outerHTML') - assert.equal(ctx.transition, false) - }) - -}); diff --git a/test/tests/unit/__handleTriggerEvent.js b/test/tests/unit/__handleTriggerEvent.js index d53cf8304..9e109789e 100644 --- a/test/tests/unit/__handleTriggerEvent.js +++ b/test/tests/unit/__handleTriggerEvent.js @@ -43,12 +43,12 @@ describe('__handleTriggerEvent unit tests', function() { assert.isTrue(evt.defaultPrevented) }) - it('resolves target from ctx.target', async function () { + it('resolves target from ctx.swap.target', async function () { createProcessedHTML('
') let button = document.querySelector('button') let ctx = htmx.__createRequestContext(button, new Event('click')) await htmx.__handleTriggerEvent(ctx) - assert.equal(ctx.target.id, 'target') + assert.equal(ctx.swap.target.id, 'target') }) it('collects form data from element', async function () { diff --git a/test/tests/unit/__issueRequest.js b/test/tests/unit/__issueRequest.js index 6d9850783..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,37 +251,19 @@ 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) }) - 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/test/tests/unit/__parseSwapSpec.js b/test/tests/unit/__parseSwapSpec.js index 407a9ef57..cb0bc0530 100644 --- a/test/tests/unit/__parseSwapSpec.js +++ b/test/tests/unit/__parseSwapSpec.js @@ -16,10 +16,11 @@ describe('__parseSwapSpec unit tests', function() { assert.equal(htmx.__parseSwapSpec('after').style, 'afterend') }) - it('parses swap delay modifier', function () { - let spec = htmx.__parseSwapSpec('innerHTML swap:100ms') + it('parses delay modifiers', function () { + let spec = htmx.__parseSwapSpec('innerHTML swap:100ms settle:50ms') assert.equal(spec.style, 'innerHTML') - assert.equal(spec.swap, '100ms') + assert.equal(spec.swapDelay, '100ms') + assert.equal(spec.settleDelay, '50ms') }) it('parses transition modifier', function () { @@ -75,19 +76,27 @@ describe('__parseSwapSpec unit tests', function() { it('parses multiple modifiers', function () { let spec = htmx.__parseSwapSpec('innerHTML swap:100ms transition:true') assert.equal(spec.style, 'innerHTML') - assert.equal(spec.swap, '100ms') + assert.equal(spec.swapDelay, '100ms') assert.equal(spec.transition, true) }) - it('uses default swap when empty', function () { - let spec = htmx.__parseSwapSpec('') - assert.equal(spec.style, htmx.config.defaultSwap) + // The parser currently hides config.defaultSwap inside otherwise partial results. + // Leave defaults to callers so parsed swap layers compose predictably. + it('does not apply an implicit style', function () { + assert.deepEqual(htmx.__parseSwapSpec(''), {}) + assert.deepEqual(htmx.__parseSwapSpec('transition:true'), {transition: true}) + }) + + it('accepts structured swap fields', function () { + let swap = {style: 'outerHTML', settleDelay: '200ms'} + assert.deepEqual(htmx.__parseSwapSpec(swap), swap) + assert.notStrictEqual(htmx.__parseSwapSpec(swap), swap) }) it('parses legacy style names with modifiers', function () { let spec = htmx.__parseSwapSpec('prepend swap:10ms') assert.equal(spec.style, 'afterbegin') - assert.equal(spec.swap, '10ms') + assert.equal(spec.swapDelay, '10ms') }) }); \ No newline at end of file diff --git a/test/tests/unit/__resolveHistoryAction.js b/test/tests/unit/__resolveHistoryAction.js index 3983871a0..5df54ea8c 100644 --- a/test/tests/unit/__resolveHistoryAction.js +++ b/test/tests/unit/__resolveHistoryAction.js @@ -10,13 +10,13 @@ describe('__resolveHistoryAction unit tests', function() { it('returns null when no push or replace', function() { let div = createProcessedHTML('
') - let ctx = { sourceElement: div } + let ctx = { sourceElement: div, actions: {} } assert.isNull(htmx.__resolveHistoryAction(ctx)) }) it('returns push with path from hx-push-url attribute', function() { let div = createProcessedHTML('
') - let ctx = { sourceElement: div, push: '/pushed' } + let ctx = { sourceElement: div, actions: { pushUrl: '/pushed' } } let action = htmx.__resolveHistoryAction(ctx) assert.equal(action.type, 'push') assert.equal(action.path, '/pushed') @@ -24,51 +24,35 @@ describe('__resolveHistoryAction unit tests', function() { it('returns replace with path from hx-replace-url attribute', function() { let div = createProcessedHTML('
') - let ctx = { sourceElement: div, replace: '/replaced' } + let ctx = { sourceElement: div, actions: { replaceUrl: '/replaced' } } let action = htmx.__resolveHistoryAction(ctx) assert.equal(action.type, 'replace') assert.equal(action.path, '/replaced') }) - it('server HX-Push-Url header overrides attribute', function() { - let div = createProcessedHTML('
') - let ctx = { sourceElement: div, push: '/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, replace: '/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, push: 'false' } + let ctx = { sourceElement: div, actions: { pushUrl: 'false' } } assert.isNull(htmx.__resolveHistoryAction(ctx)) }) it('replace "false" returns null', function() { let div = createProcessedHTML('
') - let ctx = { sourceElement: div, replace: 'false' } + let ctx = { sourceElement: div, actions: { replaceUrl: 'false' } } 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, 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, 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') @@ -78,7 +62,7 @@ describe('__resolveHistoryAction unit tests', function() { let div = createProcessedHTML('
') let ctx = { sourceElement: div, - push: 'true', + actions: { pushUrl: 'true' }, response: { raw: { url: 'http://localhost/resolved' } }, request: { action: '/fallback' } } @@ -91,7 +75,7 @@ describe('__resolveHistoryAction unit tests', function() { let div = createProcessedHTML('
') let ctx = { sourceElement: div, - push: 'true', + actions: { pushUrl: 'true' }, response: { raw: {} }, request: { action: '/fallback' } } @@ -102,7 +86,7 @@ describe('__resolveHistoryAction unit tests', function() { it('push takes precedence over replace', function() { let div = createProcessedHTML('
') - let ctx = { sourceElement: div, push: '/push-path', replace: '/replace-path' } + let ctx = { sourceElement: div, actions: { pushUrl: '/push-path', replaceUrl: '/replace-path' } } let action = htmx.__resolveHistoryAction(ctx) assert.equal(action.type, 'push') assert.equal(action.path, '/push-path') 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, {}) + }) + +}); diff --git a/test/tests/unit/__runActions.js b/test/tests/unit/__runActions.js new file mode 100644 index 000000000..6d50df5f1 --- /dev/null +++ b/test/tests/unit/__runActions.js @@ -0,0 +1,225 @@ +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') + }) + + it('HX-Location pushes history by default', async function () { + mockResponse('GET', '/test', 'ignored', {headers: {'HX-Location': 'path:/location-path, target:#dest'}}) + mockResponse('GET', '/location-path', 'Located') + createProcessedHTML('
') + + find('#loc-source').click() + await htmx.timeout(50) + + assert.equal(find('#dest').textContent, 'Located') + assert.include(window.location.href, '/location-path') + }) + + it('HX-Location honors replace', async function () { + mockResponse('GET', '/test', 'ignored', {headers: {'HX-Location': 'path:/location-replaced, target:#dest, replace:/location-replaced'}}) + mockResponse('GET', '/location-replaced', 'Located') + createProcessedHTML('
') + + find('#loc-source').click() + await htmx.timeout(50) + + assert.equal(find('#dest').textContent, 'Located') + assert.include(window.location.href, '/location-replaced') + assert.equal(history.state?.htmx, true) + }) + +}); diff --git a/test/tests/unit/ajax.js b/test/tests/unit/ajax.js index cb3ab30c7..3c163a6f4 100644 --- a/test/tests/unit/ajax.js +++ b/test/tests/unit/ajax.js @@ -22,6 +22,43 @@ describe('ajax() unit Tests', function() { assert.equal(div.innerHTML, 'foo!'); }); + it('ajax derives request headers from the target option', async function() { + mockResponse('GET', '/test', 'foo!'); + createProcessedHTML('
'); + const source = document.querySelector('#source'); + let request; + source.addEventListener('htmx:config:request', (event) => { + request = event.detail.ctx.request; + }); + + await htmx.ajax('GET', '/test', { + source, + target: '#target', + swap: 'innerHTML' + }); + + assert.equal(request.headers['HX-Source'], 'button#source'); + assert.equal(request.headers['HX-Target'], 'div#target'); + }); + + it('ajax derives request type from the swap selection', async function() { + mockResponse('GET', '/test', '
foo!
'); + createProcessedHTML('
'); + const source = document.querySelector('#source'); + let request; + source.addEventListener('htmx:config:request', (event) => { + request = event.detail.ctx.request; + }); + + await htmx.ajax('GET', '/test', { + source, + target: '#target', + swap: 'innerHTML select:#selected' + }); + + assert.equal(request.headers['HX-Request-Type'], 'full'); + }); + it('ajax rejects when target selector invalid', async function() { mockResponse('GET', '/test', 'foo!'); createProcessedHTML('
'); @@ -80,13 +117,46 @@ describe('ajax() unit Tests', function() { assert.equal(container.innerHTML, '

foo!

'); }); - it('ajax works with select option', async function() { + // Serialized swap input carries style, modifiers, and selection together. + it('ajax accepts a serialized swap specification', async function() { + mockResponse('GET', '/test', '
Selected
Ignored
'); + const div = createProcessedHTML('
'); + let finalSwap; + div.addEventListener('htmx:before:swap', event => finalSwap = event.detail.ctx.swap); + + await htmx.ajax('GET', '/test', { + target: '#target', + swap: 'innerHTML transition:false select:#selected' + }); + + assert.equal(div.innerText, 'Selected'); + assert.equal(finalSwap.style, 'innerHTML'); + assert.isFalse(finalSwap.transition); + assert.equal(finalSwap.select, '#selected'); + }); + + // Structured swap input uses canonical field names. + it('ajax accepts structured swap fields', async function() { + mockResponse('GET', '/test', '

Replaced

'); + const container = createProcessedHTML('
'); + + await htmx.ajax('GET', '/test', { + target: '#target', + swap: { style: 'outerHTML' } + }); + + assert.equal(container.innerHTML, '

Replaced

'); + }); + + it('ajax selects response content from swap fields', async function() { mockResponse('GET', '/test', '
foo
bar
'); const div = createProcessedHTML('
'); await htmx.ajax('GET', '/test', { target: '#target', - swap: 'innerHTML', - select: '#d2' + swap: { + style: 'innerHTML', + select: '#d2' + } }); assert.include(div.innerHTML, 'bar'); assert.notInclude(div.innerHTML, 'foo'); @@ -131,6 +201,33 @@ describe('ajax() unit Tests', function() { assert.equal(lastCall.request.headers['X-Custom'], 'test-value'); }); + it('ajax merges nested request options with request defaults', async function() { + mockResponse('GET', '/test', 'Done!'); + const div = createProcessedHTML('
'); + const controller = new AbortController(); + let request; + div.addEventListener('htmx:config:request', (event) => { + request = event.detail.ctx.request; + }); + + await htmx.ajax('GET', '/test', { + target: div, + swap: 'innerHTML', + request: { + credentials: 'include', + headers: { 'X-Custom': 'test-value' }, + signal: controller.signal + } + }); + + assert.equal(request.credentials, 'include'); + assert.equal(request.mode, 'same-origin'); + assert.isFunction(request.abort); + assert.strictEqual(request.signal, controller.signal); + assert.equal(request.headers['HX-Request'], 'true'); + assert.equal(request.headers['X-Custom'], 'test-value'); + }); + it('ajax collects form data from source element', async function() { mockResponse('POST', '/test', 'Submitted!'); createProcessedHTML('
'); @@ -193,13 +290,15 @@ describe('ajax() unit Tests', function() { assert.equal(div.innerHTML, 'final!'); }); - it('ajax respects transition option', async function() { + it('ajax respects transition in structured swap fields', async function() { mockResponse('GET', '/test', 'content!'); const div = createProcessedHTML('
'); await htmx.ajax('GET', '/test', { target: '#d1', - swap: 'innerHTML', - transition: false + swap: { + style: 'innerHTML', + transition: false + } }); assert.equal(div.innerHTML, 'content!'); }); @@ -235,6 +334,14 @@ describe('ajax() unit Tests', function() { assert.equal(div.innerHTML, 'deleted!'); }); + // AJAX accepts custom HTTP methods such as QUERY. + it('ajax custom verb', async function() { + mockResponse('QUERY', '/test', 'queried!'); + const div = createProcessedHTML('
'); + await htmx.ajax('QUERY', '/test', {target: '#d1', swap: 'innerHTML'}); + assert.equal(div.innerHTML, 'queried!'); + }); + it('ajax with event context', async function() { mockResponse('POST', '/test', 'clicked!'); createProcessedHTML('
'); diff --git a/test/tests/unit/morph.js b/test/tests/unit/morph.js index 0a8dc7dcb..f77bb1102 100644 --- a/test/tests/unit/morph.js +++ b/test/tests/unit/morph.js @@ -249,10 +249,10 @@ describe('Morph Swap Styles Tests', function() { it('does not re-execute identical script tags during morph', async function() { window._scriptMorphCount = 0; createProcessedHTML('
'); - await htmx.swap({target: '#target', text: '"}) + await htmx.swap("", "#test-playground") window.testVar.should.equal(1); delete window.testVar; }) @@ -139,7 +251,7 @@ describe('swap() unit tests', function() { it('executes script w/outerHTML', async function () { window.testVar = 0; createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"
", "swap" : "outerHTML"}) + await htmx.swap("
", "#d1", {style: "outerHTML"}) window.testVar.should.equal(2); delete window.testVar; }) @@ -147,7 +259,7 @@ describe('swap() unit tests', function() { it('executes script w/beforebegin', async function () { window.testVar = 0; createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"", "swap" : "beforebegin"}) + await htmx.swap("", "#d1", {style: "beforebegin"}) window.testVar.should.equal(3); delete window.testVar; }) @@ -155,7 +267,7 @@ describe('swap() unit tests', function() { it('executes script w/afterbegin', async function () { window.testVar = 0; createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"", "swap" : "afterbegin"}) + await htmx.swap("", "#d1", {style: "afterbegin"}) window.testVar.should.equal(4); delete window.testVar; }) @@ -163,7 +275,7 @@ describe('swap() unit tests', function() { it('executes script w/beforeend', async function () { window.testVar = 0; createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"", "swap" : "beforeend"}) + await htmx.swap("", "#d1", {style: "beforeend"}) window.testVar.should.equal(5); delete window.testVar; }) @@ -171,80 +283,80 @@ describe('swap() unit tests', function() { it('executes script w/afterend', async function () { window.testVar = 0; createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"", "swap" : "afterend"}) + await htmx.swap("", "#d1", {style: "afterend"}) window.testVar.should.equal(6); delete window.testVar; }) it('swaps oob content', async function () { createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"
Main
OOB
"}) + await htmx.swap("
Main
OOB
", "#d1") find('#d1').innerText.trim().should.equal("Main"); find('#d2').innerText.should.equal("OOB"); }) it('swaps oob with outerHTML', async function () { createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"
Main
OOB
"}) + await htmx.swap("
Main
OOB
", "#d1") find('#d2').innerText.should.equal("OOB"); }) it('swaps oob with innerHTML', async function () { createProcessedHTML("
Old
") - await htmx.swap({"target":"#d1", "text":"
Main
OOB
"}) + await htmx.swap("
Main
OOB
", "#d1") find('#d2').innerText.should.equal("OOB"); find('#d2').tagName.should.equal("DIV"); }) it('swaps partial with default target', async function () { - await htmx.swap({"target":"#test-playground", "text":"Partial"}) + await htmx.swap("Partial", "#test-playground") playground().innerText.should.equal("Partial"); }) it('swaps partial with custom target', async function () { createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"Partial"}) + await htmx.swap("Partial", "#d1") find('#d2').innerText.should.equal("Partial"); }) it('swaps partial with custom swap style', async function () { createProcessedHTML("
Existing
") - await htmx.swap({"target":"#test-playground", "text":"Partial"}) + await htmx.swap("Partial", "#test-playground") find('#d1').innerText.should.equal("ExistingPartial"); }) it('executes script in oob swap', async function () { window.testVar = 0; createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"
Main
"}) + await htmx.swap("
Main
", "#d1") window.testVar.should.equal(7); delete window.testVar; }) it('executes script in partial', async function () { window.testVar = 0; - await htmx.swap({"target":"#test-playground", "text":""}) + await htmx.swap("", "#test-playground") window.testVar.should.equal(8); delete window.testVar; }) it('executes script when wrapped in html tag', async function () { window.testVar = 0; - await htmx.swap({"target":"#test-playground", "text":"
Content
"}) + await htmx.swap("
Content
", "#test-playground") window.testVar.should.equal(9); delete window.testVar; }) it('executes script when wrapped in body tag', async function () { window.testVar = 0; - await htmx.swap({"target":"#test-playground", "text":"
Content
"}) + await htmx.swap("
Content
", "#test-playground") window.testVar.should.equal(10); delete window.testVar; }) it('replaces attributes when swapping element with same id', async function () { createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"
Content
", "swap":"outerHTML"}) + await htmx.swap("
Content
", "#d1", {style: "outerHTML"}) let replaced = find('#d1'); replaced.getAttribute('class').should.equal('new'); replaced.getAttribute('data-value').should.equal('2'); @@ -257,7 +369,7 @@ describe('swap() unit tests', function() { htmx.on('transitionstart', () => { transitioned = true; }); - await htmx.swap({"target":"#d1", "text":"
New
", "swap":"outerHTML"}) + await htmx.swap("
New
", "#d1", {style: "outerHTML"}) await htmx.timeout(50); transitioned.should.be.true; }) @@ -267,7 +379,7 @@ describe('swap() unit tests', function() { htmx.on('htmx:before:swap', () => { triggered = true; }); - await htmx.swap({"target":"#test-playground", "text":"
Content
"}) + await htmx.swap("
Content
", "#test-playground") triggered.should.be.true; }) @@ -276,7 +388,7 @@ describe('swap() unit tests', function() { htmx.on('htmx:after:swap', () => { triggered = true; }); - await htmx.swap({"target":"#test-playground", "text":"
Content
"}) + await htmx.swap("
Content
", "#test-playground") triggered.should.be.true; }) @@ -285,7 +397,7 @@ describe('swap() unit tests', function() { htmx.on('htmx:after:settle', () => { triggered = true; }); - await htmx.swap({"target":"#test-playground", "text":"
Content
"}) + await htmx.swap("
Content
", "#test-playground") triggered.should.be.true; }) @@ -294,7 +406,7 @@ describe('swap() unit tests', function() { let beforeSettleTarget, afterSettleTarget; htmx.on('htmx:before:settle', (e) => { beforeSettleTarget = e.target; }); htmx.on('htmx:after:settle', (e) => { afterSettleTarget = e.target; }); - await htmx.swap({"target":"#d1", "text":"Replaced", "swap":"outerHTML"}) + await htmx.swap("Replaced", "#d1", {style: "outerHTML"}) let replacement = find('#replaced'); assert.isNotNull(replacement); beforeSettleTarget.should.equal(replacement); @@ -307,7 +419,7 @@ describe('swap() unit tests', function() { createProcessedHTML("
First
Second
") let settleTarget; htmx.on('htmx:after:settle', (e) => { settleTarget = e.target; }); - await htmx.swap({"target":"#d1", "text":"New", "swap":"outerHTML"}) + await htmx.swap("New", "#d1", {style: "outerHTML"}) let replacement = find('#new-first'); assert.isNotNull(replacement); settleTarget.should.equal(replacement); @@ -318,7 +430,7 @@ describe('swap() unit tests', function() { createProcessedHTML("
Original
") let settleTarget; htmx.on('htmx:after:settle', (e) => { settleTarget = e.target; }); - await htmx.swap({"target":"#d1", "text":"Hello World", "swap":"outerHTML"}) + await htmx.swap("Hello World", "#d1", {style: "outerHTML"}) playground().innerText.should.contain("Hello") playground().innerText.should.contain("World") assert.isOk(settleTarget); @@ -330,7 +442,7 @@ describe('swap() unit tests', function() { htmx.process(playground()); let settleTarget; htmx.on('htmx:after:settle', (e) => { settleTarget = e.target; }); - await htmx.swap({"target":"#d1", "text":"Replaced", "swap":"outerHTML"}) + await htmx.swap("Replaced", "#d1", {style: "outerHTML"}) let replacement = find('#replaced'); assert.isNotNull(replacement); settleTarget.should.equal(replacement); @@ -343,7 +455,7 @@ describe('swap() unit tests', function() { let beforeSettleTarget, afterSettleTarget; wrapper.addEventListener('htmx:before:settle', (e) => { beforeSettleTarget = e.target; }); wrapper.addEventListener('htmx:after:settle', (e) => { afterSettleTarget = e.target; }); - await htmx.swap({"target":"#d1", "text":"", "swap":"outerHTML"}) + await htmx.swap("", "#d1", {style: "outerHTML"}) wrapper.children.length.should.equal(0); beforeSettleTarget.should.equal(wrapper); afterSettleTarget.should.equal(wrapper); @@ -354,7 +466,7 @@ describe('swap() unit tests', function() { htmx.process(playground()); let settleTarget; htmx.on('htmx:after:settle', (e) => { settleTarget = e.target; }); - await htmx.swap({"target":"#d1", "text":"inserted text After", "swap":"outerHTML"}) + await htmx.swap("inserted text After", "#d1", {style: "outerHTML"}) assert.isOk(settleTarget); document.body.contains(settleTarget).should.be.true; playground().innerText.should.contain("Before text inserted text After"); @@ -375,41 +487,41 @@ describe('swap() unit tests', function() { afterTriggered = true; }); - await htmx.swap({"target":"#test-playground", "text":"
Content
", "swap":"innerHTML transition:true"}) + await htmx.swap("
Content
", "#test-playground", {style: "innerHTML", transition: true}) beforeTriggered.should.be.true; afterTriggered.should.be.true; }) it('sets document title from response', async function () { let originalTitle = document.title; - await htmx.swap({"target":"#test-playground", "text":"New Title
Content
"}) + await htmx.swap("New Title
Content
", "#test-playground") document.title.should.equal('New Title'); document.title = originalTitle; }) it('ignores title when ignoreTitle:true modifier is set', async function () { let originalTitle = document.title; - await htmx.swap({"target":"#test-playground", "text":"Ignored Title
Content
", "swap":"innerHTML ignoreTitle:true"}) + await htmx.swap("Ignored Title
Content
", "#test-playground", {style: "innerHTML", ignoreTitle: true}) document.title.should.equal(originalTitle); }) it('sets title from fragment without html/body tags', async function () { let originalTitle = document.title; - await htmx.swap({"target":"#test-playground", "text":"Fragment Title
Content
"}) + await htmx.swap("Fragment Title
Content
", "#test-playground") document.title.should.equal('Fragment Title'); document.title = originalTitle; }) it('does not set title when response has no title tag', async function () { let originalTitle = document.title; - await htmx.swap({"target":"#test-playground", "text":"
Content without title
"}) + await htmx.swap("
Content without title
", "#test-playground") document.title.should.equal(originalTitle); }) it('sets title with oob swap', async function () { let originalTitle = document.title; createProcessedHTML("
") - await htmx.swap({"target":"#d1", "text":"OOB Title
Main
OOB
"}) + await htmx.swap("OOB Title
Main
OOB
", "#d1") document.title.should.equal('OOB Title'); document.title = originalTitle; }) @@ -417,83 +529,68 @@ describe('swap() unit tests', function() { it('sets title with partial swap', async function () { let originalTitle = document.title; createProcessedHTML("
") - await htmx.swap({"target":"#test-playground", "text":"Partial TitlePartial Content"}) + await htmx.swap("Partial TitlePartial Content", "#test-playground") document.title.should.equal('Partial Title'); document.title = originalTitle; }) it('sets title from body tag response', async function () { let originalTitle = document.title; - await htmx.swap({"target":"#test-playground", "text":"Body Title
Content
"}) + await htmx.swap("Body Title
Content
", "#test-playground") document.title.should.equal('Body Title'); document.title = originalTitle; }) it('decodes HTML entities in title', async function () { let originalTitle = document.title; - await htmx.swap({"target":"#test-playground", "text":"</> htmx & friends
Content
"}) + await htmx.swap("</> htmx & friends
Content
", "#test-playground") document.title.should.equal(' htmx & friends'); document.title = originalTitle; }) it('does not swap title tag into page content', async function () { - await htmx.swap({"target":"#test-playground", "text":"Test Title
Main Content
"}) + await htmx.swap("Test Title
Main Content
", "#test-playground") assert.isNull(playground().querySelector('title')); find('#content').innerText.should.equal('Main Content'); }) it('supports autofocus', async function () { let originalTitle = document.title; - await htmx.swap({"target":"#test-playground", "text":""}) + await htmx.swap("", "#test-playground") document.activeElement.id.should.equal("i1") }) it('swaps both main target and partial target when both are present', async function () { createProcessedHTML("
Hello
OOB
") - await htmx.swap({ - "target":"#target", - "text":"
Hello me!
OOB swap!
" - }) + await htmx.swap("
Hello me!
OOB swap!
", "#target") find('#target').textContent.should.equal("Hello me!"); find('#target_oob').textContent.should.equal("OOB swap!"); }) it('swaps only partial target when response contains only partial', async function () { createProcessedHTML("
Original
OOB Original
") - await htmx.swap({ - "target":"#target", - "text":"
OOB Updated
" - }) + await htmx.swap("
OOB Updated
", "#target") find('#target').textContent.should.equal("Original"); find('#target_oob').textContent.should.equal("OOB Updated"); }) it('does not swap main target when only whitespace and partial present', async function () { createProcessedHTML("
Original
OOB
") - await htmx.swap({ - "target":"#target", - "text":"\n
OOB swap!
\n" - }) + await htmx.swap("\n
OOB swap!
\n", "#target") find('#target').textContent.should.equal("Original"); find('#target_oob').textContent.should.equal("OOB swap!"); }) it('swaps both targets when empty element and partial present', async function () { createProcessedHTML("
Original
OOB
") - await htmx.swap({ - "target":"#target", - "text":"

OOB swap!
" - }) + await htmx.swap("

OOB swap!
", "#target") find('#target').querySelector('p').should.not.be.null; find('#target_oob').textContent.should.equal("OOB swap!"); }) it('swaps both targets when plain text and partial present', async function () { createProcessedHTML("
Original
OOB
") - await htmx.swap({ - "target":"#target", - "text":"Hello
OOB swap!
" - }) + await htmx.swap("Hello
OOB swap!
", "#target") find('#target').textContent.should.equal("Hello"); find('#target_oob').innerText.should.equal("OOB swap!"); }) @@ -504,7 +601,7 @@ describe('swap() unit tests', function() { input.focus() input.setSelectionRange(2, 2) - await htmx.swap({"target":"#test-playground", "text":""}) + await htmx.swap("", "#test-playground") document.activeElement.id.should.equal('focused-input') document.activeElement.selectionStart.should.equal(2) @@ -524,11 +621,11 @@ describe('swap() unit tests', function() { } try { - await htmx.swap({ - target: '#test-playground', - text: "", - swap: 'innerHTML focusScroll:true' - }) + await htmx.swap( + "", + '#test-playground', + 'innerHTML focusScroll:true' + ) focusOptions.preventScroll.should.equal(false) } finally { HTMLElement.prototype.focus = originalFocus @@ -541,7 +638,7 @@ describe('swap() unit tests', function() { input.focus() input.setSelectionRange(1, 3) - await htmx.swap({"target":"#container", "text":"
", "swap":"outerHTML"}) + await htmx.swap("
", "#container", {style: "outerHTML"}) document.activeElement.id.should.equal('focused-input') document.activeElement.selectionStart.should.equal(1) @@ -553,7 +650,7 @@ describe('swap() unit tests', function() { let input = playground().querySelector('input') input.focus() - await htmx.swap({"target":"#test-playground", "text":""}) + await htmx.swap("", "#test-playground") document.activeElement.should.not.equal(input) }) @@ -563,7 +660,7 @@ describe('swap() unit tests', function() { let input = find('#focused-input') input.focus() - await htmx.swap({"target":"#test-playground", "text":""}) + await htmx.swap("", "#test-playground") document.activeElement.id.should.not.equal('focused-input') }) @@ -574,7 +671,7 @@ describe('swap() unit tests', function() { input.focus() input.setSelectionRange(2, 2) - await htmx.swap({"target":"#test-playground", "text":"", "swap":"innerMorph"}) + await htmx.swap("", "#test-playground", {style: "innerMorph"}) // Morph should maintain focus naturally, not through restoration document.activeElement.should.equal(input) @@ -594,12 +691,7 @@ describe('swap() unit tests', function() { const btn = find('#btn') assert.isNotNull(btn._htmx?.initialized, 'child should start initialized') - await htmx.swap({ - target: '#target', - swap: 'outerSync', - text: '
replaced
', - sourceElement: find('#target') - }) + await htmx.swap('
replaced
', '#target', {style: "outerSync", source: find('#target')}) assert.isNull(btn.getAttribute('data-htmx-powered'), 'old child should be cleaned up') assert.isNotOk(find('#btn'), 'old button should be gone') @@ -610,12 +702,7 @@ describe('swap() unit tests', function() { createProcessedHTML("

static

") mockResponse('GET', '/dynamic', 'fetched') - await htmx.swap({ - target: '#target', - swap: 'outerSync', - text: '

now interactive

', - sourceElement: find('#target') - }) + await htmx.swap('

now interactive

', '#target', {style: "outerSync", source: find('#target')}) let target = find('#target') target.getAttribute('hx-get').should.equal('/dynamic') @@ -632,12 +719,7 @@ describe('swap() unit tests', function() { assert.isNotNull(target._htmx?.initialized, 'target should start initialized') // outerSync removes hx-get from the root - await htmx.swap({ - target: '#target', - swap: 'outerSync', - text: '
no longer interactive
', - sourceElement: target - }) + await htmx.swap('
no longer interactive
', '#target', {style: "outerSync", source: target}) target = find('#target') target.textContent.should.equal('no longer interactive') @@ -658,12 +740,7 @@ describe('swap() unit tests', function() { createProcessedHTML("
original
") // Change trigger from click to mousedown - await htmx.swap({ - target: '#target', - swap: 'outerSync', - text: '
updated
', - sourceElement: find('#target') - }) + await htmx.swap('
updated
', '#target', {style: "outerSync", source: find('#target')}) let target = find('#target') target.getAttribute('hx-trigger').should.equal('mousedown') @@ -683,36 +760,77 @@ describe('swap() unit tests', function() { target.textContent.should.equal('response') }) + // HX-Reswap replaces request swap modifiers without dropping independent selection state. + it('replaces the swap spec and preserves selection through HX-Reswap', async function () { + mockResponse('GET', '/test', '
Selected
OOB
', { + headers: { 'HX-Reswap': 'innerHTML responseOnly:true' } + }) + createProcessedHTML('
') + let finalSwap + find('button').addEventListener('htmx:before:swap', event => finalSwap = event.detail.ctx.swap) + + find('button').click() + await forRequest() + + assert.equal(finalSwap.style, 'innerHTML') + assert.isFalse(finalSwap.transition) + assert.isUndefined(finalSwap.requestOnly) + assert.isTrue(finalSwap.responseOnly) + assert.equal(finalSwap.select, '#selected') + assert.equal(finalSwap.selectOOB, '#oob') + }) + + // HX-Reswap falls back to the global transition preference when it does not specify one. + it('preserves global transitions through HX-Reswap', async function () { + let originalTransitions = htmx.config.transitions + try { + htmx.config.transitions = true + mockResponse('GET', '/test', 'New', { + headers: { 'HX-Reswap': 'innerHTML' } + }) + createProcessedHTML('
') + let finalSwap + find('button').addEventListener('htmx:before:swap', event => finalSwap = event.detail.ctx.swap) + + find('button').click() + await forRequest() + + assert.isTrue(finalSwap.transition) + } finally { + htmx.config.transitions = originalTransitions + } + }) + it('swaps partial to all elements matching a class selector', async function () { createProcessedHTML("
A
B
") - await htmx.swap({"target":"#test-playground", "text":"Updated"}) + await htmx.swap("Updated", "#test-playground") playground().querySelectorAll('.target').forEach(el => el.innerText.should.equal('Updated')) }) it('swapEmpty:false prevents main swap when response is only oob', async function () { createProcessedHTML("
Original
OOB
") - await htmx.swap({"target":"#target", "swap":"innerHTML swapEmpty:false", "text":"
Updated
"}) + await htmx.swap("
Updated
", "#target", {style: "innerHTML", swapEmpty: false}) find('#target').innerText.should.equal('Original'); find('#oob').innerText.should.equal('Updated'); }) it('swapEmpty:false prevents main swap when response is only partials', async function () { createProcessedHTML("
Original
Partial
") - await htmx.swap({"target":"#target", "swap":"innerHTML swapEmpty:false", "text":"Updated"}) + await htmx.swap("Updated", "#target", {style: "innerHTML", swapEmpty: false}) find('#target').innerText.should.equal('Original'); find('#partial').innerText.should.equal('Updated'); }) it('swapEmpty:true forces main swap even on empty response with partials', async function () { createProcessedHTML("
Original
Partial
") - await htmx.swap({"target":"#target", "swap":"innerHTML swapEmpty:true", "text":"Updated"}) + await htmx.swap("Updated", "#target", {style: "innerHTML", swapEmpty: true}) find('#target').innerText.should.equal(''); find('#partial').innerText.should.equal('Updated'); }) it('swapEmpty:false still swaps main target when response has real content alongside oob', async function () { createProcessedHTML("
Original
OOB
") - await htmx.swap({"target":"#target", "swap":"innerHTML swapEmpty:false", "text":"
New Content
Updated
"}) + await htmx.swap("
New Content
Updated
", "#target", {style: "innerHTML", swapEmpty: false}) find('#target').innerText.trim().should.equal('New Content'); find('#oob').innerText.should.equal('Updated'); }) @@ -723,7 +841,7 @@ describe('swap() unit tests', function() { textarea.focus() textarea.setSelectionRange(6, 11) - await htmx.swap({"target":"#test-playground", "text":""}) + await htmx.swap("", "#test-playground") document.activeElement.id.should.equal('focused-textarea') document.activeElement.selectionStart.should.equal(6) 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/docs.mdx b/www/src/content/docs.mdx index 4520deeac..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,8 +679,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_response`) +- `detail.ctx.swap.target` +- `detail.ctx.swap.style` ##### OOB swap stripping @@ -746,7 +749,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 +757,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_response`: ```javascript // htmx 2.x @@ -769,17 +772,17 @@ 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.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: fetch resolves, `htmx:after:request` fires, the body is stored in `ctx.swap.content`, `htmx:after:response` fires, then `htmx:before:swap`. ##### `encodeParameters` @@ -857,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.text`) | +| `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)` | @@ -866,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` @@ -1672,7 +1675,7 @@ event: ```html ``` @@ -2785,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 }, }); ``` @@ -2813,10 +2816,19 @@ 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 + +| 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 | @@ -2887,6 +2899,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 @@ -2900,9 +2913,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 @@ -2916,9 +2943,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/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 + +