ReviewOS

stacks/stx

Bare function refs in event handlers do nothing — `@click="foo"` silently no-ops, no warning

#1695
Closed glennmichael123 opened this 10 days ago ·
10 days ago

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

  1. 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.
  2. 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.
  3. 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/**/*.stx

Any match is suspect — none of them invoke the function.

Environment

  • @stacksjs/stx@0.2.64 (via pantry)
  • Reproduction file: any .stx template with @click="bareRef"

🤖 Filed via Claude Code

10 days ago

Already fixed in commit 1899048537 (fix: address seven reported issues across stx, bun-plugin, components). The bare-id branch is in packages/stx/src/signals.ts:1247-1252 and regression tests live in packages/stx/test/edge-cases/issue-fixes.test.ts (4 cases under #1695 — bare function ref in event handler shorthand). Shipped on main. Issue stayed open because the commit referenced #1695 but didn't include the auto-close phrase.

Sign in to comment on this issue.