Bug
Any x-if / :if / @if expression that calls a signal as a function inside a comparison or boolean operation silently evaluates to "" (falsy) regardless of the underlying value. Plain boolean literals (x-if="true", x-if="false") work. Direct signal reference (x-if="someBool") works. But the moment you write signal() === 0, !signal(), count() > 0, etc., the directive treats every render as "hidden."
The bare call form x-if="signal()" also fails — it just happens to give the right behavior by coincidence when the signal's value is 0/false (the error path returns "", which is falsy, which matches "hide on 0"). So the bug is silent and often misdiagnosed as "works fine for the truthy/falsy check, breaks for comparisons."
Reproducer
Store:
defineStore('notifications', () => {
const unreadCount = state<number>(0)
return { unreadCount, /* ... */ }
})Template (any one of these is broken — all should render their <span> when unread count is 0):
<span x-if="notifications.unreadCount() === 0">no unread</span>
<span x-if="notifications.unreadCount() == 0">no unread</span>
<span x-if="notifications.unreadCount() !== 5">not five</span>
<span x-if="notifications.unreadCount() > 0">{{ notifications.unreadCount() }}</span>None render. console.log(notifications.unreadCount()) from a <script client> correctly logs 0 (or whatever), so the store and signal work — it's purely the directive evaluator.
Root cause
signals.js's bindIf has its own evalExpr (currently at lines 2297-2313):
const evalExpr = (expression) => {
try {
if (/^__[A-Z_]+__$/.test(expression.trim())) return expression
const scope = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers }
const unwrapScope = createAutoUnwrapProxy(scope)
const fn = new Function(...Object.keys(scope), 'return ' + expression)
return fn(...Object.values(unwrapScope))
}
catch (e) {
if (!(e instanceof ReferenceError) && !(e instanceof TypeError))
console.warn('[STX] Expression error:', expression, e)
return ''
}
}createAutoUnwrapProxy (at line 179) auto-unwraps any property access that hits a signal — notifications.unreadCount becomes notifications.unreadCount()'s return value (the integer 0). Then the expression notifications.unreadCount() === 0 becomes 0() === 0, which throws TypeError: 0 is not a function.
The catch block at the bottom specifically suppresses TypeError and ReferenceError — no console warning ever fires — and returns "". Every comparison silently becomes "hidden."
The fix already exists in bindShow
bindShow (lines 1697-1721) handles this exact case correctly with a retry-without-unwrap fallback, including a comment that explains the rationale:
} catch (e1) {
// Retry without unwrapping — handles edge cases where the proxy
// interferes with certain expression patterns
try {
var fn2 = new Function(...Object.keys(capturedScope), 'return ' + expr)
value = fn2(...Object.values(capturedScope))
} catch (e2) { /* ... */ }
}The same pattern is in evalAttrExpr for generic :attr bindings (line 1478). bindIf is the only directive that's missing it.
Proposed fix
Mirror the existing retry in bindIf's evalExpr:
- const evalExpr = (expression) => {
- try {
- if (/^__[A-Z_]+__$/.test(expression.trim())) return expression
- const scope = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers }
- const unwrapScope = createAutoUnwrapProxy(scope)
- const fn = new Function(...Object.keys(scope), 'return ' + expression)
- return fn(...Object.values(unwrapScope))
- }
- catch (e) {
- if (!(e instanceof ReferenceError) && !(e instanceof TypeError))
- console.warn('[STX] Expression error:', expression, e)
- return ''
- }
- }
+ const evalExpr = (expression) => {
+ try {
+ if (/^__[A-Z_]+__$/.test(expression.trim())) return expression
+ const scope = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers }
+ const unwrapScope = createAutoUnwrapProxy(scope)
+ const fn = new Function(...Object.keys(scope), 'return ' + expression)
+ return fn(...Object.values(unwrapScope))
+ }
+ catch (e1) {
+ try {
+ const scope2 = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers }
+ const fn2 = new Function(...Object.keys(scope2), 'return ' + expression)
+ return fn2(...Object.values(scope2))
+ }
+ catch (e2) {
+ if (!(e2 instanceof ReferenceError) && !(e2 instanceof TypeError))
+ console.warn('[STX] Expression error:', expression, e2)
+ return ''
+ }
+ }
+ }Verified end-to-end with a standalone reproducer:
| Expression | Before | After |
|---|---|---|
count() === 0 (count=0) | "" (silently hidden) | true ✅ |
count() === 0 (count=7) | "" (silently hidden) | false ✅ |
count() > 0 (count=0) | "" | false ✅ |
count() > 0 (count=7) | "" | true ✅ |
count() !== 5 (count=0) | "" | true ✅ |
count() (count=0) | "" | 0 ✅ |
count() (count=7) | "" | 7 ✅ |
Why this matters
x-if / :if is the most common reactive directive in any real app — every empty-state, every conditional badge, every loading spinner uses it. The current state is that any comparison or boolean expression silently fails, the framework swallows the error, and the symptom looks like "x-if doesn't work at all" or "x-if works with literals but not state." It's the kind of bug that makes adopters bounce.
The fix is 6 lines (one catch becomes nested try/catch) and there's a precedent in the same file for the exact pattern.
Test surface (suggested)
In whatever harness exercises signals.js:
const count = state(0)
const scope = { count }
expect(evalIf('count() === 0', scope)).toBe(true)
expect(evalIf('count() > 0', scope)).toBe(false)
count.set(7)
expect(evalIf('count() === 0', scope)).toBe(false)
expect(evalIf('count() > 0', scope)).toBe(true)
expect(evalIf('!count()', scope)).toBe(false)Happy to open a PR — the diff is one block in signals.js plus tests.