ReviewOS

stacks/stx

useFetch: two unsynced implementations (module vs runtime) return incompatible shapes

#1726
Closed glennmichael123 opened this 10 days ago ·
10 days ago

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

Root 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 from window.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 useCookie in #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:

  1. Rewrite composables/use-fetch.ts useFetch to return { data, loading, error, refetch, ... } matching the runtime, using state() from signals-api.
  2. Add test/composables/use-fetch-parity.test.ts along the lines of use-cookie-parity.test.ts / dual-impl-parity.test.ts so future drift trips the suite.

Likely worth also auditing other composables with runtime counterparts for the same drift:

  • useFetch (this issue)
  • useReactiveProp
  • useQuery / useMutation
  • useStorage (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

Sign in to comment on this issue.