The gap
storage/framework/types/browser-auto-imports.d.ts declares composables that are not injected into a <script client> block. Calling one throws at hydration and takes the page's entire setup down with it — and nothing upstream of that says a word.
Verified on stx 0.2.448 (and first hit on 0.2.373, so it is not new).
What it looks like
resources/views/search.stx, debouncing a search:
const search = useDebounceFn((trimmed: string) => {
if (trimmed.length >= 2)
void runSearch(trimmed)
}, 250)tsc --noEmit passes. pickier passes. The page serves 200. In the browser:
[stx] page setup "__stx_setup_3od1rvfqt6" threw; this root will not hydrate:
ReferenceError: useDebounceFn is not defined
[stx] hydration invariant failed after DOMContentLoaded:
1 expression(s) never evaluated — query → query is not defined
[STX] Handler error: onInput($event) ReferenceError: onInput is not definedThe cost is not the one call. The whole root fails to hydrate, so every handler and binding on that page is dead — the search box stopped responding entirely, not just the debounce.
Which ones are real
Grepping node_modules/@stacksjs/stx/dist/signals.js for the injected definitions, against what the declaration file advertises:
| Composable | Declared in browser-auto-imports.d.ts | Injected by stx | Works in a client block |
|---|---|---|---|
useTimeout | yes | yes | ✅ verified |
useInterval | yes | yes | ✅ |
useEventListener | yes | yes | ✅ verified |
useSearchParams | no | yes | ✅ verified — works despite not being declared |
useDebounceFn | yes | no | ❌ throws |
useTimeoutFn | yes | no | ❌ |
refDebounced, useThrottleFn | yes | no | ❌ |
Both directions are wrong: the file advertises things that are absent, and omits at least one thing that is present.
Why the types cannot be trusted here
This is the concrete case for the warning in the stx standards guide (stacksjs/stx#1791, rule 10): "nothing verifies them. tsc --noEmit cannot see inside .stx files." A declaration file that is generated from a package's exports rather than from what the runtime injects will keep drifting, and the only thing that catches the drift is loading the page and reading the console.
What would be enough
- Generate the browser auto-import declarations from the injected set, so the file cannot advertise what is not there. This is the fix; the rest is mitigation.
- Fail loudly at compile time. The client-script compiler can see the identifiers a block references; an unresolved one that matches a declared-but-not-injected name is a compile error waiting to happen, and the message writes itself.
- At minimum, do not let one bad identifier take the whole root down. A setup that throws currently kills every binding on the page. Isolating the failure to the expression that caused it would turn this from "the page is dead" into "one feature is dead", which is the difference between a silent outage and a visible bug.
Reproducing
# wildloop, on main
grep -c "useDebounceFn" node_modules/@stacksjs/stx/dist/signals.js # 0 — not injected
grep -c "readonly useDebounceFn:" storage/framework/types/browser-auto-imports.d.ts # 1 — declaredThen put useDebounceFn(() => {}, 250) in any view's <script client> block and load the page.
Filed from wildloop, where this cost an afternoon and is now a note in CLAUDE.md telling the next person to trust only what signals.js injects. Sibling of stacksjs/stx#2067, which is the same class of silent failure on the native path.