Summary
@click="foo" (and every other @event="funcName" directive that names a bare function reference) silently does nothing. There is no console warning, no thrown error, and [stx] event fired: click still logs — but the handler is never invoked. Wrapping the call in parens (@click="foo()") works.
This is a hard footgun. The code looks correct to anyone coming from Alpine, Vue 3 template syntax, or Svelte, all of which support bare function refs. The failure is silent and the workaround is undocumented.
Reproduction
<script client>
function logIt() {
console.log('clicked!')
}
</script>
<button @click="logIt">Bare ref — does nothing</button>
<button @click="logIt()">With parens — works</button>Click both. The first button logs [stx] event fired: click on BUTTON expr: logIt but 'clicked!' never appears. The second button logs both.
Root cause
In packages/stx/src/signals.ts (event-handler binder, around the generic fallback path that wraps the expression in new Function):
} else {
var fn = new Function(...Object.keys(eventCapturedScope), '$event', value);
fn(...Object.values(eventCapturedScope), event);
}For value = "logIt", this compiles to:
function (/* scope keys */, $event) {
logIt // ← just an expression statement; result discarded
}JavaScript treats logIt alone as an expression statement that evaluates the identifier and throws the result away. The function reference is looked up and never called.
parseEventShorthand() handles a few cases (!flag, count++, x = y, x += y) but does NOT recognise bare function references, so they fall through to the generic new Function path above.
Why it's bad
- No warning, no error. stx already logs
[stx] event fired:when the click reaches the DOM listener, so the user is confidently mis-debugging at the wrong layer. - Looks correct. Vue 3 and Alpine both invoke bare function refs in event handlers (
@click="logIt"is canonical Vue). Anyone coming from those frameworks will hit this. - Codebase-wide silent dead code. In a real production codebase I had ~12
@click="funcName"/@submit.prevent="funcName"instances — every one of them was a dead handler. Search-and-fix turned a "navbar search doesn't work" / "like button does nothing" / "cancel button is inert" mystery into a one-line audit.
Suggested fix
In parseEventShorthand, after the existing assignment / increment / toggle cases, add:
// Bare function reference: `@click="foo"` → invoke foo().
// Distinct from `@click="foo()"` which the general path already handles.
const bareIdMatch = trimmed.match(/^([a-zA-Z_$][\w$]*)$/)
if (bareIdMatch) {
const fn = scope[bareIdMatch[1]]
if (typeof fn === 'function') {
return ($event) => fn($event)
}
}That preserves backwards compatibility (existing parens-style still works) and brings stx into line with Alpine/Vue's expectations.
Alternative: emit a console.warn when an @event expression resolves to a function reference but isn't called. The warning alone would have saved hours.
Workaround
Grep guardrail until upstream fixes it:
grep -rnE '@(click|submit|change|input|keyup|keydown)[^=]*="[a-zA-Z_$][\w$]*"' resources/**/*.stxAny match is suspect — none of them invoke the function.
Environment
@stacksjs/stx@0.2.64(via pantry)- Reproduction file: any
.stxtemplate with@click="bareRef"
🤖 Filed via Claude Code