effect() stores whatever the callback returns and calls it on the next run, guarded only by truthiness. An effect body is an expression at least as often as it is a block, and an expression has a value.
Repro
import { effect, state } from '@stacksjs/stx'
const name = state('a')
effect(() => el.textContent = name()) // concise arrow: RETURNS the assigned string
name.set('b')first run ok, textContent = a
CRASH on second run: cleanup is not a function. (In 'cleanup()', 'cleanup' is "a")Assigning to the DOM inside an effect is the single most common effect body in any framework, and el.textContent = x evaluates to the assigned string. if (cleanup) is truthy for any non-empty string, so the next run invokes it.
It only fails on the second run, so it survives review and survives any test that sets the signal once.
Writing the same effect with braces returns undefined and is fine — so whether your code works depends on whether you typed {}, which is not a distinction the API documents or intends.
Both implementations are affected
| assignment | called at | |
|---|---|---|
packages/stx/src/signals-api.ts | :404 cleanup = fn() | :394-395, :420 |
the generated runtime in packages/stx/src/signals.ts | :746 cleanup = fn(); | :738-739, :774 |
The runtime copy is the worse of the two. Its cleanup() call at :739 sits ABOVE the try block that wraps fn(), so the TypeError is not caught by the effect's own error handling. It escapes runEffect entirely and propagates out of whatever .set() triggered the notification — so one bad effect takes down unrelated subscribers in the same update, and the .set() call itself throws.
Verified by executing both: the module path via a direct import, the runtime path by installing generateSignalsRuntimeDev() in happy-dom and calling window.stx.effect.
Fix
Guard at the assignment rather than at the two call sites, so "cleanup is a function or undefined" becomes an invariant and both the re-run and the dispose path are correct for free:
const result = fn()
cleanup = typeof result === 'function' ? result : undefinedThis is the same missing typeof cleanup === 'function' check that #1857 fixed for mount callbacks, one file away — worth a sweep for other places a returned value is assumed callable.
Found while auditing #1885.