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
This commit is contained in:
Denis Palashevskii 2022-03-16 00:00:47 +04:00
parent bcd68ad437
commit 8bb0399052
2 changed files with 183 additions and 120 deletions

View File

@ -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) { forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function(child) {
ensureWebSocket(child) ensureWebSocket(child)
}); });
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
ensureWebSocketSend(child)
});
} }
} }
}); });
@ -125,8 +128,11 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/** @type {WebSocket} */ /** @type {WebSocket} */
var socket = htmx.createWebSocket(wssSource); var socket = htmx.createWebSocket(wssSource);
var messageQueue = [];
socket.onopen = function (e) { socket.onopen = function (e) {
retryCount = 0; retryCount = 0;
handleQueuedMessages(messageQueue, socket);
} }
socket.onclose = function (e) { socket.onclose = function (e) {
@ -158,25 +164,42 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
var fragment = api.makeFragment(response); var fragment = api.makeFragment(response);
if (fragment.children.length) { if (fragment.children.length) {
for (var i = 0; i < fragment.children.length; i++) { var children = Array.from(fragment.children);
api.oobSwap(api.getAttributeValue(fragment.children[i], "hx-swap-oob") || "true", fragment.children[i], settleInfo); 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); api.settleImmediately(settleInfo.tasks);
}); });
// Re-connect any ws-send commands as well. // Put the WebSocket into the HTML Element's custom data.
forEach(queryAttributeOnThisOrChildren(elt, "ws-send"), function(child) { api.getInternalData(elt).webSocket = socket;
var legacyAttribute = api.getAttributeValue(child, "hx-ws"); 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') { if (legacyAttribute && legacyAttribute !== 'send') {
return; return;
} }
processWebSocketSend(elt, child);
});
// Put the WebSocket into the HTML Element's custom data. var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
api.getInternalData(elt).webSocket = socket; 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;
} }
/** /**
@ -186,8 +209,12 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {HTMLElement} child * @param {HTMLElement} child
*/ */
function processWebSocketSend(parent, child) { function processWebSocketSend(parent, child) {
child.addEventListener(api.getTriggerSpecs(child)[0].trigger, function (evt) { 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 webSocket = api.getInternalData(parent).webSocket;
var messageQueue = api.getInternalData(parent).webSocketMessageQueue;
var headers = api.getHeaders(child, parent); var headers = api.getHeaders(child, parent);
var results = api.getInputValues(child, 'post'); var results = api.getInputValues(child, 'post');
var errors = results.errors; var errors = results.errors;
@ -200,11 +227,43 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
api.triggerEvent(child, 'htmx:validation:halted', errors); api.triggerEvent(child, 'htmx:validation:halted', errors);
return; return;
} }
webSocket.send(JSON.stringify(filteredParameters)); webSocketSend(webSocket, JSON.stringify(filteredParameters), messageQueue);
if(api.shouldCancel(evt, child)){ if(api.shouldCancel(evt, child)){
evt.preventDefault(); 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;
}
}
} }
/** /**
@ -293,3 +352,4 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
} }
})(); })();

View File

@ -76,6 +76,7 @@ return (function () {
/** @type {import("./htmx").HtmxInternalApi} */ /** @type {import("./htmx").HtmxInternalApi} */
var internalAPI = { var internalAPI = {
addTriggerHandler: addTriggerHandler,
bodyContains: bodyContains, bodyContains: bodyContains,
canAccessLocalStorage: canAccessLocalStorage, canAccessLocalStorage: canAccessLocalStorage,
filterValues: filterValues, filterValues: filterValues,
@ -172,13 +173,11 @@ return (function () {
* @returns {HTMLElement | null} * @returns {HTMLElement | null}
*/ */
function getClosestMatch(elt, condition) { function getClosestMatch(elt, condition) {
if (condition(elt)) { while (elt && !condition(elt)) {
return elt; elt = parentElt(elt);
} else if (parentElt(elt)) {
return getClosestMatch(parentElt(elt), condition);
} else {
return null;
} }
return elt ? elt : null;
} }
function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName){ function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName){
@ -1188,14 +1187,14 @@ return (function () {
getInternalData(elt).cancelled = true; getInternalData(elt).cancelled = true;
} }
function processPolling(elt, verb, path, spec) { function processPolling(elt, handler, spec) {
var nodeData = getInternalData(elt); var nodeData = getInternalData(elt);
nodeData.timeout = setTimeout(function () { nodeData.timeout = setTimeout(function () {
if (bodyContains(elt) && nodeData.cancelled !== true) { if (bodyContains(elt) && nodeData.cancelled !== true) {
if (!maybeFilterEvent(spec, makeEvent('hx:poll:trigger', {triggerSpec:spec, target:elt}))) { 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); }, spec.pollInterval);
} }
@ -1223,7 +1222,9 @@ return (function () {
path = getRawAttribute(elt, 'action'); path = getRawAttribute(elt, 'action');
} }
triggerSpecs.forEach(function(triggerSpec) { 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; return false;
} }
function addEventListener(elt, verb, path, nodeData, triggerSpec, explicitCancel) { function addEventListener(elt, handler, nodeData, triggerSpec, explicitCancel) {
var eltsToListenOn; var eltsToListenOn;
if (triggerSpec.from) { if (triggerSpec.from) {
eltsToListenOn = querySelectorAllExt(elt, triggerSpec.from); eltsToListenOn = querySelectorAllExt(elt, triggerSpec.from);
@ -1328,17 +1329,15 @@ return (function () {
if (triggerSpec.throttle) { if (triggerSpec.throttle) {
if (!elementData.throttle) { if (!elementData.throttle) {
issueAjaxRequest(verb, path, elt, evt); handler(elt, evt);
elementData.throttle = setTimeout(function () { elementData.throttle = setTimeout(function () {
elementData.throttle = null; elementData.throttle = null;
}, triggerSpec.throttle); }, triggerSpec.throttle);
} }
} else if (triggerSpec.delay) { } else if (triggerSpec.delay) {
elementData.delayed = setTimeout(function () { elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay);
issueAjaxRequest(verb, path, elt, evt);
}, triggerSpec.delay);
} else { } else {
issueAjaxRequest(verb, path, elt, evt); handler(elt, evt);
} }
} }
}; };
@ -1356,7 +1355,7 @@ return (function () {
var windowIsScrolling = false // used by initScrollHandler var windowIsScrolling = false // used by initScrollHandler
var scrollHandler = null; var scrollHandler = null;
function initScrollHandler() { function initScrollHandler(handler) {
if (!scrollHandler) { if (!scrollHandler) {
scrollHandler = function() { scrollHandler = function() {
windowIsScrolling = true windowIsScrolling = true
@ -1366,25 +1365,22 @@ return (function () {
if (windowIsScrolling) { if (windowIsScrolling) {
windowIsScrolling = false; windowIsScrolling = false;
forEach(getDocument().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"), function (elt) { forEach(getDocument().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"), function (elt) {
maybeReveal(elt); maybeReveal(elt, handler);
}) })
} }
}, 200); }, 200);
} }
} }
function maybeReveal(elt) { function maybeReveal(elt, handler) {
if (!hasAttribute(elt,'data-hx-revealed') && isScrolledIntoView(elt)) { if (!hasAttribute(elt,'data-hx-revealed') && isScrolledIntoView(elt)) {
elt.setAttribute('data-hx-revealed', 'true'); elt.setAttribute('data-hx-revealed', 'true');
var nodeData = getInternalData(elt); var nodeData = getInternalData(elt);
if (nodeData.initialized) { if (nodeData.initialized) {
issueAjaxRequest(nodeData.verb, nodeData.path, elt); handler(elt);
} else { } else {
// if the node isn't initialized, wait for it before triggering the request // if the node isn't initialized, wait for it before triggering the request
elt.addEventListener("htmx:afterProcessNode", elt.addEventListener("htmx:afterProcessNode", function(evt) { handler(elt) }, {once: true});
function () {
issueAjaxRequest(nodeData.verb, nodeData.path, 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); var sseSourceElt = getClosestMatch(elt, hasEventSource);
if (sseSourceElt) { if (sseSourceElt) {
var sseEventSource = getInternalData(sseSourceElt).sseEventSource; var sseEventSource = getInternalData(sseSourceElt).sseEventSource;
var sseListener = function () { var sseListener = function () {
if (!maybeCloseSSESource(sseSourceElt)) { if (!maybeCloseSSESource(sseSourceElt)) {
if (bodyContains(elt)) { if (bodyContains(elt)) {
issueAjaxRequest(verb, path, elt); handler(elt);
} else { } else {
sseEventSource.removeEventListener(sseEventName, sseListener); 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(){ var load = function(){
if (!nodeData.loaded) { if (!nodeData.loaded) {
nodeData.loaded = true; nodeData.loaded = true;
issueAjaxRequest(verb, path, elt); handler(elt);
} }
} }
if (delay) { if (delay) {
@ -1627,11 +1623,21 @@ return (function () {
nodeData.path = path; nodeData.path = path;
nodeData.verb = verb; nodeData.verb = verb;
triggerSpecs.forEach(function(triggerSpec) { triggerSpecs.forEach(function(triggerSpec) {
addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) {
issueAjaxRequest(verb, path, elt, evt)
})
});
}
});
return explicitAction;
}
function addTriggerHandler(elt, triggerSpec, nodeData, handler) {
if (triggerSpec.sseEvent) { if (triggerSpec.sseEvent) {
processSSETrigger(elt, verb, path, triggerSpec.sseEvent); processSSETrigger(elt, handler, triggerSpec.sseEvent);
} else if (triggerSpec.trigger === "revealed") { } else if (triggerSpec.trigger === "revealed") {
initScrollHandler(); initScrollHandler(handler);
maybeReveal(elt); maybeReveal(elt, handler);
} else if (triggerSpec.trigger === "intersect") { } else if (triggerSpec.trigger === "intersect") {
var observerOptions = {}; var observerOptions = {};
if (triggerSpec.root) { if (triggerSpec.root) {
@ -1650,21 +1656,17 @@ return (function () {
} }
}, observerOptions); }, observerOptions);
observer.observe(elt); observer.observe(elt);
addEventListener(elt, verb, path, nodeData, triggerSpec); addEventListener(elt, handler, nodeData, triggerSpec);
} else if (triggerSpec.trigger === "load") { } else if (triggerSpec.trigger === "load") {
if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) { if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) {
loadImmediately(elt, verb, path, nodeData, triggerSpec.delay); loadImmediately(elt, handler, nodeData, triggerSpec.delay);
} }
} else if (triggerSpec.pollInterval) { } else if (triggerSpec.pollInterval) {
nodeData.polling = true; nodeData.polling = true;
processPolling(elt, verb, path, triggerSpec); processPolling(elt, handler, triggerSpec);
} else { } else {
addEventListener(elt, verb, path, nodeData, triggerSpec); addEventListener(elt, handler, nodeData, triggerSpec);
} }
});
}
});
return explicitAction;
} }
function evalScript(script) { function evalScript(script) {
@ -3177,3 +3179,4 @@ return (function () {
} }
)() )()
})); }));