Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

[htmx4]: an `hx-trigger` filter throws a SyntaxError when the event has a property that isn't an identifier

Open Beginner friendly
#4,123 0 comments 0 reactions 1 assignee View on GitHub

Maintainers usually reply within 4 days

@MichaelWest22 is already working on this.

Since Oct 15, 2026.

  • #4124 by @nickhstr — open

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
66/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
javascript
Domain
frontend

Research direction

Start in src/htmx.js: the trigger handler near line 803 copies every for…in property of the event into evtArgs, and __executeJavaScript (around lines 906-909) passes those keys to the Function constructor. Confirm that a key such as __nrNode:1234 makes the constructor throw, then filter out names that aren't valid parameter identifiers. The test to extend is in test/tests/attributes/hx-trigger.js. Done means that test passes and npm test stays green.

Written by the indexing model from the issue text.

Description

Summary

A trigger filter never passes, and throws on every event, when the event carries an enumerable property whose name
isn't a valid JavaScript identifier. No request is sent. New Relic's browser agent adds such a property to the events
it handles, so on pages that run it, hx-trigger="input[this.value.length >= 3]" silently stops working for every
visitor whose events the agent tags.

  • htmx 4.0.0, and four-dev at 372c6e3
  • Tested in Chrome 155

Cause

To evaluate the filter, the trigger handler copies every for…in property of the event
(src/htmx.js#L803):

let evtArgs = {}; for (let k in evt) evtArgs[k] = evt[k];
if (!this.__executeJavaScript(elt, evtArgs, filter, true, false)) return;

__executeJavaScript then passes every key to the Function constructor as a parameter name
(src/htmx.js#L906-L909):

let keys = Object.keys(args);
let values = Object.values(args);
let FunctionConstructor = isAsync ? this.#AsyncFunction : this.#Function;
let func = new FunctionConstructor(...keys, expression ? `return (${code})` : code);

A key such as __nrNode:1234, data-x or default can't be a parameter name, so the constructor throws
(V8: SyntaxError: Arg string terminates parameters early). The exception escapes the event listener, the filter is
treated as failed, and timed(evt) never runs.

New Relic's SPA agent (nr-spa-1.288.1.min.js) runs s["__nrNode:".concat(agentId)] = t.currentNode on the
events it handles, which is how we found this: a site search sent no requests in some browser sessions and worked in
others.

hx-on is unaffected: it passes { event: evt }, not the event's keys.

Reproduction

Save this as a file, serve it from any static server, and type boston into the box:

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8">
	<title>htmx 4: a trigger filter throws when the event has a non-identifier property</title>
	<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.js"></script>
</head>
<body>
	<p>
		<label>
			<input type="checkbox" id="tag" checked>
			Tag input events the way New Relic's browser agent does
		</label>
	</p>
	<p>
		<input name="q" placeholder="Type 3+ characters"
			hx-get="/search" hx-trigger="input[this.value.length >= 3]" hx-swap="none">
	</p>
	<p>Requests started: <output id="requests">0</output> (errors are in the console)</p>
	<script>
		// New Relic's SPA agent runs `event["__nrNode:" + agentId] = node` on the events it handles.
		addEventListener('input', (event) => {
			if (document.getElementById('tag').checked) event['__nrNode:1234'] = {};
		}, true);
		// Counts the request instead of sending it, so the page needs no server.
		document.addEventListener('htmx:before:request', (event) => {
			event.preventDefault();
			const requests = document.getElementById('requests');
			requests.value = Number(requests.value) + 1;
		});
	</script>
</body>
</html>
  • Expected: "Requests started" goes to 4 (one per keystroke from bos on).
  • Actual: it stays at 0, and the console logs once per keystroke:
    Uncaught SyntaxError: Arg string terminates parameters early
        at new Function (<anonymous>)
        at #executeJavaScript (htmx.js:907:23)
        at spec.handler (htmx.js:802:52)
    
  • Untick the checkbox, reload and type again: it counts 4 requests.

Proposed fix

Pass only the keys a function parameter can take: identifiers that aren't reserved words. Any other key couldn't be
referenced by bare name in the filter anyway, so dropping it changes nothing a filter could observe. The reserved-word
list is the set of words that V8 rejects as a parameter of a sloppy-mode Function or AsyncFunction (yield,
let, static, eval and arguments are accepted). The change is in __executeJavaScript, so it also covers keys
that an htmx:scope extension adds.

diff --git a/src/htmx.js b/src/htmx.js
index 0ff73582..56f7be38 100644
--- a/src/htmx.js
+++ b/src/htmx.js
@@ -137,6 +137,9 @@ var htmx = (() => {
         #internalAPI;
         #Function = Function;
         #AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
+        // The names a function parameter can take: identifiers that aren't reserved words
+        #identifier = /^[$_\p{ID_Start}][$\u200C\u200D\p{ID_Continue}]*$/u;
+        #reservedWords = new Set(["await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", "in", "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with"]);
         #ttPolicy = { createHTML: s => s, createScript: s => s };
         #actionSelector
         #boostSelector = "a,form";
@@ -903,8 +906,16 @@ var htmx = (() => {
             code = detail.code;
             Object.assign(args, scope);
             Object.assign(args, obj)
-            let keys = Object.keys(args);
-            let values = Object.values(args);
+            let keys = [];
+            let values = [];
+            for (let [key, value] of Object.entries(args)) {
+                // Any other name would make the constructor throw (an event can carry one, e.g. the key New Relic's
+                // browser agent sets on events it handles), and code couldn't reference it by bare name anyway
+                if (this.#identifier.test(key) && !this.#reservedWords.has(key)) {
+                    keys.push(key);
+                    values.push(value);
+                }
+            }
             let FunctionConstructor = isAsync ? this.#AsyncFunction : this.#Function;
             let func = new FunctionConstructor(...keys, expression ? `return (${code})` : code);
             return compile ? () => func.call(thisArg, ...values) : func.call(thisArg, ...values);
diff --git a/test/tests/attributes/hx-trigger.js b/test/tests/attributes/hx-trigger.js
index 3ccbb527..2a6b75e0 100644
--- a/test/tests/attributes/hx-trigger.js
+++ b/test/tests/attributes/hx-trigger.js
@@ -52,6 +52,18 @@ describe('hx-trigger attribute', function() {
         await forRequest()
     })
 
+    it('filters properly when the event has a property that is not an identifier', async function() {
+        mockResponse('GET', '/test', 'Called!')
+        let form = createProcessedHTML('<form hx-get="/test" hx-trigger="evt[foo]">Not Called</form>')
+        let event = new CustomEvent("evt");
+        event.foo = true
+        event['__nrNode:1234'] = {}
+        event.default = true
+        form.dispatchEvent(event)
+        fetchMock.calls.length.should.equal(1)
+        await forRequest()
+    })
+
     it('filters properly with true expression filter spec', async function() {
         mockResponse('GET', '/test', 'Called!')
         let form = createProcessedHTML('<form hx-get="/test" hx-trigger="evt[true]">Not Called</form>')
How we checked it

The unpatched and patched src/htmx.js were each built with the build:iife sed and loaded into a page with filtered
triggers: input[this.value.length >= 3], evt[foo] (a bare event property), keyup[key=='Enter'] and an hx-on:click.
That page added __nrNode:1234, default and a b to each event when tagging was on.

Build Tagged events input filter evt[foo] (true, then false) keyup (a, Enter) hx-on Errors
four-dev no 4 requests 1 1 runs none
four-dev yes 0 0 0 runs SyntaxError on every event
patched no 4 requests 1 1 runs none
patched yes 4 requests 1 1 runs none

With the patch, npm test passes in Chromium (1,781 passed, 0 failed, 4 skipped). The added test fails on four-dev
with the same SyntaxError: Arg string terminates parameters early.

Workaround until a fix lands

Move the condition out of the trigger and cancel in htmx:config:request instead:

document.addEventListener('htmx:config:request', (event) => {
  const q = event.detail.ctx.request.body?.get?.('q');
  if (typeof q === 'string' && q.trim().length < 3) event.preventDefault();
});
Dominant language
JavaScript
Stars
49.6k
Forks
1.7k
Avg merge
12d 20h
Merged PRs (30d)
9

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from bigskysoftware/htmx

All issues in bigskysoftware/htmx

Similar issues

More JavaScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.