Skip to content

Commit eb27f38

Browse files
fix(storage)!: useLocalStorage returns a Signal on every import path
BREAKING CHANGE: `useLocalStorage` / `useSessionStorage` imported from `@stacksjs/stx/composables` now return a Signal instead of a StorageRef. The same identifier had five return shapes. The two that mattered disagreed: the module path returned a StorageRef (`.value`), the signals runtime returned a Signal (`s()`), and the ambient stx.d.ts declared the Signal for both. So `.value` worked or silently yielded `undefined` purely according to which entry point an import resolved — and a signal from the module impl is invisible to the runtime anyway, so binding to one never re-rendered. `useLocalStorage` was also not exported from the package index at all: `import { useLocalStorage } from '@stacksjs/stx'` failed with TS2614 while `state`/`derived` imported fine. A `functions/` composable — which stx's own docs recommend for anything with persistence — therefore had to reach for the subpath, i.e. the one path returning the other shape. Now exported from the index alongside `useStorage`, `clearStorage`, `getStorageKeys`, `getStorageSize`. Follows the precedent #1710 set when it converted `useCookie` from a Vue-style CookieRef to a Signal, and is pinned the same way by test/signals/use-storage-parity.test.ts (13 of its 31 cases fail if either impl drifts). `useStorage` is deliberately untouched and still returns a StorageRef. Options, custom serializers, `remove()` and `subscribe()` are genuinely a different, richer thing, so they keep their own name rather than being folded into a signal. Migration is narrower than it looks: Signal already carries a Vue-compatible `.value` accessor, so `.value` reads, `.value = x` writes and `.set()` all keep working. Only `.get()` → `s()` and `.remove()` need changing — or switch the import to `useStorage`. browser-composables.ts — the source of a third shape, `{ value, remove }`, and of at least one downstream agent guide teaching that destructure for <script client> blocks where it yields undefined for both — is now marked deprecated at module and function level. It is imported by nothing and re-exported from neither index; it ships only because the package's `./*` wildcard makes the path resolve. Removal in the next major. Closes #1797
1 parent ee63d3a commit eb27f38

6 files changed

Lines changed: 425 additions & 12 deletions

File tree

docs/api/composables-ref.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,35 @@ draftCart.set([...draftCart(), item])
874874
</script>
875875
```
876876

877+
Both return the same signal whether they come from the bare global in a
878+
`<script client>` block, `import … from '@stacksjs/stx'`, or
879+
`'@stacksjs/stx/composables'`.
880+
881+
> **Changed in #1797.** The `/composables` subpath used to return a
882+
> `StorageRef` (`.value`, `.get()`, `.remove()`, `.subscribe()`) while the
883+
> runtime returned a signal, so `.value` worked or silently yielded `undefined`
884+
> depending on which entry point an import resolved to. Migration is narrow —
885+
> `.value` reads, `.value = x` writes and `.set()` all still work, because
886+
> `Signal` carries a Vue-compatible `.value` accessor. Only `.get()``s()`
887+
> and `.remove()` need changing.
888+
889+
### useStorage
890+
891+
```typescript
892+
useStorage<T>(key: string, defaultValue: T, options?: UseStorageOptions<T>): StorageRef<T>
893+
```
894+
895+
The richer object API, unchanged: `.value`, `.get()`, `.set()`, `.remove()`,
896+
`.subscribe()`, plus `storage: 'local' | 'session'`, a custom `serializer`, and
897+
`mergeDefaults`. Reach for it when you need `remove()`, a non-JSON encoding, or
898+
subscription with the previous value; otherwise prefer the signals above.
899+
900+
```ts
901+
const cart = useStorage('cart', [], { storage: 'session', mergeDefaults: true })
902+
cart.subscribe((next, prev) => sync(next, prev))
903+
cart.remove()
904+
```
905+
877906
### useCookie
878907

879908
```typescript

