What breaks
Verified through the real pipeline (renderApp + the real runtime, packages/stx/test/router/spa-harness):
| one instance | two instances | |
|---|---|---|
<Listbox> — click an option | selects | does nothing |
<Combobox> — click an option | selects | does nothing |
two <Listbox> on one page — click the first trigger | — | neither opens (isOpen = [false, false]) |
<Combobox> chevron | never opens the list | never opens the list |
<Dropdown> — click an item | selects | selects |
So <Listbox> and <Combobox> cannot be used with more than one option, and two listboxes on a page have two dead triggers. There is no console error; the bindings are simply inert.
Cause
Scope processing for a component's <script client> is gated on the script naming one of SIGNAL_API_RE's identifiers (signal-processing.ts:1408 — state, derived, onMount, defineProps, defineEmits, …). A component script that only declares plain functions matches nothing, so no data-stx-scope is registered for the instance:
Dropdown with 1/2/4 items -> itemScopes = 1 / 2 / 4 works
Listbox with 1/2 options -> optionScopes = 0 / 0 works only at 1With exactly one instance the @click still resolves — which is why this was never noticed. With two, nothing binds.
The only difference between the two components is one line. DropdownItem.stx opens its client script with
const emit = defineEmits()and ListboxOption.stx does not. Adding that one line to ListboxOption takes optionScopes from 0 to N and makes selection work at every count — which is the diagnosis, not a proposed fix: a component should not need to call defineEmits() it has no use for in order for its own @click to bind.
The gate is wrong in the same way twice already
processScriptSetup carries a comment about the page-level version of exactly this (stacksjs/stacks#2394):
a script that factors its signals into a module names no signal API itself —
const { signal } = makeProbe()matches nothing above — so the page was treated as static: no setup function, nodata-stxon<body>, every binding inert with nothing logged
Same shape, one level down. "Does this script name a signal API?" is a proxy for "does this script need a scope?", and it is wrong whenever the answer comes from the template rather than the script — a script that declares onClick and a template that calls it in @click="onClick($event)" needs a scope with no signal API anywhere in sight.
Suggested direction
Decide the gate from the pairing rather than from the script alone: a component needs a per-instance scope when its template references an identifier its client script declares. That is cheap to compute (collect function/const declarations, test them against the directive attributes) and it is the condition that actually matters.
Components with the at-risk shape
A client-script handler referenced from the template, with neither defineEmits() nor a signal API in the script:
ui/listbox/ListboxOption.stx—onClickui/listbox/ListboxButton.stx—onClickui/combobox/ComboboxOption.stx—onClickui/combobox/ComboboxInput.stx—onInputui/combobox/ComboboxButton.stx—onClickui/radio-group/RadioGroupOption.stx—onClick,onKey
RadioGroup is not affected despite being on that list: it delegates clicks at the group root, so selection works (verified — two radios, second one clicks, selected = "pro"). The other two widgets rely on each part's own scope.
Found while adding keyboard navigation to these widgets for stacksjs/stx#2032; <Listbox>'s Enter-to-select could not be made to work until this was understood.