Summary
composables/use-event-listener.ts:55 resolves a string target via document.querySelector(...), and falls back to window if the selector returns null:
target = document.querySelector(options.target) || windowA typo'd selector ('#dropdown' when the actual id is '#dropdwn') silently attaches the handler to window instead. A click listener intended to fire only when a specific element is clicked now fires on every click anywhere in the page.
Reproducer
<script client>
// Intended: log only when #dropdown is clicked
useEventListener('click', () => console.log('clicked'), { target: '#dropdwn' /* typo */ })
</script>
<button id="dropdown">Click me</button>
<button>Other</button>Click the "Other" button → console logs "clicked". Should not.
Root cause
packages/stx/src/composables/use-event-listener.ts:53-58:
if (options?.target) {
if (typeof options.target === 'string') {
target = document.querySelector(options.target) || window // ← silent fallback
}
else {
target = options.target
}
}Impact
Quiet correctness bug. Listeners fire when they shouldn't. Easy to miss in code review — the typo can be off-by-one and the code path that depends on the listener still "works", just over-fires. Especially bad with useClickOutside-style patterns where window-level fallback means EVERY click triggers the "outside" handler.
Suggested fix
Throw / warn loudly when the selector doesn't resolve:
const resolved = document.querySelector(options.target)
if (!resolved) {
console.warn(`[stx] useEventListener: selector "${options.target}" did not match any element. Listener not attached.`)
return { remove: () => {} }
}
target = resolvedAlternative (opt-in): a waitForTarget: true option that uses MutationObserver to bind once the element appears — useful for dynamically-inserted targets.
Environment
- stx main
- Bun 1.3.13