packages/stx/src/browser-composables.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@
55
* Reusable composable functions for browser APIs.
66
* All composables follow the `use*` naming convention.
77
*
8+
* @deprecated Vestigial. Nothing in the repo imports this module and it is not
9+
* re-exported from `src/index.ts` or `composables/index.ts` — it ships only
10+
* because the package's `./*` wildcard export makes
11+
* `@stacksjs/stx/browser-composables` resolve.
12+
*
13+
* Everything here exists in a canonical form elsewhere, and the duplicates
14+
* disagree: `useLocalStorage` here returns `{ value, remove }`, while the
15+
* canonical `composables/use-storage.ts` and the signals runtime both return a
16+
* Signal. That third shape is a documented source of downstream confusion —
17+
* at least one Stacks app's agent guide taught the `{ value, remove }`
18+
* destructure for `<script client>` blocks, where it yields `undefined` for
19+
* both (stacksjs/stx#1797).
20+
*
21+
* Import from `@stacksjs/stx` or `@stacksjs/stx/composables` instead. Slated
22+
* for removal in the next major.
23+
*
824
* @module browser-composables
925
*/
1026

@@ -29,6 +45,11 @@ function unref<T>(value: MaybeRef<T>): T {
2945
/**
3046
* Reactive localStorage with automatic serialization.
3147
*
48+
* @deprecated Returns `{ value, remove }` — a third shape for this name, and
49+
* one the signals runtime cannot see. Use `useLocalStorage` from
50+
* `@stacksjs/stx`, which returns a Signal (`s()` reads, `s.set(v)` writes) on
51+
* every import path (stacksjs/stx#1797).
52+
*
3253
* @example
3354
* ```typescript
3455
* const theme = useLocalStorage('theme', 'dark')
@@ -83,6 +104,9 @@ export function useLocalStorage<T>(
83104
/**
84105
* Reactive sessionStorage with automatic serialization.
85106
*
107+
* @deprecated See {@link useLocalStorage} above — use the Signal-returning
108+
* version from `@stacksjs/stx` (stacksjs/stx#1797).
109+
*
86110
* @example
87111
* ```typescript
88112
* const token = useSessionStorage('auth_token', null)

packages/stx/src/composables/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
* ```
1818
*/
1919

20-
// Storage
20+
// Storage. `useLocalStorage` / `useSessionStorage` return a Signal<T> matching
21+
// the runtime contract and the ambient stx.d.ts; the old StorageRef shape was
22+
// removed in #1797 as part of the dual-impl unification. Migrate `.value` reads
23+
// to `s()` and `.value = x` writes to `s.set(x)` (`.set()` is unchanged); for
24+
// `.remove()` / `.subscribe()` / custom serializers use `useStorage`, which
25+
// still returns a StorageRef.
2126
export {
2227
useStorage,
2328
useLocalStorage,

packages/stx/src/composables/use-storage.ts

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,23 @@
33
*
44
* Similar to Nuxt's useStorage but for STX applications.
55
* Provides a reactive, type-safe wrapper around Web Storage APIs.
6+
*
7+
* Two shapes live here, deliberately:
8+
*
9+
* - `useLocalStorage` / `useSessionStorage` return a **Signal** — the same
10+
* shape the ambient `stx.d.ts` declares and the same shape a
11+
* `<script client>` block gets from `window.stx`. One identifier, one
12+
* contract, whichever way it is imported (stacksjs/stx#1797).
13+
* - `useStorage` returns a **StorageRef** — `.value`, `.get()`, `.remove()`,
14+
* `.subscribe()`, custom serializers. It is genuinely a different, richer
15+
* thing, so it keeps its own name and shape.
16+
*
17+
* Parity with the runtime implementation is pinned by
18+
* `test/composables/use-storage-parity.test.ts`, following the precedent
19+
* #1710 set for `useCookie`.
620
*/
21+
import type { Signal } from '../signals-api'
22+
import { effect, state } from '../signals-api'
723

824
export type StorageType = 'local' | 'session'
925

@@ -213,23 +229,124 @@ catch (e) {
213229
}
214230

215231
/**
216-
* Shorthand for useStorage with localStorage
232+
* Reactive storage binding shared by useLocalStorage / useSessionStorage.
233+
*
234+
* Mirrors the runtime implementation in `signals.ts` exactly — `state()` seeded
235+
* from storage, an `effect()` that persists every change, and a `storage`
236+
* listener filtered by both key and storageArea. The runtime is the canonical
237+
* contract; this file follows it (stacksjs/stx#1797).
238+
*/
239+
function storageSignal<T>(
240+
storageType: StorageType,
241+
label: string,
242+
key: string,
243+
defaultValue: T,
244+
options: Omit<UseStorageOptions<T>, 'storage'> = {},
245+
): Signal<T> {
246+
const {
247+
serializer = defaultSerializer,
248+
mergeDefaults = false,
249+
listenToStorageChanges = true,
250+
} = options
251+
252+
const isClient = typeof window !== 'undefined'
253+
const getStorage = (): Storage | null => {
254+
if (!isClient)
255+
return null
256+
return storageType === 'session' ? sessionStorage : localStorage
257+
}
258+
259+
const parse = (raw: string | null): T => {
260+
if (raw === null || raw === undefined)
261+
return defaultValue
262+
try {
263+
const parsed = serializer.read(raw) as T
264+
if (mergeDefaults && typeof defaultValue === 'object' && defaultValue !== null)
265+
return { ...defaultValue, ...parsed }
266+
return parsed
267+
}
268+
catch (e) {
269+
console.warn(`[${label}] Failed to read key "${key}":`, e)
270+
return defaultValue
271+
}
272+
}
273+
274+
const read = (): T => {
275+
const store = getStorage()
276+
if (!store)
277+
return defaultValue
278+
try {
279+
return parse(store.getItem(key))
280+
}
281+
catch (e) {
282+
console.warn(`[${label}] Cannot read "${key}":`, e)
283+
return defaultValue
284+
}
285+
}
286+
287+
const signal = state<T>(read())
288+
289+
effect(() => {
290+
const value = signal()
291+
const store = getStorage()
292+
if (!store)
293+
return
294+
try {
295+
if (value === null || value === undefined)
296+
store.removeItem(key)
297+
else
298+
store.setItem(key, serializer.write(value))
299+
}
300+
catch (e) {
301+
console.warn(`[${label}] Cannot persist "${key}":`, e)
302+
}
303+
})
304+
305+
if (isClient && listenToStorageChanges) {
306+
// Named binding so it can be removed on scope teardown (#1718). The
307+
// storageArea filter stops a sessionStorage write to the same key name
308+
// from clobbering a localStorage signal, and vice versa; synthetic events
309+
// (tests) leave it null and are still accepted.
310+
const onStorage = (event: StorageEvent) => {
311+
if (event.key !== key)
312+
return
313+
if (event.storageArea && event.storageArea !== getStorage())
314+
return
315+
signal.set(parse(event.newValue))
316+
}
317+
window.addEventListener('storage', onStorage)
318+
319+
// eslint-disable-next-line ts/no-explicit-any
320+
const maybeOnDestroy = (globalThis as any).onDestroy
321+
if (typeof maybeOnDestroy === 'function')
322+
maybeOnDestroy(() => window.removeEventListener('storage', onStorage))
323+
}
324+
325+
return signal
326+
}
327+
328+
/**
329+
* Reactive localStorage binding. Returns a Signal: `s()` reads, `s.set(v)`
330+
* writes and persists.
331+
*
332+
* The same shape the ambient `stx.d.ts` declares and the same shape a
333+
* `<script client>` block gets from `window.stx` — one identifier, one
334+
* contract, whichever way it's imported (stacksjs/stx#1797). It used to return
335+
* a `StorageRef` here and a signal there, so `.value` worked or silently
336+
* yielded `undefined` depending on which entry point resolved.
217337
*
218-
* The return type is annotated rather than inferred on purpose: the shipped
219-
* declarations are emitted by bun-plugin-dtsx, which resolves an unannotated
220-
* export to `void`. Consumers importing from `@stacksjs/stx/composables` then
221-
* got `Property 'value' does not exist on type 'void'` for a function that
222-
* plainly returns a StorageRef. See stacksjs/stx#1796.
338+
* For the richer object API — `.value`, `.remove()`, `.subscribe()`, custom
339+
* serializers — use {@link useStorage}, which is unchanged.
223340
*/
224-
export function useLocalStorage<T>(key: string, defaultValue: T, options?: Omit<UseStorageOptions<T>, 'storage'>): StorageRef<T> {
225-
return useStorage(key, defaultValue, { ...options, storage: 'local' })
341+
export function useLocalStorage<T>(key: string, defaultValue: T, options?: Omit<UseStorageOptions<T>, 'storage'>): Signal<T> {
342+
return storageSignal('local', 'useLocalStorage', key, defaultValue, options)
226343
}
227344

228345
/**
229-
* Shorthand for useStorage with sessionStorage
346+
* Reactive sessionStorage binding. Returns a Signal — see {@link useLocalStorage}.
230347
*/
231-
export function useSessionStorage<T>(key: string, defaultValue: T, options?: Omit<UseStorageOptions<T>, 'storage'>): StorageRef<T> {
232-
return useStorage(key, defaultValue, { ...options, storage: 'session' })
348+
export function useSessionStorage<T>(key: string, defaultValue: T, options?: Omit<UseStorageOptions<T>, 'storage'>): Signal<T> {
349+
return storageSignal('session', 'useSessionStorage', key, defaultValue, options)
233350
}
234351

235352
/**

packages/stx/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ export { type HydrationOptions } from './hydration'
1414
// shapes with different field sets; those are kept private to their modules
1515
// to avoid silently shadowing the user-facing one. Surfaced by stacksjs/stx#1707.
1616
export { type CookieOptions } from './composables/use-cookie'
17+
// Storage composables were reachable ONLY via the './composables' subpath, so
18+
// `import { useLocalStorage } from '@stacksjs/stx'` failed with TS2614 while
19+
// `state`/`derived` imported fine — and a `functions/` composable (which the
20+
// docs recommend for anything with persistence) had to reach for the subpath,
21+
// where the same identifier used to return a different shape. See #1797.
22+
export {
23+
clearStorage,
24+
getStorageKeys,
25+
getStorageSize,
26+
type StorageRef,
27+
type StorageType,
28+
useLocalStorage,
29+
useSessionStorage,
30+
useStorage,
31+
type UseStorageOptions,
32+
} from './composables/use-storage'
1733
export { formatSize, getTotalSize } from './deploy'
1834
export { type ImageRenderResult, type ImageVariant, type ProcessedImage, clearImageCache, getFallbackVariant, getMimeType, groupVariantsByFormat, optimizeImage, processImage } from './image-optimization'
1935
export { type AnalysisResult } from './analyzer'

0 commit comments

Comments
 (0)