@stacksjs/stx ships two contradictory declarations of useInterval, and the one the <script client> typechecker reads describes a function the runtime does not implement. Code written against it compiles clean and throws on mount.
Same shape as #1923 (useEventListener declared target-first against an event-first runtime): one name, two conventions, and no way for the call site to tell which it has.
The two declarations
dist/browser-composables.d.ts:163 — matches the runtime, a reactive counter:
export declare function useInterval(interval: number, options?: { immediate?: boolean }): {
counter: { readonly value: number }
pause: () => void
resume: () => void
reset: () => void
}dist/stx.d.ts:358 — what buddy typecheck / stx typecheck resolves for a client script, and what does not exist:
declare function useInterval(_fn: () => void, _ms: number, _options?: { immediate?: boolean }): {
start: () => void
stop: () => void
isActive: StxSignal<boolean>
}dist/browser-composables.js implements only the first: export function useInterval(interval, options = {}), returning { counter, pause, resume, reset }. There is no start, no stop, no isActive, and no callback parameter. (useTimeout next to it is declared consistently in both files and is fine.)
Failure
<script client>
const poll = useInterval(() => void refresh(), 60_000, { immediate: false })
onMount(() => poll.start())
</script>stx typecheck reports 0 errors. In the browser:
[stx] onMount error: TypeError: poll.start is not a functionTwo things go wrong at once, and only the first is visible:
poll.startisundefined, soonMountthrows and everything after it in the callback is skipped.- The callback was passed as
interval, so the body runssetInterval(tick, someFunction). The delay coerces toNaN, which the platform clamps to0— a runaway timer incrementing a counter nobody can read. It also starts unconditionally: the implementation callsstart()insideif (typeof window !== 'undefined'), so the{ immediate: false }in the call above is ignored even on the correct signature.
Why it is worth fixing rather than documenting
The declared-but-absent overload is the plausible one — a callback-driven interval is what most people reach for, and it is what the composables docs elsewhere suggest. It is also the only useInterval visible to someone writing a client script, because stx.d.ts is the ambient set for that context. I picked it over useIntervalFn specifically because it was the one the framework typed; useIntervalFn worked at runtime at the time but failed the typecheck. The types pointed at the broken call and away from the working one.
The counter form also cannot express "run this every N ms" at all: counter is a plain getter ({ get value() { return count } }), not a signal, so an effect cannot track it either. With this declaration removed there is no interval-with-callback in the package — useTimeout re-armed from its own callback is the workaround, which is what I have shipped.
Suggested fix
Either:
- Implement the declared overload. Detect a function first argument and branch, the way
faker.lorem.sentencealready branches ontypeof q === 'object'. Keeps both call styles working and makes existing code correct retroactively. - Or delete the
stx.d.tsdeclaration and re-export the real one, so the counter form is the only thing that typechecks — and adduseIntervalFn(fn, ms, options)for the callback case, since removing the overload leaves a genuine gap.
Either way the two files should not disagree. A consistency test asserting that every name in stx.d.ts's composable registry has a matching arity and return shape in browser-composables.d.ts would catch this class of bug — it is now the second instance.
Also worth honouring options.immediate in the current implementation: it is accepted, documented in the type, and ignored.
Found while upgrading an app that had been calling the phantom overload for weeks with green gates the whole time.