ReviewOS

stacks/stx

useLocalStorage ships three different contracts: .d.ts says void, composable returns a ref, runtime returns a signal

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

useLocalStorage ships three different contracts: the .d.ts, the composable, and the runtime

Verified against @stacksjs/stx@0.2.113 (bun.lock pin, single copy in node_modules).

useLocalStorage has six definitions in dist. Two of them matter, they behave differently, and the type declaration matches neither.

1. What the types say

dist/composables/use-storage.d.ts:24

export declare function useLocalStorage<T>(
  key: string,
  defaultValue: T,
  options?: Omit<UseStorageOptions<T>, 'storage'>
): void

void. So the typed form is rejected at compile time:

const token = useLocalStorage('token', '')   // Type 'void' is not assignable

2. What the composable actually returns

dist/composables/use-storage.js:104 → useStorage, which returns a ref object with .value, .get(), .set(), .remove() and .subscribe(), and wraps both read and write in try/catch.

Not void, and not a signal either.

3. What a client script actually gets

dist/signals.js:3526 — a different implementation, defined inside the injected browser runtime, and the one useLocalStorage resolves to in a .stx client block (dist/client-script.js:166 lists it in the auto-import set):

function useLocalStorage(key, defaultValue) {
  var stored = localStorage.getItem(key);
  var initial = stored !== null ? JSON.parse(stored) : defaultValue;
  var s = state(initial);
  effect(function() { localStorage.setItem(key, JSON.stringify(s())); });
  …

Returns a signal (call it: token()), takes no options argument, and has no error handling — which is what #1793 reports against this same function.

The three-way disagreement

ReturnOptions argGuarded
use-storage.d.tsvoidyesn/a
composables/use-storage.jsref object (.value)yesyes
signals.js (what runs)signal (token())nono

A developer reading the types writes code that does not compile. A developer ignoring the types and reading composables/ writes .value against something that is a signal at runtime. Only reading the minified runtime gets you the right answer.

The other four copies are in cli.js:5766, pwa.js:7514, ssg.js:6323 and browser-composables.js:5.

Why this matters beyond one function

This is the concrete reason an stx codebase stays untyped. In a 48-file app here, script blocks contain 123 functions, 70 parameters and 12 type annotations — essentially all any. Not from carelessness: the typed path is blocked by the framework's own declarations, and the untyped path silently works. stx is described as TypeScript-first, and the composable surface has to hold that up before application code can.

Related: dist/client-script.js:315 detects auto-imports with \b<symbol>\s*\(, so writing the generic form useLocalStorage<string>(…) puts a < where the ( is expected, the symbol is never detected, and the destructuring line is never emitted. So the documented generic form silently produces a ReferenceError at a client call site.

Suggested fix

  1. One implementation, or an explicit statement of which context gets which.
  2. Correct the return type to match whatever the runtime actually provides.
  3. Either make the generic form work with the auto-import detector, or document that it must not be used at a client call site.

Filed alongside #1793 (missing error handling in the signals.js copy) and #1788.

10 days ago

Two of the three contracts here have since converged; closing in favour of #1797, which covers what's left.

1. .d.ts says void — fixed. bun-plugin-dtsx is a syntactic emitter with no type checker, so an unannotated return inferred as StorageRef<T> was emitted as void. Nineteen exports shipped that way. Fixed in #1796 (28007d9646); dist/composables/use-storage.d.ts now declares StorageRef<T>, and test/build/exported-return-types.test.ts fails on any exported function that returns a value without annotating it.

2. Runtime has no error handling — fixed. useLocalStorage/useSessionStorage in the runtime now go through guarded read/write helpers: a non-JSON value warns and is returned as a raw string (so a legacy plain-string key migrates instead of throwing), and a failing setItem — Safari private mode, quota — warns instead of tearing down the effect. That was #1793. The cross-tab storage handler was unguarded on both implementations and is fixed on both, along with a missing storageArea filter that let sessionStorage writes clobber localStorage signals.

3. Three-way shape disagreement — still open, and the substantive one. #1797 has the full inventory (five shapes, not three, once @stacksjs/composables and packages/components are counted), the useLocalStorage is not exported from the package index problem, and a suggested direction following the #1710 useCookie precedent. It needs a call on breaking-change appetite for the /composables subpath, which is why it isn't done yet.

One correction worth recording: signals.js:3526 is the implementation that runs in a <script client> block, and it returns a signal — token(), not token.value. The ambient stx.d.ts types the bare global as StxSignal<T>, so those two agree; it's the import paths that don't.

Sign in to comment on this issue.