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) {
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} */
var socket = htmx.createWebSocket(wssSource);
var messageQueue = [];
socket.onopen = function (e) {
retryCount = 0;
handleQueuedMessages(messageQueue, socket);
}
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);
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");
// 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;
}
processWebSocketSend(elt, child);
});
// Put the WebSocket into the HTML Element's custom data.
api.getInternalData(elt).webSocket = socket;
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;
}
/**
@ -186,8 +209,12 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {HTMLElement} 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 messageQueue = api.getInternalData(parent).webSocketMessageQueue;
var headers = api.getHeaders(child, parent);
var results = api.getInputValues(child, 'post');
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);
return;
}
webSocket.send(JSON.stringify(filteredParameters));
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;
}
}
}
/**
@ -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} */
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){
@ -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,11 +1623,21 @@ return (function () {
nodeData.path = path;
nodeData.verb = verb;
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) {
processSSETrigger(elt, verb, path, triggerSpec.sseEvent);
processSSETrigger(elt, handler, triggerSpec.sseEvent);
} else if (triggerSpec.trigger === "revealed") {
initScrollHandler();
maybeReveal(elt);
initScrollHandler(handler);
maybeReveal(elt, handler);
} else if (triggerSpec.trigger === "intersect") {
var observerOptions = {};
if (triggerSpec.root) {
@ -1650,21 +1656,17 @@ return (function () {
}
}, observerOptions);
observer.observe(elt);
addEventListener(elt, verb, path, nodeData, triggerSpec);
addEventListener(elt, handler, nodeData, triggerSpec);
} else if (triggerSpec.trigger === "load") {
if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) {
loadImmediately(elt, verb, path, nodeData, triggerSpec.delay);
loadImmediately(elt, handler, nodeData, triggerSpec.delay);
}
} else if (triggerSpec.pollInterval) {
nodeData.polling = true;
processPolling(elt, verb, path, triggerSpec);
processPolling(elt, handler, triggerSpec);
} else {
addEventListener(elt, verb, path, nodeData, triggerSpec);
addEventListener(elt, handler, nodeData, triggerSpec);
}
});
}
});
return explicitAction;
}
function evalScript(script) {
@ -3177,3 +3179,4 @@ return (function () {
}
)()
}));