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'>
): voidvoid. So the typed form is rejected at compile time:
const token = useLocalStorage('token', '') // Type 'void' is not assignable2. 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
| Return | Options arg | Guarded | |
|---|---|---|---|
use-storage.d.ts | void | yes | n/a |
composables/use-storage.js | ref object (.value) | yes | yes |
signals.js (what runs) | signal (token()) | no | no |
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
- One implementation, or an explicit statement of which context gets which.
- Correct the return type to match whatever the runtime actually provides.
- 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.