Summary
useFetch ships in two impls — composables/use-fetch.ts (module-import path) and inside the runtime template literal in packages/stx/src/signals.ts (function useFetch at line 836). They return different shapes. Whichever import path resolves decides which API the caller gets, with no warning.
This is the same class of bug as #1710 (useCookie) — and is what motivated the dual-impl parity test pattern documented in CLAUDE.md item 40.
Reproducer
<script client>
const result = useFetch('/api/products')
// Inside a `<script client>` block, the stx bundler rewrites the
// import to destructure from window.stx → caller gets the RUNTIME
// shape. So this works:
// result.data() ✅ runtime: data is a signal
// result.refetch ✅ runtime: method named refetch
// …but this does NOT work:
// result.get() ❌ runtime has no .get()
// result.refresh ❌ runtime has no .refresh
</script>// In an SSR helper / Node script / test that imports directly:
import { useFetch } from 'stx'
const result = useFetch('/api/products')
// Module shape:
// result.get() ✅ module: get returns FetchState
// result.subscribe() ✅ module: subscribe pattern
// result.refresh() ✅ module: refresh, not refetch
// result.data() ❌ module has no .dataRoot cause
Runtime version — packages/stx/src/signals.ts:903-912:
return {
data, // a signal
loading, // a signal
error, // a signal
refetch: fetchData,
get isLoading() { return loading(); },
get hasError() { return !!error(); },
get isEmpty() { return !loading() && !data(); }
};Module version — packages/stx/src/composables/use-fetch.ts:305-330:
return {
get: () => state, // returns a FetchState object
subscribe: (fn) => { ... },
refresh: doFetch,
execute: async () => { ... },
abort: () => { ... },
// ...
}Different return keys (data/loading/error vs get/subscribe), different method names (refetch vs refresh), different reactivity model (signals vs subscribe pattern).
Impact
- Code that compiles fine in a
<script client>block crashes when imported from an SSR helper or test (and vice versa). - The composable is unreachable from a typical stx page because the bundler rewrites
import { useFetch } from 'stx'→ destructure fromwindow.stx(the runtime version), so the module version's API is effectively dead code unless callers reach in via a different import path. - Mirrors the silent footgun resolved for
useCookiein #1710.
Suggested fix
Pick the runtime's Signal-shape as canonical — it aligns with the rest of stx's reactive primitives (state, derived, useLocalStorage, useCookie post-#1710). Two-step:
- Rewrite
composables/use-fetch.tsuseFetchto return{ data, loading, error, refetch, ... }matching the runtime, usingstate()fromsignals-api. - Add
test/composables/use-fetch-parity.test.tsalong the lines ofuse-cookie-parity.test.ts/dual-impl-parity.test.tsso future drift trips the suite.
Likely worth also auditing other composables with runtime counterparts for the same drift:
useFetch(this issue)useReactivePropuseQuery/useMutationuseStorage(recently touched in #1718)useClickOutside,useFocus,useColorMode,useDark
If any of these have a runtime counterpart, the same parity-test pattern applies.
Environment
- stx main
- Bun 1.3.13