ReviewOS

stacks/stx

fix(auto-imports): browser-auto-imports declares composables that are never injected, and one bad identifier kills the whole root

#2068
Closed glennmichael123 opened this 9 days ago ·
9 days ago

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 defined

The 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:

ComposableDeclared in browser-auto-imports.d.tsInjected by stxWorks in a client block
useTimeoutyesyes✅ verified
useIntervalyesyes✅
useEventListeneryesyes✅ verified
useSearchParamsnoyes✅ verified — works despite not being declared
useDebounceFnyesno❌ throws
useTimeoutFnyesno❌
refDebounced, useThrottleFnyesno❌

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

  1. 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.
  2. 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.
  3. 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 — declared

Then 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.

9 days ago

Fixed on main in 6e1fdcc and b6d1286. browser-auto-imports.d.ts is now generated from the injector manifest, preventing declaration drift, and client setup wrappers contain/log failures so one missing identifier no longer blanks the whole root. Verified by browser-auto-imports-declaration.test.ts and client-script-external.test.ts (46 focused tests passed, 0 failed).

Sign in to comment on this issue.