diff --git a/www/static/src/ext/path-params.js b/www/static/src/ext/path-params.js new file mode 100644 index 00000000..e251b892 --- /dev/null +++ b/www/static/src/ext/path-params.js @@ -0,0 +1,11 @@ +htmx.defineExtension('path-params', { + onEvent: function(name, evt) { + if (name === "htmx:configRequest") { + evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function (_, param) { + var val = evt.detail.parameters[param]; + delete evt.detail.parameters[param]; + return val === undefined ? "{" + param + "}" : encodeURIComponent(val); + }) + } + } +}); \ No newline at end of file diff --git a/www/static/src/ext/sse.js b/www/static/src/ext/sse.js index 75c875a1..943d80ac 100644 --- a/www/static/src/ext/sse.js +++ b/www/static/src/ext/sse.js @@ -5,7 +5,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions */ -(function(){ +(function() { /** @type {import("../htmx").HtmxInternalApi} */ var api; @@ -39,17 +39,19 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions switch (name) { - // Try to remove remove an EventSource when elements are removed - case "htmx:beforeCleanupElement": - var internalData = api.getInternalData(evt.target) - if (internalData.sseEventSource) { - internalData.sseEventSource.close(); - } - return; + case "htmx:beforeCleanupElement": + var internalData = api.getInternalData(evt.target) + // Try to remove remove an EventSource when elements are removed + if (internalData.sseEventSource) { + internalData.sseEventSource.close(); + } - // Try to create EventSources when elements are processed - case "htmx:afterProcessNode": - createEventSourceOnElement(evt.target); + return; + + // Try to create EventSources when elements are processed + case "htmx:afterProcessNode": + ensureEventSourceOnElement(evt.target); + registerSSE(evt.target); } } }); @@ -66,8 +68,8 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions * @param {string} url * @returns EventSource */ - function createEventSource(url) { - return new EventSource(url, {withCredentials:true}); + function createEventSource(url) { + return new EventSource(url, { withCredentials: true }); } function splitOnWhitespace(trigger) { @@ -90,7 +92,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions function getLegacySSESwaps(elt) { var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); var returnArr = []; - if (legacySSEValue) { + if (legacySSEValue != null) { var values = splitOnWhitespace(legacySSEValue); for (var i = 0; i < values.length; i++) { var value = values[i].split(/:(.+)/); @@ -103,63 +105,24 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions } /** - * createEventSourceOnElement creates a new EventSource connection on the provided element. - * If a usable EventSource already exists, then it is returned. If not, then a new EventSource - * is created and stored in the element's internalData. + * registerSSE looks for attributes that can contain sse events, right + * now hx-trigger and sse-swap and adds listeners based on these attributes too + * the closest event source + * * @param {HTMLElement} elt - * @param {number} retryCount - * @returns {EventSource | null} */ - function createEventSourceOnElement(elt, retryCount) { - - if (elt == null) { - return null; + function registerSSE(elt) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource); + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null; // no eventsource in parentage, orphaned element } - var internalData = api.getInternalData(elt); + // Set internalData and source + var internalData = api.getInternalData(sourceElement); + var source = internalData.sseEventSource; - // get URL from element's attribute - var sseURL = api.getAttributeValue(elt, "sse-connect"); - - - if (sseURL == undefined) { - var legacyURL = getLegacySSEURL(elt) - if (legacyURL) { - sseURL = legacyURL; - } else { - return null; - } - } - - // Connect to the EventSource - var source = htmx.createEventSource(sseURL); - internalData.sseEventSource = source; - - // Create event handlers - source.onerror = function (err) { - - // Log an error event - api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source:source}); - - // If parent no longer exists in the document, then clean up this EventSource - if (maybeCloseSSESource(elt)) { - return; - } - - // Otherwise, try to reconnect the EventSource - if (source.readyState === EventSource.CLOSED) { - retryCount = retryCount || 0; - var timeout = Math.random() * (2 ^ retryCount) * 500; - window.setTimeout(function() { - createEventSourceOnElement(elt, Math.min(7, retryCount+1)); - }, timeout); - } - }; - - source.onopen = function (evt) { - api.triggerEvent(elt, "htmx:sseOpen", {source: source}); - } - // Add message handlers for every `sse-swap` attribute queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) { @@ -170,23 +133,27 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions var sseEventNames = getLegacySSESwaps(child); } - for (var i = 0 ; i < sseEventNames.length ; i++) { + for (var i = 0; i < sseEventNames.length; i++) { var sseEventName = sseEventNames[i].trim(); var listener = function(event) { - // If the parent is missing then close SSE and remove listener - if (maybeCloseSSESource(elt)) { - source.removeEventListener(sseEventName, listener); + // If the source is missing then close SSE + if (maybeCloseSSESource(sourceElement)) { return; } + // If the body no longer contains the element, remove the listener + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener); + } + // swap the response into the DOM and trigger a notification swap(child, event.data); api.triggerEvent(elt, "htmx:sseMessage", event); }; // Register the new listener - api.getInternalData(elt).sseEventListener = listener; + api.getInternalData(child).sseEventListener = listener; source.addEventListener(sseEventName, listener); } }); @@ -203,24 +170,86 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions if (sseEventName.slice(0, 4) != "sse:") { return; } + + // remove the sse: prefix from here on out + sseEventName = sseEventName.substr(4); - var listener = function(event) { - - // If parent is missing, then close SSE and remove listener - if (maybeCloseSSESource(elt)) { - source.removeEventListener(sseEventName, listener); - return; + var listener = function() { + if (maybeCloseSSESource(sourceElement)) { + return } - // Trigger events to be handled by the rest of htmx - htmx.trigger(child, sseEventName, event); - htmx.trigger(child, "htmx:sseMessage", event); + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener); + } + } + }); + } + + /** + * ensureEventSourceOnElement creates a new EventSource connection on the provided element. + * If a usable EventSource already exists, then it is returned. If not, then a new EventSource + * is created and stored in the element's internalData. + * @param {HTMLElement} elt + * @param {number} retryCount + * @returns {EventSource | null} + */ + function ensureEventSourceOnElement(elt, retryCount) { + + if (elt == null) { + return null; + } + + // handle extension source creation attribute + queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { + var sseURL = api.getAttributeValue(child, "sse-connect"); + if (sseURL == null) { + return; } - // Register the new listener - api.getInternalData(elt).sseEventListener = listener; - source.addEventListener(sseEventName.slice(4), listener); + ensureEventSource(child, sseURL, retryCount); }); + + // handle legacy sse, remove for HTMX2 + queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) { + var sseURL = getLegacySSEURL(child); + if (sseURL == null) { + return; + } + + ensureEventSource(child, sseURL, retryCount); + }); + + } + + function ensureEventSource(elt, url, retryCount) { + var source = htmx.createEventSource(url); + + source.onerror = function(err) { + + // Log an error event + api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); + + // If parent no longer exists in the document, then clean up this EventSource + if (maybeCloseSSESource(elt)) { + return; + } + + // Otherwise, try to reconnect the EventSource + if (source.readyState === EventSource.CLOSED) { + retryCount = retryCount || 0; + var timeout = Math.random() * (2 ^ retryCount) * 500; + window.setTimeout(function() { + ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); + }, timeout); + } + }; + + source.onopen = function(evt) { + api.triggerEvent(elt, "htmx:sseOpen", { source: source }); + } + + api.getInternalData(elt).sseEventSource = source; } /** @@ -253,12 +282,12 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions var result = []; // If the parent element also contains the requested attribute, then add it to the results too. - if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) { + if (api.hasAttribute(elt, attributeName)) { result.push(elt); } // Search all child nodes that match the requested attribute - elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) { + elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) { result.push(node); }); @@ -281,7 +310,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); - settleInfo.elts.forEach(function (elt) { + settleInfo.elts.forEach(function(elt) { if (elt.classList) { elt.classList.add(htmx.config.settlingClass); } @@ -306,11 +335,11 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions function doSettle(settleInfo) { return function() { - settleInfo.tasks.forEach(function (task) { + settleInfo.tasks.forEach(function(task) { task.call(); }); - settleInfo.elts.forEach(function (elt) { + settleInfo.elts.forEach(function(elt) { if (elt.classList) { elt.classList.remove(htmx.config.settlingClass); } @@ -319,4 +348,8 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions } } -})(); \ No newline at end of file + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null; + } + +})(); diff --git a/www/static/src/htmx.d.ts b/www/static/src/htmx.d.ts index 6200cd3f..197ac527 100644 --- a/www/static/src/htmx.d.ts +++ b/www/static/src/htmx.d.ts @@ -400,6 +400,42 @@ export interface HtmxConfig { * @default true */ scrollIntoViewOnBoost?: boolean; + /** + * If set, the nonce will be added to inline scripts. + * @default '' + */ + inlineScriptNonce?: string; + /** + * The type of binary data being received over the WebSocket connection + * @default 'blob' + */ + wsBinaryType?: 'blob' | 'arraybuffer'; + /** + * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser + * @default false + */ + getCacheBusterParam?: boolean; + /** + * If set to true, htmx will use the View Transition API when swapping in new content. + * @default false + */ + globalViewTransitions?: boolean; + /** + * htmx will format requests with these methods by encoding their parameters in the URL, not the request body + * @default ["get"] + */ + methodsThatUseUrlParams?: ('get' | 'head' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' | 'patch' )[]; + /** + * If set to true htmx will not update the title of the document when a title tag is found in new content + * @default false + */ + ignoreTitle:? boolean; + /** + * The cache to store evaluated trigger specifications into. + * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) + * @default null + */ + triggerSpecsCache?: {[trigger: string]: HtmxTriggerSpecification[]}; } /** diff --git a/www/static/src/htmx.js b/www/static/src/htmx.js index aa86010f..acd70dc0 100644 --- a/www/static/src/htmx.js +++ b/www/static/src/htmx.js @@ -76,7 +76,8 @@ return (function () { methodsThatUseUrlParams: ["get"], selfRequestsOnly: false, ignoreTitle: false, - scrollIntoViewOnBoost: true + scrollIntoViewOnBoost: true, + triggerSpecsCache: null, }, parseInterval:parseInterval, _:internalEval, @@ -127,24 +128,40 @@ return (function () { return "[hx-" + verb + "], [data-hx-" + verb + "]" }).join(", "); + var HEAD_TAG_REGEX = makeTagRegEx('head'), + TITLE_TAG_REGEX = makeTagRegEx('title'), + SVG_TAGS_REGEX = makeTagRegEx('svg', true); + //==================================================================== // Utilities //==================================================================== + /** + * @param {string} tag + * @param {boolean} global + * @returns {RegExp} + */ + function makeTagRegEx(tag, global = false) { + return new RegExp(`<${tag}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${tag}>`, + global ? 'gim' : 'im'); + } + function parseInterval(str) { if (str == undefined) { - return undefined + return undefined; } + + let interval = NaN; if (str.slice(-2) == "ms") { - return parseFloat(str.slice(0,-2)) || undefined + interval = parseFloat(str.slice(0, -2)); + } else if (str.slice(-1) == "s") { + interval = parseFloat(str.slice(0, -1)) * 1000; + } else if (str.slice(-1) == "m") { + interval = parseFloat(str.slice(0, -1)) * 1000 * 60; + } else { + interval = parseFloat(str); } - if (str.slice(-1) == "s") { - return (parseFloat(str.slice(0,-1)) * 1000) || undefined - } - if (str.slice(-1) == "m") { - return (parseFloat(str.slice(0,-1)) * 1000 * 60) || undefined - } - return parseFloat(str) || undefined + return isNaN(interval) ? undefined : interval; } /** @@ -276,43 +293,46 @@ return (function () { } function aFullPageResponse(resp) { - return resp.match(/", 0); + var documentFragment = parseHTML("", 0); // @ts-ignore type mismatch between DocumentFragment and Element. // TODO: Are these close enough for htmx to use interchangeably? return documentFragment.querySelector('template').content; - } else { - var startTag = getStartTag(resp); - switch (startTag) { - case "thead": - case "tbody": - case "tfoot": - case "colgroup": - case "caption": - return parseHTML("" + resp + "
", 1); - case "col": - return parseHTML("" + resp + "
", 2); - case "tr": - return parseHTML("" + resp + "
", 2); - case "td": - case "th": - return parseHTML("" + resp + "
", 3); - case "script": - case "style": - return parseHTML("
" + resp + "
", 1); - default: - return parseHTML(resp, 0); - } + } + switch (startTag) { + case "thead": + case "tbody": + case "tfoot": + case "colgroup": + case "caption": + return parseHTML("" + content + "
", 1); + case "col": + return parseHTML("" + content + "
", 2); + case "tr": + return parseHTML("" + content + "
", 2); + case "td": + case "th": + return parseHTML("" + content + "
", 3); + case "script": + case "style": + return parseHTML("
" + content + "
", 1); + default: + return parseHTML(content, 0); } } @@ -450,7 +470,7 @@ return (function () { path = url.pathname + url.search; } // remove trailing slash, unless index page - if (!path.match('^/$')) { + if (!(/^\/$/.test(path))) { path = path.replace(/\/+$/, ''); } return path; @@ -827,7 +847,7 @@ return (function () { var oobSelects = getClosestAttributeValue(elt, "hx-select-oob"); if (oobSelects) { var oobSelectValues = oobSelects.split(","); - for (let i = 0; i < oobSelectValues.length; i++) { + for (var i = 0; i < oobSelectValues.length; i++) { var oobSelectValue = oobSelectValues[i].split(":", 2); var id = oobSelectValue[0].trim(); if (id.indexOf("#") === 0) { @@ -934,7 +954,7 @@ return (function () { function deInitOnHandlers(elt) { var internalData = getInternalData(elt); if (internalData.onHandlers) { - for (let i = 0; i < internalData.onHandlers.length; i++) { + for (var i = 0; i < internalData.onHandlers.length; i++) { const handlerInfo = internalData.onHandlers[i]; elt.removeEventListener(handlerInfo.event, handlerInfo.listener); } @@ -960,10 +980,8 @@ return (function () { } }); } - if (internalData.initHash) { - internalData.initHash = null - } deInitOnHandlers(element); + forEach(Object.keys(internalData), function(key) { delete internalData[key] }); } function cleanUpElement(element) { @@ -987,7 +1005,6 @@ return (function () { } else { newElt = eltBeforeNewContent.nextSibling; } - getInternalData(target).replacedWith = newElt; // tuck away so we can fire events on it later settleInfo.elts = settleInfo.elts.filter(function(e) { return e != target }); while(newElt && newElt !== target) { if (newElt.nodeType === Node.ELEMENT_NODE) { @@ -1099,9 +1116,8 @@ return (function () { function findTitle(content) { if (content.indexOf(' -1) { - var contentWithSvgsRemoved = content.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - var result = contentWithSvgsRemoved.match(/]*>|>)([\s\S]*?)<\/title>/im); - + var contentWithSvgsRemoved = content.replace(SVG_TAGS_REGEX, ''); + var result = contentWithSvgsRemoved.match(TITLE_TAG_REGEX); if (result) { return result[2]; } @@ -1230,7 +1246,7 @@ return (function () { function consumeUntil(tokens, match) { var result = ""; - while (tokens.length > 0 && !tokens[0].match(match)) { + while (tokens.length > 0 && !match.test(tokens[0])) { result += tokens.shift(); } return result; @@ -1250,6 +1266,99 @@ return (function () { var INPUT_SELECTOR = 'input, textarea, select'; + /** + * @param {HTMLElement} elt + * @param {string} explicitTrigger + * @param {cache} cache for trigger specs + * @returns {import("./htmx").HtmxTriggerSpecification[]} + */ + function parseAndCacheTrigger(elt, explicitTrigger, cache) { + var triggerSpecs = []; + var tokens = tokenizeString(explicitTrigger); + do { + consumeUntil(tokens, NOT_WHITESPACE); + var initialLength = tokens.length; + var trigger = consumeUntil(tokens, /[,\[\s]/); + if (trigger !== "") { + if (trigger === "every") { + var every = {trigger: 'every'}; + consumeUntil(tokens, NOT_WHITESPACE); + every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/)); + consumeUntil(tokens, NOT_WHITESPACE); + var eventFilter = maybeGenerateConditional(elt, tokens, "event"); + if (eventFilter) { + every.eventFilter = eventFilter; + } + triggerSpecs.push(every); + } else if (trigger.indexOf("sse:") === 0) { + triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)}); + } else { + var triggerSpec = {trigger: trigger}; + var eventFilter = maybeGenerateConditional(elt, tokens, "event"); + if (eventFilter) { + triggerSpec.eventFilter = eventFilter; + } + while (tokens.length > 0 && tokens[0] !== ",") { + consumeUntil(tokens, NOT_WHITESPACE) + var token = tokens.shift(); + if (token === "changed") { + triggerSpec.changed = true; + } else if (token === "once") { + triggerSpec.once = true; + } else if (token === "consume") { + triggerSpec.consume = true; + } else if (token === "delay" && tokens[0] === ":") { + tokens.shift(); + triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); + } else if (token === "from" && tokens[0] === ":") { + tokens.shift(); + if (COMBINED_SELECTOR_START.test(tokens[0])) { + var from_arg = consumeCSSSelector(tokens); + } else { + var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); + if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { + tokens.shift(); + var selector = consumeCSSSelector(tokens); + // `next` and `previous` allow a selector-less syntax + if (selector.length > 0) { + from_arg += " " + selector; + } + } + } + triggerSpec.from = from_arg; + } else if (token === "target" && tokens[0] === ":") { + tokens.shift(); + triggerSpec.target = consumeCSSSelector(tokens); + } else if (token === "throttle" && tokens[0] === ":") { + tokens.shift(); + triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); + } else if (token === "queue" && tokens[0] === ":") { + tokens.shift(); + triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA); + } else if (token === "root" && tokens[0] === ":") { + tokens.shift(); + triggerSpec[token] = consumeCSSSelector(tokens); + } else if (token === "threshold" && tokens[0] === ":") { + tokens.shift(); + triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA); + } else { + triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); + } + } + triggerSpecs.push(triggerSpec); + } + } + if (tokens.length === initialLength) { + triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); + } + consumeUntil(tokens, NOT_WHITESPACE); + } while (tokens[0] === "," && tokens.shift()) + if (cache) { + cache[explicitTrigger] = triggerSpecs + } + return triggerSpecs + } + /** * @param {HTMLElement} elt * @returns {import("./htmx").HtmxTriggerSpecification[]} @@ -1258,85 +1367,8 @@ return (function () { var explicitTrigger = getAttributeValue(elt, 'hx-trigger'); var triggerSpecs = []; if (explicitTrigger) { - var tokens = tokenizeString(explicitTrigger); - do { - consumeUntil(tokens, NOT_WHITESPACE); - var initialLength = tokens.length; - var trigger = consumeUntil(tokens, /[,\[\s]/); - if (trigger !== "") { - if (trigger === "every") { - var every = {trigger: 'every'}; - consumeUntil(tokens, NOT_WHITESPACE); - every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/)); - consumeUntil(tokens, NOT_WHITESPACE); - var eventFilter = maybeGenerateConditional(elt, tokens, "event"); - if (eventFilter) { - every.eventFilter = eventFilter; - } - triggerSpecs.push(every); - } else if (trigger.indexOf("sse:") === 0) { - triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)}); - } else { - var triggerSpec = {trigger: trigger}; - var eventFilter = maybeGenerateConditional(elt, tokens, "event"); - if (eventFilter) { - triggerSpec.eventFilter = eventFilter; - } - while (tokens.length > 0 && tokens[0] !== ",") { - consumeUntil(tokens, NOT_WHITESPACE) - var token = tokens.shift(); - if (token === "changed") { - triggerSpec.changed = true; - } else if (token === "once") { - triggerSpec.once = true; - } else if (token === "consume") { - triggerSpec.consume = true; - } else if (token === "delay" && tokens[0] === ":") { - tokens.shift(); - triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); - } else if (token === "from" && tokens[0] === ":") { - tokens.shift(); - if (COMBINED_SELECTOR_START.test(tokens[0])) { - var from_arg = consumeCSSSelector(tokens); - } else { - var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); - if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { - tokens.shift(); - var selector = consumeCSSSelector(tokens); - // `next` and `previous` allow a selector-less syntax - if (selector.length > 0) { - from_arg += " " + selector; - } - } - } - triggerSpec.from = from_arg; - } else if (token === "target" && tokens[0] === ":") { - tokens.shift(); - triggerSpec.target = consumeCSSSelector(tokens); - } else if (token === "throttle" && tokens[0] === ":") { - tokens.shift(); - triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); - } else if (token === "queue" && tokens[0] === ":") { - tokens.shift(); - triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA); - } else if (token === "root" && tokens[0] === ":") { - tokens.shift(); - triggerSpec[token] = consumeCSSSelector(tokens); - } else if (token === "threshold" && tokens[0] === ":") { - tokens.shift(); - triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA); - } else { - triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); - } - } - triggerSpecs.push(triggerSpec); - } - } - if (tokens.length === initialLength) { - triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); - } - consumeUntil(tokens, NOT_WHITESPACE); - } while (tokens[0] === "," && tokens.shift()) + var cache = htmx.config.triggerSpecsCache + triggerSpecs = (cache && cache[explicitTrigger]) || parseAndCacheTrigger(elt, explicitTrigger, cache) } if (triggerSpecs.length > 0) { @@ -1508,14 +1540,14 @@ return (function () { return; } - if (triggerSpec.throttle) { + if (triggerSpec.throttle > 0) { if (!elementData.throttle) { handler(elt, evt); elementData.throttle = setTimeout(function () { elementData.throttle = null; }, triggerSpec.throttle); } - } else if (triggerSpec.delay) { + } else if (triggerSpec.delay > 0) { elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay); } else { triggerEvent(elt, 'htmx:trigger') @@ -1792,7 +1824,7 @@ return (function () { handler(elt); } } - if (delay) { + if (delay > 0) { setTimeout(load, delay); } else { load(); @@ -1851,7 +1883,7 @@ return (function () { if (!maybeFilterEvent(triggerSpec, elt, makeEvent("load", {elt: elt}))) { loadImmediately(elt, handler, nodeData, triggerSpec.delay); } - } else if (triggerSpec.pollInterval) { + } else if (triggerSpec.pollInterval > 0) { nodeData.polling = true; processPolling(elt, handler, triggerSpec); } else { @@ -1894,26 +1926,35 @@ return (function () { }); } - function hasChanceOfBeingBoosted() { - return document.querySelector("[hx-boost], [data-hx-boost]"); + function shouldProcessHxOn(elt) { + var attributes = elt.attributes + for (var j = 0; j < attributes.length; j++) { + var attrName = attributes[j].name + if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:") || + startsWith(attrName, "hx-on-") || startsWith(attrName, "data-hx-on-")) { + return true + } + } + return false } function findHxOnWildcardElements(elt) { var node = null var elements = [] + if (shouldProcessHxOn(elt)) { + elements.push(elt) + } + if (document.evaluate) { - var iter = document.evaluate('//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") ]]', elt) + var iter = document.evaluate('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or' + + ' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]', elt) while (node = iter.iterateNext()) elements.push(node) } else { - var allElements = document.getElementsByTagName("*") + var allElements = elt.getElementsByTagName("*") for (var i = 0; i < allElements.length; i++) { - var attributes = allElements[i].attributes - for (var j = 0; j < attributes.length; j++) { - var attrName = attributes[j].name - if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:")) { - elements.push(allElements[i]) - } + if (shouldProcessHxOn(allElements[i])) { + elements.push(allElements[i]) } } } @@ -1923,8 +1964,8 @@ return (function () { function findElementsToProcess(elt) { if (elt.querySelectorAll) { - var boostedElts = hasChanceOfBeingBoosted() ? ", a" : ""; - var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," + + var boostedSelector = ", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]"; + var results = elt.querySelectorAll(VERB_SELECTOR + boostedSelector + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," + " [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]"); return results; } else { @@ -1970,7 +2011,7 @@ return (function () { function countCurlies(line) { var tokens = tokenizeString(line); var netCurlies = 0; - for (let i = 0; i < tokens.length; i++) { + for (var i = 0; i < tokens.length; i++) { const token = tokens[i]; if (token === "{") { netCurlies++; @@ -2032,12 +2073,22 @@ return (function () { for (var i = 0; i < elt.attributes.length; i++) { var name = elt.attributes[i].name var value = elt.attributes[i].value - if (startsWith(name, "hx-on:") || startsWith(name, "data-hx-on:")) { - let eventName = name.slice(name.indexOf(":") + 1) - // if the eventName starts with a colon, prepend "htmx" for shorthand support - if (startsWith(eventName, ":")) eventName = "htmx" + eventName + if (startsWith(name, "hx-on") || startsWith(name, "data-hx-on")) { + var afterOnPosition = name.indexOf("-on") + 3; + var nextChar = name.slice(afterOnPosition, afterOnPosition + 1); + if (nextChar === "-" || nextChar === ":") { + var eventName = name.slice(afterOnPosition + 1); + // if the eventName starts with a colon or dash, prepend "htmx" for shorthand support + if (startsWith(eventName, ":")) { + eventName = "htmx" + eventName + } else if (startsWith(eventName, "-")) { + eventName = "htmx:" + eventName.slice(1); + } else if (startsWith(eventName, "htmx-")) { + eventName = "htmx:" + eventName.slice(5); + } - addHxOnEventHandler(elt, eventName, value) + addHxOnEventHandler(elt, eventName, value) + } } } } @@ -2315,7 +2366,9 @@ return (function () { var details = {path: path, xhr:request}; triggerEvent(getDocument().body, "htmx:historyCacheMiss", details); request.open('GET', path, true); + request.setRequestHeader("HX-Request", "true"); request.setRequestHeader("HX-History-Restore-Request", "true"); + request.setRequestHeader("HX-Current-URL", getDocument().location.href); request.onload = function () { if (this.status >= 200 && this.status < 400) { triggerEvent(getDocument().body, "htmx:historyCacheMissLoad", details); @@ -2430,7 +2483,7 @@ return (function () { } function shouldInclude(elt) { - if(elt.name === "" || elt.name == null || elt.disabled) { + if(elt.name === "" || elt.name == null || elt.disabled || closest(elt, "fieldset[disabled]")) { return false; } // ignore "submitter" types (see jQuery src/serialize.js) @@ -2906,7 +2959,7 @@ return (function () { } function hasHeader(xhr, regexp) { - return xhr.getAllResponseHeaders().match(regexp); + return regexp.test(xhr.getAllResponseHeaders()) } function ajaxHelper(verb, path, context) { @@ -3453,7 +3506,11 @@ return (function () { } if (hasHeader(xhr,/HX-Retarget:/i)) { - responseInfo.target = getDocument().querySelector(xhr.getResponseHeader("HX-Retarget")); + if (xhr.getResponseHeader("HX-Retarget") === "this") { + responseInfo.target = elt; + } else { + responseInfo.target = querySelectorExt(elt, xhr.getResponseHeader("HX-Retarget")); + } } var historyUpdate = determineHistoryUpdates(elt, responseInfo); @@ -3752,34 +3809,25 @@ return (function () { //==================================================================== // Initialization //==================================================================== - /** - * We want to initialize the page elements after DOMContentLoaded - * fires, but there isn't always a good way to tell whether - * it has already fired when we get here or not. - */ - function ready(functionToCall) { - // call the function exactly once no matter how many times this is called - var callReadyFunction = function() { - if (!functionToCall) return; - functionToCall(); - functionToCall = null; - }; + var isReady = false + getDocument().addEventListener('DOMContentLoaded', function() { + isReady = true + }) - if (getDocument().readyState === "complete") { - // DOMContentLoaded definitely fired, we can initialize the page - callReadyFunction(); - } - else { - /* DOMContentLoaded *maybe* already fired, wait for - * the next DOMContentLoaded or readystatechange event - */ - getDocument().addEventListener("DOMContentLoaded", function() { - callReadyFunction(); - }); - getDocument().addEventListener("readystatechange", function() { - if (getDocument().readyState !== "complete") return; - callReadyFunction(); - }); + /** + * Execute a function now if DOMContentLoaded has fired, otherwise listen for it. + * + * This function uses isReady because there is no realiable way to ask the browswer whether + * the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded + * firing and readystate=complete. + */ + function ready(fn) { + // Checking readyState here is a failsafe in case the htmx script tag entered the DOM by + // some means other than the initial page load. + if (isReady || getDocument().readyState === 'complete') { + fn(); + } else { + getDocument().addEventListener('DOMContentLoaded', fn); } } @@ -3827,7 +3875,9 @@ return (function () { internalData.xhr.abort(); } }); - var originalPopstate = window.onpopstate; + /** @type {(ev: PopStateEvent) => any} */ + const originalPopstate = window.onpopstate ? window.onpopstate.bind(window) : null; + /** @type {(ev: PopStateEvent) => any} */ window.onpopstate = function (event) { if (event.state && event.state.htmx) { restoreHistory(); diff --git a/www/static/test/attributes/hx-on-wildcard.js b/www/static/test/attributes/hx-on-wildcard.js index b45bb085..9aa3633e 100644 --- a/www/static/test/attributes/hx-on-wildcard.js +++ b/www/static/test/attributes/hx-on-wildcard.js @@ -15,6 +15,14 @@ describe("hx-on:* attribute", function() { delete window.foo; }); + it("can use dashes rather than colons", function () { + var btn = make(""); + btn.click(); + window.foo.should.equal(true); + delete window.foo; + }); + + it("can modify a parameter via htmx:configRequest", function () { this.server.respondWith("POST", "/test", function (xhr) { var params = parseParams(xhr.requestBody); @@ -26,6 +34,17 @@ describe("hx-on:* attribute", function() { btn.innerText.should.equal("bar"); }); + it("can modify a parameter via htmx:configRequest with dashes", function () { + this.server.respondWith("POST", "/test", function (xhr) { + var params = parseParams(xhr.requestBody); + xhr.respond(200, {}, params.foo); + }); + var btn = make(""); + btn.click(); + this.server.respond(); + btn.innerText.should.equal("bar"); + }); + it("expands :: shorthand into htmx:", function () { this.server.respondWith("POST", "/test", function (xhr) { var params = parseParams(xhr.requestBody); @@ -37,6 +56,17 @@ describe("hx-on:* attribute", function() { btn.innerText.should.equal("bar"); }); + it("expands -- shorthand into htmx:", function () { + this.server.respondWith("POST", "/test", function (xhr) { + var params = parseParams(xhr.requestBody); + xhr.respond(200, {}, params.foo); + }); + var btn = make(""); + btn.click(); + this.server.respond(); + btn.innerText.should.equal("bar"); + }); + it("can cancel an event via preventDefault for htmx:config-request", function () { this.server.respondWith("POST", "/test", function (xhr) { xhr.respond(200, {}, ""); @@ -185,7 +215,7 @@ describe("hx-on:* attribute", function() { // check there is just one handler against each event htmx.trigger(div, "increment-foo"); - htmx.trigger(div, "increment-bar"); + htmx.trigger(div, "increment-bar"); window.foo.should.equal(1); window.bar.should.equal(1); diff --git a/www/static/test/attributes/hx-sse.js b/www/static/test/attributes/hx-sse.js index 3bacd758..9fd438b7 100644 --- a/www/static/test/attributes/hx-sse.js +++ b/www/static/test/attributes/hx-sse.js @@ -4,43 +4,53 @@ describe("hx-sse attribute", function() { var listeners = {}; var wasClosed = false; var mockEventSource = { - removeEventListener: function(name) { - delete listeners[name]; + removeEventListener: function(name, l) { + listeners[name] = listeners[name].filter(function(elt, idx, arr) { + if (arr[idx] === l) { + return false; + } + return true; + }) }, - addEventListener: function (message, l) { - listeners[message] = l; + addEventListener: function(message, l) { + if (listeners == undefined) { + listeners[message] = []; + } + listeners[message].push(l) }, - sendEvent: function (eventName, data) { - var listener = listeners[eventName]; - if (listener) { - var event = htmx._("makeEvent")(eventName); - event.data = data; - listener(event); + sendEvent: function(eventName, data) { + var listeners = listeners[eventName]; + if (listeners) { + listeners.forEach(function(listener) { + var event = htmx._("makeEvent")(eventName); + event.data = data; + listener(event); + } } }, - close: function () { + close: function() { wasClosed = true; }, - wasClosed: function () { + wasClosed: function() { return wasClosed; } }; return mockEventSource; } - beforeEach(function () { + beforeEach(function() { this.server = makeServer(); var eventSource = mockEventSource(); this.eventSource = eventSource; clearWorkArea(); - htmx.createEventSource = function(){ return eventSource }; + htmx.createEventSource = function() { return eventSource }; }); - afterEach(function () { + afterEach(function() { this.server.restore(); clearWorkArea(); }); - it('handles basic sse triggering', function () { + it('handles basic sse triggering', function() { this.server.respondWith("GET", "/d1", "div1 updated"); this.server.respondWith("GET", "/d2", "div2 updated"); @@ -61,7 +71,7 @@ describe("hx-sse attribute", function() { byId("d2").innerHTML.should.equal("div2 updated"); }) - it('does not trigger events that arent named', function () { + it('does not trigger events that arent named', function() { this.server.respondWith("GET", "/d1", "div1 updated"); @@ -82,7 +92,7 @@ describe("hx-sse attribute", function() { byId("d1").innerHTML.should.equal("div1 updated"); }) - it('does not trigger events not on descendents', function () { + it('does not trigger events not on descendents', function() { this.server.respondWith("GET", "/d1", "div1 updated"); @@ -102,7 +112,7 @@ describe("hx-sse attribute", function() { byId("d1").innerHTML.should.equal("div1"); }) - it('is closed after removal', function () { + it('is closed after removal', function() { this.server.respondWith("GET", "/test", "Clicked!"); var div = make('
' + '
div1
' + @@ -112,7 +122,7 @@ describe("hx-sse attribute", function() { this.eventSource.wasClosed().should.equal(true) }) - it('is closed after removal with no close and activity', function () { + it('is closed after removal with no close and activity', function() { var div = make('
' + '
div1
' + '
'); @@ -121,7 +131,7 @@ describe("hx-sse attribute", function() { this.eventSource.wasClosed().should.equal(true) }) - it('swaps content properly on SSE swap', function () { + it('swaps content properly on SSE swap', function() { var div = make('
\n' + '
\n' + '
\n' + @@ -136,5 +146,15 @@ describe("hx-sse attribute", function() { byId("d2").innerText.should.equal("Event 2") }) + it('swaps swapped in content', function() { + var div = make('
\n' + + '
\n' + + '
\n' + ) + + this.eventSource.sendEvent("e1", '
') + this.eventSource.sendEvent("e2", 'Event 2') + byId("d2").innerText.should.equal("Event 2") + }) }); diff --git a/www/static/test/attributes/hx-swap.js b/www/static/test/attributes/hx-swap.js index dff1b040..4ff63621 100644 --- a/www/static/test/attributes/hx-swap.js +++ b/www/static/test/attributes/hx-swap.js @@ -197,27 +197,43 @@ describe("hx-swap attribute", function(){ swapSpec(make("
")).swapDelay.should.equal(0) swapSpec(make("
")).settleDelay.should.equal(0) // set to 0 in tests swapSpec(make("
")).swapDelay.should.equal(10) + swapSpec(make("
")).swapDelay.should.equal(0) + swapSpec(make("
")).swapDelay.should.equal(0) swapSpec(make("
")).settleDelay.should.equal(10) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) swapSpec(make("
")).swapDelay.should.equal(10) swapSpec(make("
")).settleDelay.should.equal(11) swapSpec(make("
")).swapDelay.should.equal(10) swapSpec(make("
")).settleDelay.should.equal(11) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) swapSpec(make("
")).settleDelay.should.equal(11) swapSpec(make("
")).settleDelay.should.equal(11) - + swapSpec(make("
")).swapStyle.should.equal("innerHTML") swapSpec(make("
")).swapDelay.should.equal(10) + swapSpec(make("
")).swapDelay.should.equal(0); + swapSpec(make("
")).swapDelay.should.equal(0); swapSpec(make("
")).swapStyle.should.equal("innerHTML") swapSpec(make("
")).settleDelay.should.equal(10) - + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).swapStyle.should.equal("innerHTML") swapSpec(make("
")).swapDelay.should.equal(10) swapSpec(make("
")).settleDelay.should.equal(11) + swapSpec(make("
")).swapDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) swapSpec(make("
")).swapStyle.should.equal("innerHTML") swapSpec(make("
")).swapDelay.should.equal(10) swapSpec(make("
")).settleDelay.should.equal(11) + swapSpec(make("
")).swapDelay.should.equal(10) + swapSpec(make("
")).settleDelay.should.equal(0) swapSpec(make("
")).swapStyle.should.equal("customstyle") }) @@ -234,6 +250,17 @@ describe("hx-swap attribute", function(){ }, 30); }); + it("works immediately with no swap delay", function (done) { + this.server.respondWith("GET", "/test", "Clicked!"); + var div = make( + "
" + ); + div.click(); + this.server.respond(); + div.innerText.should.equal("Clicked!"); + done(); + }); + it('works with a settle delay', function(done) { this.server.respondWith("GET", "/test", "
"); var div = make("
"); @@ -246,6 +273,24 @@ describe("hx-swap attribute", function(){ }, 30); }); + it("works with no settle delay", function (done) { + this.server.respondWith( + "GET", + "/test", + "
" + ); + var div = make( + "
" + ); + div.click(); + this.server.respond(); + div.classList.contains("foo").should.equal(false); + setTimeout(function () { + byId("d1").classList.contains("foo").should.equal(true); + done(); + }, 30); + }); + it('swap outerHTML properly w/ data-* prefix', function() { this.server.respondWith("GET", "/test", 'Click Me'); diff --git a/www/static/test/attributes/hx-trigger.js b/www/static/test/attributes/hx-trigger.js index 0cbb4adf..f65f974b 100644 --- a/www/static/test/attributes/hx-trigger.js +++ b/www/static/test/attributes/hx-trigger.js @@ -211,14 +211,20 @@ describe("hx-trigger attribute", function(){ var specExamples = { "": [{trigger: 'click'}], "every 1s": [{trigger: 'every', pollInterval: 1000}], + "every 0s": [{trigger: 'every', pollInterval: 0}], + "every 0ms": [{trigger: 'every', pollInterval: 0}], "click": [{trigger: 'click'}], "customEvent": [{trigger: 'customEvent'}], "event changed": [{trigger: 'event', changed: true}], "event once": [{trigger: 'event', once: true}], - "event delay:1s": [{trigger: 'event', delay: 1000}], "event throttle:1s": [{trigger: 'event', throttle: 1000}], - "event delay:1s, foo": [{trigger: 'event', delay: 1000}, {trigger: 'foo'}], + "event throttle:0s": [{trigger: 'event', throttle: 0}], + "event throttle:0ms": [{trigger: 'event', throttle: 0}], "event throttle:1s, foo": [{trigger: 'event', throttle: 1000}, {trigger: 'foo'}], + "event delay:1s": [{trigger: 'event', delay: 1000}], + "event delay:1s, foo": [{trigger: 'event', delay: 1000}, {trigger: 'foo'}], + "event delay:0s, foo": [{trigger: 'event', delay: 0}, {trigger: 'foo'}], + "event delay:0ms, foo": [{trigger: 'event', delay: 0}, {trigger: 'foo'}], "event changed once delay:1s": [{trigger: 'event', changed: true, once: true, delay: 1000}], "event1,event2": [{trigger: 'event1'}, {trigger: 'event2'}], "event1, event2": [{trigger: 'event1'}, {trigger: 'event2'}], @@ -678,6 +684,37 @@ describe("hx-trigger attribute", function(){ }, 50); }); + it("A throttle of 0 does not multiple requests from happening", function (done) { + var requests = 0; + var server = this.server; + server.respondWith("GET", "/test", function (xhr) { + requests++; + xhr.respond(200, {}, "Requests: " + requests); + }); + server.respondWith("GET", "/bar", "bar"); + var div = make( + "
" + ); + + div.click(); + server.respond(); + div.innerText.should.equal("Requests: 1"); + + div.click(); + server.respond(); + div.innerText.should.equal("Requests: 2"); + + div.click(); + server.respond(); + div.innerText.should.equal("Requests: 3"); + + div.click(); + server.respond(); + div.innerText.should.equal("Requests: 4"); + + done() + }); + it('delay delays the request', function(done) { var requests = 0; @@ -714,6 +751,37 @@ describe("hx-trigger attribute", function(){ }, 50); }); + it("A 0 delay does not delay the request", function (done) { + var requests = 0; + this.server.respondWith("GET", "/test", function (xhr) { + requests++; + xhr.respond(200, {}, "Requests: " + requests); + }); + this.server.respondWith("GET", "/bar", "bar"); + var div = make( + "
" + ); + + div.click(); + this.server.respond(); + div.innerText.should.equal("Requests: 1"); + + div.click(); + this.server.respond(); + div.innerText.should.equal("Requests: 2"); + + div.click(); + this.server.respond(); + div.innerText.should.equal("Requests: 3"); + + div.click(); + this.server.respond(); + div.innerText.should.equal("Requests: 4"); + + done(); + }); + + it('requests are queued with last one winning by default', function() { var requests = 0; @@ -954,5 +1022,56 @@ describe("hx-trigger attribute", function(){ make('
Not Called
'); }); + it("uses trigger specs cache if defined", function () { + var initialCacheConfig = htmx.config.triggerSpecsCache + htmx.config.triggerSpecsCache = {} + var specExamples = { + "every 1s": [{trigger: 'every', pollInterval: 1000}], + "click": [{trigger: 'click'}], + "customEvent": [{trigger: 'customEvent'}], + "event changed": [{trigger: 'event', changed: true}], + "event once": [{trigger: 'event', once: true}], + "event delay:1s": [{trigger: 'event', delay: 1000}], + "event throttle:1s": [{trigger: 'event', throttle: 1000}], + "event delay:1s, foo": [{trigger: 'event', delay: 1000}, {trigger: 'foo'}], + "event throttle:1s, foo": [{trigger: 'event', throttle: 1000}, {trigger: 'foo'}], + "event changed once delay:1s": [{trigger: 'event', changed: true, once: true, delay: 1000}], + "event1,event2": [{trigger: 'event1'}, {trigger: 'event2'}], + "event1, event2": [{trigger: 'event1'}, {trigger: 'event2'}], + "event1 once, event2 changed": [{trigger: 'event1', once: true}, {trigger: 'event2', changed: true}], + } + + for (var specString in specExamples) { + var div = make("
"); + var spec = htmx._('getTriggerSpecs')(div); + spec.should.deep.equal(specExamples[specString], "Found : " + JSON.stringify(spec) + ", expected : " + JSON.stringify(specExamples[specString]) + " for spec: " + specString); + } + + Object.keys(htmx.config.triggerSpecsCache).length.should.greaterThan(0) + Object.keys(htmx.config.triggerSpecsCache).length.should.equal(Object.keys(specExamples).length) + + htmx.config.triggerSpecsCache = initialCacheConfig + }) + + it("correctly reuses trigger specs from the cache if defined", function () { + var initialCacheConfig = htmx.config.triggerSpecsCache + htmx.config.triggerSpecsCache = {} + + var triggerStr = "event changed once delay:1s" + var expectedSpec = [{trigger: 'event', changed: true, once: true, delay: 1000}] + + var div = make("
"); + var spec = htmx._('getTriggerSpecs')(div); + spec.should.deep.equal(expectedSpec, "Found : " + JSON.stringify(spec) + ", expected : " + JSON.stringify(expectedSpec) + " for spec: " + triggerStr); + spec.push("This should be carried to further identical specs thanks to the cache") + + var div2 = make("
"); + var spec2 = htmx._('getTriggerSpecs')(div2); + spec2.should.deep.equal(spec, "Found : " + JSON.stringify(spec) + ", expected : " + JSON.stringify(spec2) + " for cached spec: " + triggerStr); + + Object.keys(htmx.config.triggerSpecsCache).length.should.equal(1) + + htmx.config.triggerSpecsCache = initialCacheConfig + }) }) diff --git a/www/static/test/core/headers.js b/www/static/test/core/headers.js index af357d37..7e127ece 100644 --- a/www/static/test/core/headers.js +++ b/www/static/test/core/headers.js @@ -348,4 +348,31 @@ describe("Core htmx AJAX headers", function () { this.server.respond(); div.innerHTML.should.equal('
Yay! Welcome
'); }) + + it('request to restore history should include the HX-Request header', function () { + this.server.respondWith('GET', '/test', function (xhr) { + xhr.requestHeaders['HX-Request'].should.be.equal('true'); + xhr.respond(200, {}, ''); + }); + htmx._('loadHistoryFromServer')('/test'); + this.server.respond(); + }); + + it('request to restore history should include the HX-History-Restore-Request header', function () { + this.server.respondWith('GET', '/test', function (xhr) { + xhr.requestHeaders['HX-History-Restore-Request'].should.be.equal('true'); + xhr.respond(200, {}, ''); + }); + htmx._('loadHistoryFromServer')('/test'); + this.server.respond(); + }); + + it('request to restore history should include the HX-Current-URL header', function () { + this.server.respondWith('GET', '/test', function (xhr) { + chai.assert(typeof xhr.requestHeaders['HX-Current-URL'] !== 'undefined', 'HX-Current-URL should not be undefined'); + xhr.respond(200, {}, ''); + }); + htmx._('loadHistoryFromServer')('/test'); + this.server.respond(); + }); }); diff --git a/www/static/test/core/internals.js b/www/static/test/core/internals.js index 1de1fb98..3d689293 100644 --- a/www/static/test/core/internals.js +++ b/www/static/test/core/internals.js @@ -76,9 +76,14 @@ describe("Core htmx internals Tests", function() { it("handles parseInterval correctly", function() { chai.expect(htmx.parseInterval("1ms")).to.be.equal(1); chai.expect(htmx.parseInterval("300ms")).to.be.equal(300); - chai.expect(htmx.parseInterval("1s")).to.be.equal(1000) - chai.expect(htmx.parseInterval("1.5s")).to.be.equal(1500) - chai.expect(htmx.parseInterval("2s")).to.be.equal(2000) + chai.expect(htmx.parseInterval("1s")).to.be.equal(1000); + chai.expect(htmx.parseInterval("1.5s")).to.be.equal(1500); + chai.expect(htmx.parseInterval("2s")).to.be.equal(2000); + chai.expect(htmx.parseInterval("0ms")).to.be.equal(0); + chai.expect(htmx.parseInterval("0s")).to.be.equal(0); + chai.expect(htmx.parseInterval("0m")).to.be.equal(0); + chai.expect(htmx.parseInterval("0")).to.be.equal(0); + chai.expect(htmx.parseInterval("5")).to.be.equal(5); chai.expect(htmx.parseInterval(null)).to.be.undefined chai.expect(htmx.parseInterval("")).to.be.undefined diff --git a/www/static/test/core/parameters.js b/www/static/test/core/parameters.js index fa6f6257..b395ac7d 100644 --- a/www/static/test/core/parameters.js +++ b/www/static/test/core/parameters.js @@ -239,5 +239,12 @@ describe("Core htmx Parameter Handling", function() { } }); + it('Input within disabled fieldset is excluded', function () { + var input = make('
'); + var vals = htmx._('getInputValues')(input, "get").values; + vals['foo'].should.equal('bar'); + should.equal(vals["do"], undefined); + }) + }); diff --git a/www/static/test/ext/path-params.js b/www/static/test/ext/path-params.js new file mode 100644 index 00000000..3f0ac3a3 --- /dev/null +++ b/www/static/test/ext/path-params.js @@ -0,0 +1,51 @@ +describe("path-params extension", function() { + beforeEach(function () { + this.server = makeServer(); + clearWorkArea(); + }); + afterEach(function () { + this.server.restore(); + clearWorkArea(); + }); + + it('uses parameter to populate path variable', function () { + var request; + htmx.on("htmx:beforeRequest", function (evt) { + request = evt; + }); + var div = make("
") + div.click(); + should.equal(request.detail.requestConfig.path, '/items/42'); + }); + + it('parameter is removed when used', function () { + var request; + htmx.on("htmx:beforeRequest", function (evt) { + request = evt; + }); + var div = make("
") + div.click(); + should.equal(request.detail.requestConfig.parameters.other, 43); + should.equal(request.detail.requestConfig.parameters.itemId, undefined); + }); + + it('parameter value is encoded', function () { + var request; + htmx.on("htmx:beforeRequest", function (evt) { + request = evt; + }); + var div = make("
") + div.click(); + should.equal(request.detail.requestConfig.path, '/items/a%2Fb'); + }); + + it('missing variables are ignored', function () { + var request; + htmx.on("htmx:beforeRequest", function (evt) { + request = evt; + }); + var div = make("
") + div.click(); + should.equal(request.detail.requestConfig.path, '/items/42/{subitem}'); + }); +}); diff --git a/www/static/test/ext/sse.js b/www/static/test/ext/sse.js new file mode 100644 index 00000000..a9702525 --- /dev/null +++ b/www/static/test/ext/sse.js @@ -0,0 +1,232 @@ +const { assert } = require("chai"); + +describe("sse extension", function() { + + function mockEventSource() { + var listeners = {}; + var wasClosed = false; + var url; + var mockEventSource = { + removeEventListener: function(name, l) { + listeners[name] = listeners[name].filter(function(elt, idx, arr) { + if (arr[idx] === l) { + return false; + } + return true; + }) + }, + addEventListener: function(message, l) { + if (listeners == undefined) { + listeners[message] = []; + } + listeners[message].push(l) + }, + sendEvent: function(eventName, data) { + var listeners = listeners[eventName]; + if (listeners) { + listeners.forEach(function(listener) { + var event = htmx._("makeEvent")(eventName); + event.data = data; + listener(event); + } + } + }, + close: function() { + wasClosed = true; + }, + wasClosed: function() { + return wasClosed; + }, + connect: function(url) { + this.url = url + } + }; + return mockEventSource; + } + + beforeEach(function() { + this.server = makeServer(); + var eventSource = mockEventSource(); + this.eventSource = eventSource; + clearWorkArea(); + htmx.createEventSource = function(url) { + eventSource.connect(url); + return eventSource; + }; + }); + afterEach(function() { + this.server.restore(); + clearWorkArea(); + }); + + it('handles basic sse triggering', function() { + + this.server.respondWith("GET", "/d1", "div1 updated"); + this.server.respondWith("GET", "/d2", "div2 updated"); + + var div = make('
' + + '
div1
' + + '
div2
' + + '
'); + + this.eventSource.sendEvent("e1"); + this.server.respond(); + byId("d1").innerHTML.should.equal("div1 updated"); + byId("d2").innerHTML.should.equal("div2"); + + this.eventSource.sendEvent("e2"); + this.server.respond(); + byId("d1").innerHTML.should.equal("div1 updated"); + byId("d2").innerHTML.should.equal("div2 updated"); + }) + + it('does not trigger events that arent named', function() { + + this.server.respondWith("GET", "/d1", "div1 updated"); + + var div = make('
' + + '
div1
' + + '
'); + + this.eventSource.sendEvent("foo"); + this.server.respond(); + byId("d1").innerHTML.should.equal("div1"); + + this.eventSource.sendEvent("e2"); + this.server.respond(); + byId("d1").innerHTML.should.equal("div1"); + + this.eventSource.sendEvent("e1"); + this.server.respond(); + byId("d1").innerHTML.should.equal("div1 updated"); + }) + + it('does not trigger events not on descendents', function() { + + this.server.respondWith("GET", "/d1", "div1 updated"); + + var div = make('
' + + '
div1
'); + + this.eventSource.sendEvent("foo"); + this.server.respond(); + byId("d1").innerHTML.should.equal("div1"); + + this.eventSource.sendEvent("e2"); + this.server.respond(); + byId("d1").innerHTML.should.equal("div1"); + + this.eventSource.sendEvent("e1"); + this.server.respond(); + byId("d1").innerHTML.should.equal("div1"); + }) + + it('is closed after removal, hx-trigger', function() { + this.server.respondWith("GET", "/test", "Clicked!"); + var div = make('
' + + '
div1
' + + '
'); + div.click(); + this.server.respond(); + this.eventSource.wasClosed().should.equal(true) + }) + + it('is closed after removal, hx-swap', function() { + this.server.respondWith("GET", "/test", "Clicked!"); + var div = make('
' + + '
div1
' + + '
'); + div.click(); + this.server.respond(); + this.eventSource.wasClosed().should.equal(true) + }) + + it('is closed after removal with no close and activity, hx-trigger', function() { + var div = make('
' + + '
div1
' + + '
'); + div.parentElement.removeChild(div); + this.eventSource.sendEvent("e1") + this.eventSource.wasClosed().should.equal(true) + }) + + // sse and hx-trigger handlers are distinct + it('is closed after removal with no close and activity, sse-swap', function() { + var div = make('
' + + '
div1
' + + '
'); + div.parentElement.removeChild(div); + this.eventSource.sendEvent("e1") + this.eventSource.wasClosed().should.equal(true) + }) + + it('swaps content properly on SSE swap', function() { + var div = make('
\n' + + '
\n' + + '
\n' + + '
\n'); + byId("d1").innerText.should.equal("") + byId("d2").innerText.should.equal("") + this.eventSource.sendEvent("e1", "Event 1") + byId("d1").innerText.should.equal("Event 1") + byId("d2").innerText.should.equal("") + this.eventSource.sendEvent("e2", "Event 2") + byId("d1").innerText.should.equal("Event 1") + byId("d2").innerText.should.equal("Event 2") + }) + + it('swaps swapped in content', function() { + var div = make('
\n' + + '
\n' + + '
\n' + ) + + this.eventSource.sendEvent("e1", '
') + this.eventSource.sendEvent("e2", 'Event 2') + byId("d2").innerText.should.equal("Event 2") + }) + + it('works in a child of an hx-ext="sse" element', function() { + var div = make('
\n' + + '
div1
\n' + + '
\n' + ) + this.eventSource.url = "/event_stream" + }) + + it('only adds sseEventSource to elements with sse-connect', function() { + var div = make('
\n' + + '
\n' + + '
'); + + (byId('d1')["htmx-internal-data"].sseEventSource == undefined).should.be.true + + // Even when content is swapped in + this.eventSource.sendEvent("e1", '
'); + + (byId('d2')["htmx-internal-data"].sseEventSource == undefined).should.be.true + }) + + it('initializes connections in swapped content', function() { + this.server.respondWith("GET", "/d1", '
div2
'); + this.server.respondWith("GET", "/d2", "div2 updated"); + + var div = make('
'); + div.click(); + + this.server.respond(); + this.eventSource.sendEvent("e2"); + this.server.respond(); + + byId("d2").innerHTML.should.equal("div2 updated"); + }) + + it('creates an eventsource on elements with sse-connect', function() { + var div = make('
'); + + (byId("d1")['htmx-internal-data'].sseEventSource == undefined).should.be.false; + + }) + +}); + diff --git a/www/static/test/index.html b/www/static/test/index.html index eb6f15b1..aa128c54 100644 --- a/www/static/test/index.html +++ b/www/static/test/index.html @@ -158,9 +158,15 @@ + + + + + + @@ -174,7 +180,7 @@ Work Area
-
+
diff --git a/www/static/test/manual/hxboost_partial_template_parsing/index-partial.html b/www/static/test/manual/hxboost_partial_template_parsing/index-partial.html new file mode 100644 index 00000000..4ec55342 --- /dev/null +++ b/www/static/test/manual/hxboost_partial_template_parsing/index-partial.html @@ -0,0 +1,8 @@ + + Index content + +

# Index

+
    +
  • <title> should not spawn inside <main>
  • +
  • <title> should be index content
  • +
\ No newline at end of file diff --git a/www/static/test/manual/hxboost_partial_template_parsing/index.html b/www/static/test/manual/hxboost_partial_template_parsing/index.html new file mode 100644 index 00000000..dd8a93ff --- /dev/null +++ b/www/static/test/manual/hxboost_partial_template_parsing/index.html @@ -0,0 +1,41 @@ + + + + + + + Index content + + + + + + + +
+
+
+
+

# Index

+
    +
  • <title> should not spawn inside <main>
  • +
  • <title> should be index content
  • +
+
+ + \ No newline at end of file diff --git a/www/static/test/manual/hxboost_partial_template_parsing/other-content.html b/www/static/test/manual/hxboost_partial_template_parsing/other-content.html new file mode 100644 index 00000000..2c749abe --- /dev/null +++ b/www/static/test/manual/hxboost_partial_template_parsing/other-content.html @@ -0,0 +1,16 @@ + + Other content + + +

# Swapped content

+
    +
  • <title> and <style> + should not spawn inside <main>
  • +
  • <title> should be other content
  • +
  • Background should be green
  • +
+ diff --git a/www/static/test/manual/index.html b/www/static/test/manual/index.html index 99b25d65..19f41b5f 100644 --- a/www/static/test/manual/index.html +++ b/www/static/test/manual/index.html @@ -41,6 +41,7 @@ diff --git a/www/themes/htmx-theme/static/js/htmx.js b/www/themes/htmx-theme/static/js/htmx.js index 1f0709d7..acd70dc0 100644 --- a/www/themes/htmx-theme/static/js/htmx.js +++ b/www/themes/htmx-theme/static/js/htmx.js @@ -76,7 +76,8 @@ return (function () { methodsThatUseUrlParams: ["get"], selfRequestsOnly: false, ignoreTitle: false, - scrollIntoViewOnBoost: true + scrollIntoViewOnBoost: true, + triggerSpecsCache: null, }, parseInterval:parseInterval, _:internalEval, @@ -88,7 +89,7 @@ return (function () { sock.binaryType = htmx.config.wsBinaryType; return sock; }, - version: "1.9.9" + version: "1.9.10" }; /** @type {import("./htmx").HtmxInternalApi} */ @@ -127,24 +128,40 @@ return (function () { return "[hx-" + verb + "], [data-hx-" + verb + "]" }).join(", "); + var HEAD_TAG_REGEX = makeTagRegEx('head'), + TITLE_TAG_REGEX = makeTagRegEx('title'), + SVG_TAGS_REGEX = makeTagRegEx('svg', true); + //==================================================================== // Utilities //==================================================================== + /** + * @param {string} tag + * @param {boolean} global + * @returns {RegExp} + */ + function makeTagRegEx(tag, global = false) { + return new RegExp(`<${tag}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${tag}>`, + global ? 'gim' : 'im'); + } + function parseInterval(str) { if (str == undefined) { - return undefined + return undefined; } + + let interval = NaN; if (str.slice(-2) == "ms") { - return parseFloat(str.slice(0,-2)) || undefined + interval = parseFloat(str.slice(0, -2)); + } else if (str.slice(-1) == "s") { + interval = parseFloat(str.slice(0, -1)) * 1000; + } else if (str.slice(-1) == "m") { + interval = parseFloat(str.slice(0, -1)) * 1000 * 60; + } else { + interval = parseFloat(str); } - if (str.slice(-1) == "s") { - return (parseFloat(str.slice(0,-1)) * 1000) || undefined - } - if (str.slice(-1) == "m") { - return (parseFloat(str.slice(0,-1)) * 1000 * 60) || undefined - } - return parseFloat(str) || undefined + return isNaN(interval) ? undefined : interval; } /** @@ -276,43 +293,46 @@ return (function () { } function aFullPageResponse(resp) { - return resp.match(/", 0); + var documentFragment = parseHTML("", 0); // @ts-ignore type mismatch between DocumentFragment and Element. // TODO: Are these close enough for htmx to use interchangeably? return documentFragment.querySelector('template').content; - } else { - var startTag = getStartTag(resp); - switch (startTag) { - case "thead": - case "tbody": - case "tfoot": - case "colgroup": - case "caption": - return parseHTML("" + resp + "
", 1); - case "col": - return parseHTML("" + resp + "
", 2); - case "tr": - return parseHTML("" + resp + "
", 2); - case "td": - case "th": - return parseHTML("" + resp + "
", 3); - case "script": - case "style": - return parseHTML("
" + resp + "
", 1); - default: - return parseHTML(resp, 0); - } + } + switch (startTag) { + case "thead": + case "tbody": + case "tfoot": + case "colgroup": + case "caption": + return parseHTML("" + content + "
", 1); + case "col": + return parseHTML("" + content + "
", 2); + case "tr": + return parseHTML("" + content + "
", 2); + case "td": + case "th": + return parseHTML("" + content + "
", 3); + case "script": + case "style": + return parseHTML("
" + content + "
", 1); + default: + return parseHTML(content, 0); } } @@ -450,7 +470,7 @@ return (function () { path = url.pathname + url.search; } // remove trailing slash, unless index page - if (!path.match('^/$')) { + if (!(/^\/$/.test(path))) { path = path.replace(/\/+$/, ''); } return path; @@ -827,7 +847,7 @@ return (function () { var oobSelects = getClosestAttributeValue(elt, "hx-select-oob"); if (oobSelects) { var oobSelectValues = oobSelects.split(","); - for (let i = 0; i < oobSelectValues.length; i++) { + for (var i = 0; i < oobSelectValues.length; i++) { var oobSelectValue = oobSelectValues[i].split(":", 2); var id = oobSelectValue[0].trim(); if (id.indexOf("#") === 0) { @@ -934,7 +954,7 @@ return (function () { function deInitOnHandlers(elt) { var internalData = getInternalData(elt); if (internalData.onHandlers) { - for (let i = 0; i < internalData.onHandlers.length; i++) { + for (var i = 0; i < internalData.onHandlers.length; i++) { const handlerInfo = internalData.onHandlers[i]; elt.removeEventListener(handlerInfo.event, handlerInfo.listener); } @@ -960,10 +980,8 @@ return (function () { } }); } - if (internalData.initHash) { - internalData.initHash = null - } deInitOnHandlers(element); + forEach(Object.keys(internalData), function(key) { delete internalData[key] }); } function cleanUpElement(element) { @@ -987,7 +1005,6 @@ return (function () { } else { newElt = eltBeforeNewContent.nextSibling; } - getInternalData(target).replacedWith = newElt; // tuck away so we can fire events on it later settleInfo.elts = settleInfo.elts.filter(function(e) { return e != target }); while(newElt && newElt !== target) { if (newElt.nodeType === Node.ELEMENT_NODE) { @@ -1099,9 +1116,8 @@ return (function () { function findTitle(content) { if (content.indexOf(' -1) { - var contentWithSvgsRemoved = content.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - var result = contentWithSvgsRemoved.match(/]*>|>)([\s\S]*?)<\/title>/im); - + var contentWithSvgsRemoved = content.replace(SVG_TAGS_REGEX, ''); + var result = contentWithSvgsRemoved.match(TITLE_TAG_REGEX); if (result) { return result[2]; } @@ -1230,7 +1246,7 @@ return (function () { function consumeUntil(tokens, match) { var result = ""; - while (tokens.length > 0 && !tokens[0].match(match)) { + while (tokens.length > 0 && !match.test(tokens[0])) { result += tokens.shift(); } return result; @@ -1250,6 +1266,99 @@ return (function () { var INPUT_SELECTOR = 'input, textarea, select'; + /** + * @param {HTMLElement} elt + * @param {string} explicitTrigger + * @param {cache} cache for trigger specs + * @returns {import("./htmx").HtmxTriggerSpecification[]} + */ + function parseAndCacheTrigger(elt, explicitTrigger, cache) { + var triggerSpecs = []; + var tokens = tokenizeString(explicitTrigger); + do { + consumeUntil(tokens, NOT_WHITESPACE); + var initialLength = tokens.length; + var trigger = consumeUntil(tokens, /[,\[\s]/); + if (trigger !== "") { + if (trigger === "every") { + var every = {trigger: 'every'}; + consumeUntil(tokens, NOT_WHITESPACE); + every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/)); + consumeUntil(tokens, NOT_WHITESPACE); + var eventFilter = maybeGenerateConditional(elt, tokens, "event"); + if (eventFilter) { + every.eventFilter = eventFilter; + } + triggerSpecs.push(every); + } else if (trigger.indexOf("sse:") === 0) { + triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)}); + } else { + var triggerSpec = {trigger: trigger}; + var eventFilter = maybeGenerateConditional(elt, tokens, "event"); + if (eventFilter) { + triggerSpec.eventFilter = eventFilter; + } + while (tokens.length > 0 && tokens[0] !== ",") { + consumeUntil(tokens, NOT_WHITESPACE) + var token = tokens.shift(); + if (token === "changed") { + triggerSpec.changed = true; + } else if (token === "once") { + triggerSpec.once = true; + } else if (token === "consume") { + triggerSpec.consume = true; + } else if (token === "delay" && tokens[0] === ":") { + tokens.shift(); + triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); + } else if (token === "from" && tokens[0] === ":") { + tokens.shift(); + if (COMBINED_SELECTOR_START.test(tokens[0])) { + var from_arg = consumeCSSSelector(tokens); + } else { + var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); + if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { + tokens.shift(); + var selector = consumeCSSSelector(tokens); + // `next` and `previous` allow a selector-less syntax + if (selector.length > 0) { + from_arg += " " + selector; + } + } + } + triggerSpec.from = from_arg; + } else if (token === "target" && tokens[0] === ":") { + tokens.shift(); + triggerSpec.target = consumeCSSSelector(tokens); + } else if (token === "throttle" && tokens[0] === ":") { + tokens.shift(); + triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); + } else if (token === "queue" && tokens[0] === ":") { + tokens.shift(); + triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA); + } else if (token === "root" && tokens[0] === ":") { + tokens.shift(); + triggerSpec[token] = consumeCSSSelector(tokens); + } else if (token === "threshold" && tokens[0] === ":") { + tokens.shift(); + triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA); + } else { + triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); + } + } + triggerSpecs.push(triggerSpec); + } + } + if (tokens.length === initialLength) { + triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); + } + consumeUntil(tokens, NOT_WHITESPACE); + } while (tokens[0] === "," && tokens.shift()) + if (cache) { + cache[explicitTrigger] = triggerSpecs + } + return triggerSpecs + } + /** * @param {HTMLElement} elt * @returns {import("./htmx").HtmxTriggerSpecification[]} @@ -1258,85 +1367,8 @@ return (function () { var explicitTrigger = getAttributeValue(elt, 'hx-trigger'); var triggerSpecs = []; if (explicitTrigger) { - var tokens = tokenizeString(explicitTrigger); - do { - consumeUntil(tokens, NOT_WHITESPACE); - var initialLength = tokens.length; - var trigger = consumeUntil(tokens, /[,\[\s]/); - if (trigger !== "") { - if (trigger === "every") { - var every = {trigger: 'every'}; - consumeUntil(tokens, NOT_WHITESPACE); - every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/)); - consumeUntil(tokens, NOT_WHITESPACE); - var eventFilter = maybeGenerateConditional(elt, tokens, "event"); - if (eventFilter) { - every.eventFilter = eventFilter; - } - triggerSpecs.push(every); - } else if (trigger.indexOf("sse:") === 0) { - triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)}); - } else { - var triggerSpec = {trigger: trigger}; - var eventFilter = maybeGenerateConditional(elt, tokens, "event"); - if (eventFilter) { - triggerSpec.eventFilter = eventFilter; - } - while (tokens.length > 0 && tokens[0] !== ",") { - consumeUntil(tokens, NOT_WHITESPACE) - var token = tokens.shift(); - if (token === "changed") { - triggerSpec.changed = true; - } else if (token === "once") { - triggerSpec.once = true; - } else if (token === "consume") { - triggerSpec.consume = true; - } else if (token === "delay" && tokens[0] === ":") { - tokens.shift(); - triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); - } else if (token === "from" && tokens[0] === ":") { - tokens.shift(); - if (COMBINED_SELECTOR_START.test(tokens[0])) { - var from_arg = consumeCSSSelector(tokens); - } else { - var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); - if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { - tokens.shift(); - var selector = consumeCSSSelector(tokens); - // `next` and `previous` allow a selector-less syntax - if (selector.length > 0) { - from_arg += " " + selector; - } - } - } - triggerSpec.from = from_arg; - } else if (token === "target" && tokens[0] === ":") { - tokens.shift(); - triggerSpec.target = consumeCSSSelector(tokens); - } else if (token === "throttle" && tokens[0] === ":") { - tokens.shift(); - triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); - } else if (token === "queue" && tokens[0] === ":") { - tokens.shift(); - triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA); - } else if (token === "root" && tokens[0] === ":") { - tokens.shift(); - triggerSpec[token] = consumeCSSSelector(tokens); - } else if (token === "threshold" && tokens[0] === ":") { - tokens.shift(); - triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA); - } else { - triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); - } - } - triggerSpecs.push(triggerSpec); - } - } - if (tokens.length === initialLength) { - triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); - } - consumeUntil(tokens, NOT_WHITESPACE); - } while (tokens[0] === "," && tokens.shift()) + var cache = htmx.config.triggerSpecsCache + triggerSpecs = (cache && cache[explicitTrigger]) || parseAndCacheTrigger(elt, explicitTrigger, cache) } if (triggerSpecs.length > 0) { @@ -1508,14 +1540,14 @@ return (function () { return; } - if (triggerSpec.throttle) { + if (triggerSpec.throttle > 0) { if (!elementData.throttle) { handler(elt, evt); elementData.throttle = setTimeout(function () { elementData.throttle = null; }, triggerSpec.throttle); } - } else if (triggerSpec.delay) { + } else if (triggerSpec.delay > 0) { elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay); } else { triggerEvent(elt, 'htmx:trigger') @@ -1792,7 +1824,7 @@ return (function () { handler(elt); } } - if (delay) { + if (delay > 0) { setTimeout(load, delay); } else { load(); @@ -1851,7 +1883,7 @@ return (function () { if (!maybeFilterEvent(triggerSpec, elt, makeEvent("load", {elt: elt}))) { loadImmediately(elt, handler, nodeData, triggerSpec.delay); } - } else if (triggerSpec.pollInterval) { + } else if (triggerSpec.pollInterval > 0) { nodeData.polling = true; processPolling(elt, handler, triggerSpec); } else { @@ -1894,26 +1926,35 @@ return (function () { }); } - function hasChanceOfBeingBoosted() { - return document.querySelector("[hx-boost], [data-hx-boost]"); + function shouldProcessHxOn(elt) { + var attributes = elt.attributes + for (var j = 0; j < attributes.length; j++) { + var attrName = attributes[j].name + if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:") || + startsWith(attrName, "hx-on-") || startsWith(attrName, "data-hx-on-")) { + return true + } + } + return false } function findHxOnWildcardElements(elt) { var node = null var elements = [] + if (shouldProcessHxOn(elt)) { + elements.push(elt) + } + if (document.evaluate) { - var iter = document.evaluate('//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") ]]', elt) + var iter = document.evaluate('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or' + + ' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]', elt) while (node = iter.iterateNext()) elements.push(node) } else { - var allElements = document.getElementsByTagName("*") + var allElements = elt.getElementsByTagName("*") for (var i = 0; i < allElements.length; i++) { - var attributes = allElements[i].attributes - for (var j = 0; j < attributes.length; j++) { - var attrName = attributes[j].name - if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:")) { - elements.push(allElements[i]) - } + if (shouldProcessHxOn(allElements[i])) { + elements.push(allElements[i]) } } } @@ -1923,8 +1964,8 @@ return (function () { function findElementsToProcess(elt) { if (elt.querySelectorAll) { - var boostedElts = hasChanceOfBeingBoosted() ? ", a" : ""; - var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," + + var boostedSelector = ", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]"; + var results = elt.querySelectorAll(VERB_SELECTOR + boostedSelector + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," + " [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]"); return results; } else { @@ -1970,7 +2011,7 @@ return (function () { function countCurlies(line) { var tokens = tokenizeString(line); var netCurlies = 0; - for (let i = 0; i < tokens.length; i++) { + for (var i = 0; i < tokens.length; i++) { const token = tokens[i]; if (token === "{") { netCurlies++; @@ -2032,12 +2073,22 @@ return (function () { for (var i = 0; i < elt.attributes.length; i++) { var name = elt.attributes[i].name var value = elt.attributes[i].value - if (startsWith(name, "hx-on:") || startsWith(name, "data-hx-on:")) { - let eventName = name.slice(name.indexOf(":") + 1) - // if the eventName starts with a colon, prepend "htmx" for shorthand support - if (startsWith(eventName, ":")) eventName = "htmx" + eventName + if (startsWith(name, "hx-on") || startsWith(name, "data-hx-on")) { + var afterOnPosition = name.indexOf("-on") + 3; + var nextChar = name.slice(afterOnPosition, afterOnPosition + 1); + if (nextChar === "-" || nextChar === ":") { + var eventName = name.slice(afterOnPosition + 1); + // if the eventName starts with a colon or dash, prepend "htmx" for shorthand support + if (startsWith(eventName, ":")) { + eventName = "htmx" + eventName + } else if (startsWith(eventName, "-")) { + eventName = "htmx:" + eventName.slice(1); + } else if (startsWith(eventName, "htmx-")) { + eventName = "htmx:" + eventName.slice(5); + } - addHxOnEventHandler(elt, eventName, value) + addHxOnEventHandler(elt, eventName, value) + } } } } @@ -2315,7 +2366,9 @@ return (function () { var details = {path: path, xhr:request}; triggerEvent(getDocument().body, "htmx:historyCacheMiss", details); request.open('GET', path, true); + request.setRequestHeader("HX-Request", "true"); request.setRequestHeader("HX-History-Restore-Request", "true"); + request.setRequestHeader("HX-Current-URL", getDocument().location.href); request.onload = function () { if (this.status >= 200 && this.status < 400) { triggerEvent(getDocument().body, "htmx:historyCacheMissLoad", details); @@ -2430,7 +2483,7 @@ return (function () { } function shouldInclude(elt) { - if(elt.name === "" || elt.name == null || elt.disabled) { + if(elt.name === "" || elt.name == null || elt.disabled || closest(elt, "fieldset[disabled]")) { return false; } // ignore "submitter" types (see jQuery src/serialize.js) @@ -2906,7 +2959,7 @@ return (function () { } function hasHeader(xhr, regexp) { - return xhr.getAllResponseHeaders().match(regexp); + return regexp.test(xhr.getAllResponseHeaders()) } function ajaxHelper(verb, path, context) { @@ -3453,7 +3506,11 @@ return (function () { } if (hasHeader(xhr,/HX-Retarget:/i)) { - responseInfo.target = getDocument().querySelector(xhr.getResponseHeader("HX-Retarget")); + if (xhr.getResponseHeader("HX-Retarget") === "this") { + responseInfo.target = elt; + } else { + responseInfo.target = querySelectorExt(elt, xhr.getResponseHeader("HX-Retarget")); + } } var historyUpdate = determineHistoryUpdates(elt, responseInfo); @@ -3752,34 +3809,25 @@ return (function () { //==================================================================== // Initialization //==================================================================== - /** - * We want to initialize the page elements after DOMContentLoaded - * fires, but there isn't always a good way to tell whether - * it has already fired when we get here or not. - */ - function ready(functionToCall) { - // call the function exactly once no matter how many times this is called - var callReadyFunction = function() { - if (!functionToCall) return; - functionToCall(); - functionToCall = null; - }; + var isReady = false + getDocument().addEventListener('DOMContentLoaded', function() { + isReady = true + }) - if (getDocument().readyState === "complete") { - // DOMContentLoaded definitely fired, we can initialize the page - callReadyFunction(); - } - else { - /* DOMContentLoaded *maybe* already fired, wait for - * the next DOMContentLoaded or readystatechange event - */ - getDocument().addEventListener("DOMContentLoaded", function() { - callReadyFunction(); - }); - getDocument().addEventListener("readystatechange", function() { - if (getDocument().readyState !== "complete") return; - callReadyFunction(); - }); + /** + * Execute a function now if DOMContentLoaded has fired, otherwise listen for it. + * + * This function uses isReady because there is no realiable way to ask the browswer whether + * the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded + * firing and readystate=complete. + */ + function ready(fn) { + // Checking readyState here is a failsafe in case the htmx script tag entered the DOM by + // some means other than the initial page load. + if (isReady || getDocument().readyState === 'complete') { + fn(); + } else { + getDocument().addEventListener('DOMContentLoaded', fn); } } @@ -3827,7 +3875,9 @@ return (function () { internalData.xhr.abort(); } }); - var originalPopstate = window.onpopstate; + /** @type {(ev: PopStateEvent) => any} */ + const originalPopstate = window.onpopstate ? window.onpopstate.bind(window) : null; + /** @type {(ev: PopStateEvent) => any} */ window.onpopstate = function (event) { if (event.state && event.state.htmx) { restoreHistory();