[htmx4]: an `hx-trigger` filter throws a SyntaxError when the event has a property that isn't an identifier
Maintainers usually reply within 4 days
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-devat372c6e3 - 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
boson). - 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
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from bigskysoftware/htmx
-
[htmx4] Swap style for boosted elements is not outerSyncPossibly taken @1cg claimed this 4 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
bigskysoftware/htmx#4138 · 3 comments · 1 assignee ·
Maintainers usually reply within 4 days
-
[htmx4] HTMX errors if HX-Trigger Event Detail is nullPossibly taken @MichaelWest22 claimed this 4 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
bigskysoftware/htmx#4135 · 3 comments · 1 assignee ·
Maintainers usually reply within 4 days
-
[htmx4] When importing and configuring htmx with JS, you can't turn off indicator CSSPossibly taken @MichaelWest22 claimed this 4 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
bigskysoftware/htmx#4122 · 1 comment · 1 assignee ·
Maintainers usually reply within 4 days
-
No GitHub Release for version 2.0.11Possibly taken @1cg claimed this 4 days ago. Open
Difficulty 1/5 Under an hour Newbie friendliness 75/100
bigskysoftware/htmx#4117 · 1 assignee ·
Maintainers usually reply within 4 days
-
implicitInheritance also inherits hx-get, hx-post and hx-trigger, which htmx 2 never inheritedPossibly taken @MichaelWest22 claimed this 4 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
bigskysoftware/htmx#4104 · 1 comment · 1 assignee ·
Maintainers usually reply within 4 days
All issues in bigskysoftware/htmx
Similar issues
-
idnits3
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
ietf-tools/idnits#363 ·
Maintainers usually reply within 1 day
-
prototype(verifier_token): map disallowed warning to contraindicated before composite precedence checkPossibly taken @drk-ram claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
agentrust-io/trace-spec#582 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 Half a day Newbie friendliness 72/100
TheOdinProject/curriculum#31479 ·
Maintainers usually reply within 1 day
-
chore
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
SignalK/n2k-signalk#368 ·
Maintainers usually reply within 1 day