From 8bb0399052f5a52a49e9e4f0f9624d25f1c2e890 Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Wed, 16 Mar 2022 00:00:47 +0400 Subject: [PATCH] Improvements in WebSocket extension * Implement proper `hx-trigger` support * Expose trigger handling API to extensions * Implement safe message sending with sending queue * Fix `ws-send` attributes connecting in new elements * Fix OOB swapping of multiple elements in response --- src/ext/ws.js | 170 ++++++++++++++++++++++++++++++++++---------------- src/htmx.js | 133 ++++++++++++++++++++------------------- 2 files changed, 183 insertions(+), 120 deletions(-) diff --git a/src/ext/ws.js b/src/ext/ws.js index d0064324..474df022 100644 --- a/src/ext/ws.js +++ b/src/ext/ws.js @@ -13,7 +13,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f /** * init is called once, when this extension is first registered. - * @param {import("../htmx").HtmxInternalApi} apiRef + * @param {import("../htmx").HtmxInternalApi} apiRef */ init: function(apiRef) { @@ -33,9 +33,9 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f /** * onEvent handles all events passed to this extension. - * - * @param {string} name - * @param {Event} evt + * + * @param {string} name + * @param {Event} evt */ onEvent: function(name, evt) { @@ -50,7 +50,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f internalData.webSocket.close(); } return; - + // Try to create EventSources when elements are processed case "htmx:afterProcessNode": var parent = evt.target; @@ -58,6 +58,9 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function(child) { ensureWebSocket(child) }); + forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) { + ensureWebSocketSend(child) + }); } } }); @@ -81,14 +84,14 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f /** * ensureWebSocket creates a new WebSocket on the designated element, using - * the element's "ws-connect" attribute. - * @param {HTMLElement} elt - * @param {number=} retryCount - * @returns + * the element's "ws-connect" attribute. + * @param {HTMLElement} elt + * @param {number=} retryCount + * @returns */ function ensureWebSocket(elt, retryCount) { - // If the element containing the WebSocket connection no longer exists, then + // If the element containing the WebSocket connection no longer exists, then // do not connect/reconnect the WebSocket. if (!api.bodyContains(elt)) { return; @@ -125,16 +128,19 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f /** @type {WebSocket} */ var socket = htmx.createWebSocket(wssSource); + var messageQueue = []; + socket.onopen = function (e) { retryCount = 0; + handleQueuedMessages(messageQueue, socket); } socket.onclose = function (e) { // If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause. - if ([1006, 1012, 1013].indexOf(e.code) >= 0) { + if ([1006, 1012, 1013].indexOf(e.code) >= 0) { var delay = getWebSocketReconnectDelay(retryCount); setTimeout(function() { - ensureWebSocket(elt, retryCount+1); + ensureWebSocket(elt, retryCount+1); }, delay); } }; @@ -158,25 +164,42 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f var fragment = api.makeFragment(response); if (fragment.children.length) { - for (var i = 0; i < fragment.children.length; i++) { - api.oobSwap(api.getAttributeValue(fragment.children[i], "hx-swap-oob") || "true", fragment.children[i], settleInfo); + var children = Array.from(fragment.children); + for (var i = 0; i < children.length; i++) { + api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo); } } api.settleImmediately(settleInfo.tasks); }); - // Re-connect any ws-send commands as well. - forEach(queryAttributeOnThisOrChildren(elt, "ws-send"), function(child) { - var legacyAttribute = api.getAttributeValue(child, "hx-ws"); - if (legacyAttribute && legacyAttribute !== 'send') { - return; - } - processWebSocketSend(elt, child); - }); - // Put the WebSocket into the HTML Element's custom data. api.getInternalData(elt).webSocket = socket; + api.getInternalData(elt).webSocketMessageQueue = messageQueue; + } + + /** + * ensureWebSocketSend attaches trigger handles to elements with + * "ws-send" attribute + * @param {HTMLElement} elt + */ + function ensureWebSocketSend(elt) { + var legacyAttribute = api.getAttributeValue(elt, "hx-ws"); + if (legacyAttribute && legacyAttribute !== 'send') { + return; + } + + var webSocketParent = api.getClosestMatch(elt, hasWebSocket) + processWebSocketSend(webSocketParent, elt); + } + + /** + * hasWebSocket function checks if a node has webSocket instance attached + * @param {HTMLElement} node + * @returns {boolean} + */ + function hasWebSocket(node) { + return api.getInternalData(node).webSocket != null; } /** @@ -184,29 +207,65 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f * messages can be sent to the WebSocket server when the form is submitted. * @param {HTMLElement} parent * @param {HTMLElement} child - */ + */ function processWebSocketSend(parent, child) { - child.addEventListener(api.getTriggerSpecs(child)[0].trigger, function (evt) { - var webSocket = api.getInternalData(parent).webSocket; - var headers = api.getHeaders(child, parent); - var results = api.getInputValues(child, 'post'); - var errors = results.errors; - var rawParameters = results.values; - var expressionVars = api.getExpressionVars(child); - var allParameters = api.mergeObjects(rawParameters, expressionVars); - var filteredParameters = api.filterValues(allParameters, child); - filteredParameters['HEADERS'] = headers; - if (errors && errors.length > 0) { - api.triggerEvent(child, 'htmx:validation:halted', errors); - return; - } - webSocket.send(JSON.stringify(filteredParameters)); - if(api.shouldCancel(evt, child)){ - evt.preventDefault(); - } + var nodeData = api.getInternalData(child); + let triggerSpecs = api.getTriggerSpecs(child); + triggerSpecs.forEach(function(ts) { + api.addTriggerHandler(child, ts, nodeData, function (evt) { + var webSocket = api.getInternalData(parent).webSocket; + var messageQueue = api.getInternalData(parent).webSocketMessageQueue; + var headers = api.getHeaders(child, parent); + var results = api.getInputValues(child, 'post'); + var errors = results.errors; + var rawParameters = results.values; + var expressionVars = api.getExpressionVars(child); + var allParameters = api.mergeObjects(rawParameters, expressionVars); + var filteredParameters = api.filterValues(allParameters, child); + filteredParameters['HEADERS'] = headers; + if (errors && errors.length > 0) { + api.triggerEvent(child, 'htmx:validation:halted', errors); + return; + } + webSocketSend(webSocket, JSON.stringify(filteredParameters), messageQueue); + if(api.shouldCancel(evt, child)){ + evt.preventDefault(); + } + }); }); } - + + /** + * webSocketSend provides a safe way to send messages through a WebSocket. + * It checks that the socket is in OPEN state and, otherwise, awaits for it. + * @param {WebSocket} socket + * @param {string} message + * @param {string[]} messageQueue + * @return {boolean} + */ + function webSocketSend(socket, message, messageQueue) { + if (socket.readyState != socket.OPEN) { + messageQueue.push(message); + } else { + socket.send(message); + } + } + + /** + * handleQueuedMessages sends messages awaiting in the message queue + */ + function handleQueuedMessages(messageQueue, socket) { + while (messageQueue.length > 0) { + var message = messageQueue[0] + if (socket.readyState == socket.OPEN) { + socket.send(message); + messageQueue.shift() + } else { + break; + } + } + } + /** * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects. * @param {number} retryCount // The number of retries that have already taken place @@ -230,12 +289,12 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f /** * maybeCloseWebSocketSource checks to the if the element that created the WebSocket - * still exists in the DOM. If NOT, then the WebSocket is closed and this function + * still exists in the DOM. If NOT, then the WebSocket is closed and this function * returns TRUE. If the element DOES EXIST, then no action is taken, and this function * returns FALSE. - * - * @param {*} elt - * @returns + * + * @param {*} elt + * @returns */ function maybeCloseWebSocketSource(elt) { if (!api.bodyContains(elt)) { @@ -248,8 +307,8 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f /** * createWebSocket is the default method for creating new WebSocket objects. * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed. - * - * @param {string} url + * + * @param {string} url * @returns WebSocket */ function createWebSocket(url){ @@ -258,9 +317,9 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f /** * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. - * - * @param {HTMLElement} elt - * @param {string} attributeName + * + * @param {HTMLElement} elt + * @param {string} attributeName */ function queryAttributeOnThisOrChildren(elt, attributeName) { @@ -281,8 +340,8 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f /** * @template T - * @param {T[]} arr - * @param {(T) => void} func + * @param {T[]} arr + * @param {(T) => void} func */ function forEach(arr, func) { if (arr) { @@ -292,4 +351,5 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f } } -})(); \ No newline at end of file +})(); + diff --git a/src/htmx.js b/src/htmx.js index c3e0deca..0cc9c915 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -76,6 +76,7 @@ return (function () { /** @type {import("./htmx").HtmxInternalApi} */ var internalAPI = { + addTriggerHandler: addTriggerHandler, bodyContains: bodyContains, canAccessLocalStorage: canAccessLocalStorage, filterValues: filterValues, @@ -172,13 +173,11 @@ return (function () { * @returns {HTMLElement | null} */ function getClosestMatch(elt, condition) { - if (condition(elt)) { - return elt; - } else if (parentElt(elt)) { - return getClosestMatch(parentElt(elt), condition); - } else { - return null; + while (elt && !condition(elt)) { + elt = parentElt(elt); } + + return elt ? elt : null; } function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName){ @@ -609,7 +608,7 @@ return (function () { //==================================================================== // Node processing //==================================================================== - + var DUMMY_ELT = getDocument().createElement("output"); // dummy element for bad selectors function findAttributeTargets(elt, attrName) { var attrTarget = getClosestAttributeValue(elt, attrName); @@ -1188,14 +1187,14 @@ return (function () { getInternalData(elt).cancelled = true; } - function processPolling(elt, verb, path, spec) { + function processPolling(elt, handler, spec) { var nodeData = getInternalData(elt); nodeData.timeout = setTimeout(function () { if (bodyContains(elt) && nodeData.cancelled !== true) { if (!maybeFilterEvent(spec, makeEvent('hx:poll:trigger', {triggerSpec:spec, target:elt}))) { - issueAjaxRequest(verb, path, elt); + handler(elt); } - processPolling(elt, verb, getAttributeValue(elt, "hx-" + verb), spec); + processPolling(elt, handler, spec); } }, spec.pollInterval); } @@ -1223,7 +1222,9 @@ return (function () { path = getRawAttribute(elt, 'action'); } triggerSpecs.forEach(function(triggerSpec) { - addEventListener(elt, verb, path, nodeData, triggerSpec, true); + addEventListener(elt, function(evt) { + issueAjaxRequest(verb, path, elt, evt) + }, nodeData, triggerSpec, true); }); } } @@ -1267,7 +1268,7 @@ return (function () { return false; } - function addEventListener(elt, verb, path, nodeData, triggerSpec, explicitCancel) { + function addEventListener(elt, handler, nodeData, triggerSpec, explicitCancel) { var eltsToListenOn; if (triggerSpec.from) { eltsToListenOn = querySelectorAllExt(elt, triggerSpec.from); @@ -1328,17 +1329,15 @@ return (function () { if (triggerSpec.throttle) { if (!elementData.throttle) { - issueAjaxRequest(verb, path, elt, evt); + handler(elt, evt); elementData.throttle = setTimeout(function () { elementData.throttle = null; }, triggerSpec.throttle); } } else if (triggerSpec.delay) { - elementData.delayed = setTimeout(function () { - issueAjaxRequest(verb, path, elt, evt); - }, triggerSpec.delay); + elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay); } else { - issueAjaxRequest(verb, path, elt, evt); + handler(elt, evt); } } }; @@ -1356,7 +1355,7 @@ return (function () { var windowIsScrolling = false // used by initScrollHandler var scrollHandler = null; - function initScrollHandler() { + function initScrollHandler(handler) { if (!scrollHandler) { scrollHandler = function() { windowIsScrolling = true @@ -1366,25 +1365,22 @@ return (function () { if (windowIsScrolling) { windowIsScrolling = false; forEach(getDocument().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"), function (elt) { - maybeReveal(elt); + maybeReveal(elt, handler); }) } }, 200); } } - function maybeReveal(elt) { + function maybeReveal(elt, handler) { if (!hasAttribute(elt,'data-hx-revealed') && isScrolledIntoView(elt)) { elt.setAttribute('data-hx-revealed', 'true'); var nodeData = getInternalData(elt); if (nodeData.initialized) { - issueAjaxRequest(nodeData.verb, nodeData.path, elt); + handler(elt); } else { // if the node isn't initialized, wait for it before triggering the request - elt.addEventListener("htmx:afterProcessNode", - function () { - issueAjaxRequest(nodeData.verb, nodeData.path, elt); - }, {once: true}); + elt.addEventListener("htmx:afterProcessNode", function(evt) { handler(elt) }, {once: true}); } } } @@ -1571,14 +1567,14 @@ return (function () { } } - function processSSETrigger(elt, verb, path, sseEventName) { + function processSSETrigger(elt, handler, sseEventName) { var sseSourceElt = getClosestMatch(elt, hasEventSource); if (sseSourceElt) { var sseEventSource = getInternalData(sseSourceElt).sseEventSource; var sseListener = function () { if (!maybeCloseSSESource(sseSourceElt)) { if (bodyContains(elt)) { - issueAjaxRequest(verb, path, elt); + handler(elt); } else { sseEventSource.removeEventListener(sseEventName, sseListener); } @@ -1604,11 +1600,11 @@ return (function () { //==================================================================== - function loadImmediately(elt, verb, path, nodeData, delay) { + function loadImmediately(elt, handler, nodeData, delay) { var load = function(){ if (!nodeData.loaded) { nodeData.loaded = true; - issueAjaxRequest(verb, path, elt); + handler(elt); } } if (delay) { @@ -1627,46 +1623,52 @@ return (function () { nodeData.path = path; nodeData.verb = verb; triggerSpecs.forEach(function(triggerSpec) { - if (triggerSpec.sseEvent) { - processSSETrigger(elt, verb, path, triggerSpec.sseEvent); - } else if (triggerSpec.trigger === "revealed") { - initScrollHandler(); - maybeReveal(elt); - } else if (triggerSpec.trigger === "intersect") { - var observerOptions = {}; - if (triggerSpec.root) { - observerOptions.root = querySelectorExt(elt, triggerSpec.root) - } - if (triggerSpec.threshold) { - observerOptions.threshold = parseFloat(triggerSpec.threshold); - } - var observer = new IntersectionObserver(function (entries) { - for (var i = 0; i < entries.length; i++) { - var entry = entries[i]; - if (entry.isIntersecting) { - triggerEvent(elt, "intersect"); - break; - } - } - }, observerOptions); - observer.observe(elt); - addEventListener(elt, verb, path, nodeData, triggerSpec); - } else if (triggerSpec.trigger === "load") { - if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) { - loadImmediately(elt, verb, path, nodeData, triggerSpec.delay); - } - } else if (triggerSpec.pollInterval) { - nodeData.polling = true; - processPolling(elt, verb, path, triggerSpec); - } else { - addEventListener(elt, verb, path, nodeData, triggerSpec); - } + addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) { + issueAjaxRequest(verb, path, elt, evt) + }) }); } }); return explicitAction; } + function addTriggerHandler(elt, triggerSpec, nodeData, handler) { + if (triggerSpec.sseEvent) { + processSSETrigger(elt, handler, triggerSpec.sseEvent); + } else if (triggerSpec.trigger === "revealed") { + initScrollHandler(handler); + maybeReveal(elt, handler); + } else if (triggerSpec.trigger === "intersect") { + var observerOptions = {}; + if (triggerSpec.root) { + observerOptions.root = querySelectorExt(elt, triggerSpec.root) + } + if (triggerSpec.threshold) { + observerOptions.threshold = parseFloat(triggerSpec.threshold); + } + var observer = new IntersectionObserver(function (entries) { + for (var i = 0; i < entries.length; i++) { + var entry = entries[i]; + if (entry.isIntersecting) { + triggerEvent(elt, "intersect"); + break; + } + } + }, observerOptions); + observer.observe(elt); + addEventListener(elt, handler, nodeData, triggerSpec); + } else if (triggerSpec.trigger === "load") { + if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) { + loadImmediately(elt, handler, nodeData, triggerSpec.delay); + } + } else if (triggerSpec.pollInterval) { + nodeData.polling = true; + processPolling(elt, handler, triggerSpec); + } else { + addEventListener(elt, handler, nodeData, triggerSpec); + } + } + function evalScript(script) { if (script.type === "text/javascript" || script.type === "module" || script.type === "") { var newScript = getDocument().createElement("script"); @@ -2885,9 +2887,9 @@ return (function () { target = beforeSwapDetails.target; // allow re-targeting serverResponse = beforeSwapDetails.serverResponse; // allow updating content isError = beforeSwapDetails.isError; // allow updating error - + responseInfo.failed = isError; // Make failed property available to response events - responseInfo.successful = !isError; // Make successful property available to response events + responseInfo.successful = !isError; // Make successful property available to response events if (beforeSwapDetails.shouldSwap) { if (xhr.status === 286) { @@ -3177,3 +3179,4 @@ return (function () { } )() })); +