|
3 | 3 | * |
4 | 4 | * Similar to Nuxt's useStorage but for STX applications. |
5 | 5 | * 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`. |
6 | 20 | */ |
| 21 | +import type { Signal } from '../signals-api' |
| 22 | +import { effect, state } from '../signals-api' |
7 | 23 |
|
8 | 24 | export type StorageType = 'local' | 'session' |
9 | 25 |
|
@@ -213,23 +229,124 @@ catch (e) { |
213 | 229 | } |
214 | 230 |
|
215 | 231 | /** |
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. |
217 | 337 | * |
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. |
223 | 340 | */ |
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) |
226 | 343 | } |
227 | 344 |
|
228 | 345 | /** |
229 | | - * Shorthand for useStorage with sessionStorage |
| 346 | + * Reactive sessionStorage binding. Returns a Signal — see {@link useLocalStorage}. |
230 | 347 | */ |
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) |
233 | 350 | } |
234 | 351 |
|
235 | 352 | /** |
|
0 commit comments