"use client" /** * Exxat DS — persisted-state primitive. * * Two layers in one file: * * 1. **Low-level storage** — `getStorageItem`, `setStorageItem`, * `scheduleStorageWrite`, `subscribeToStorageKey`, * `clearAllPersistedState`. Use when you need imperative control * (writing on `beforeunload`, listing keys for a Settings panel, * draining a queue). * * 2. **React hook** — `usePersistedState(key, default, opts?)`. The * drop-in replacement for `useState` that survives reloads. 90% of * consumers should reach for this and not the low-level helpers. * * Centralised so future migrations (server-side sync, IndexedDB * fallback, encryption) only have to touch one file. **Don't read or * write `localStorage` directly inside DS code** — go through here. */ import * as React from "react" import { markBuilderStateDirty } from "@exxatdesignux/product-framework/tenant-products/builder-dev-sync" // ───────────────────────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────────────────────── /** Every DS-written storage key starts with this prefix. */ export const EXXAT_STORAGE_PREFIX = "exxat-ds:" /** Debounce window for collapsed writes. */ export const DEFAULT_DEBOUNCE_MS = 400 export type StorageDriver = "local" | "session" const isBrowser = (): boolean => typeof window !== "undefined" function getDriverImpl(driver: StorageDriver): Storage | null { if (!isBrowser()) return null try { return driver === "session" ? window.sessionStorage : window.localStorage } catch { // Some browsers throw on access in private mode / locked-down profiles. return null } } // ───────────────────────────────────────────────────────────────────────────── // Namespacing helpers // ───────────────────────────────────────────────────────────────────────────── /** * Compose a fully-qualified storage key. Pass the unprefixed key (e.g. * `"sidebar:collapsed"`) — the prefix is added here. If the caller already * passes a prefixed key (e.g. legacy `exxat-ds:placements:lifecycle:v1:main`) * we don't double-prefix. */ export function namespacedKey(key: string): string { return key.startsWith(EXXAT_STORAGE_PREFIX) ? key : `${EXXAT_STORAGE_PREFIX}${key}` } // ───────────────────────────────────────────────────────────────────────────── // Read / write // ───────────────────────────────────────────────────────────────────────────── export function getStorageItem( key: string, driver: StorageDriver = "local", ): string | null { const store = getDriverImpl(driver) if (!store) return null try { return store.getItem(namespacedKey(key)) } catch { return null } } export function setStorageItem( key: string, value: string, driver: StorageDriver = "local", ): void { const store = getDriverImpl(driver) if (!store) return try { const fullKey = namespacedKey(key) // Short-circuit no-op writes. `usePersistedState` re-runs its save // effect on every mount with the same hydrated value; without this // guard, mounting any persisted hook would fire `markBuilderStateDirty` // and trigger a builder dev-sync flush even though nothing changed — // which round-tripped through `tenant-products.json` and caused a Vite // reload storm (fixed in @exxatdesignux/ui@0.6.18). if (store.getItem(fullKey) === value) return store.setItem(fullKey, value) markBuilderStateDirty() // Same-tab listeners (other `usePersistedState` hooks on this key). The // browser `storage` event only fires in *other* tabs. notifySameTabStorageListeners(fullKey, value) } catch { // Quota exceeded / private mode / disabled storage. Swallow — the value // simply won't survive a refresh; in-memory state still works. } } export function removeStorageItem( key: string, driver: StorageDriver = "local", ): void { const store = getDriverImpl(driver) if (!store) return try { const fullKey = namespacedKey(key) store.removeItem(fullKey) notifySameTabStorageListeners(fullKey, null) } catch { /* noop */ } } // ───────────────────────────────────────────────────────────────────────────── // Debounced write // ───────────────────────────────────────────────────────────────────────────── type PendingWrite = { timer: ReturnType key: string value: string driver: StorageDriver } /** Keyed by namespaced key; keeps the payload so a flush can still write it. */ const debounceTimers = new Map() /** * Schedule a debounced write. Successive calls for the same `key` reset the * timer so only the trailing value lands in storage. Use this for the hot * path (column resize, search keystrokes, scroll position) — anything that * fires faster than ~10Hz. * * For one-shot writes (theme change, sidebar toggle) call `setStorageItem` * directly; the debounce is overhead you don't need. */ export function scheduleStorageWrite( key: string, value: string, options: { debounceMs?: number; driver?: StorageDriver } = {}, ): void { if (!isBrowser()) return const { debounceMs = DEFAULT_DEBOUNCE_MS, driver = "local" } = options const fullKey = namespacedKey(key) const prev = debounceTimers.get(fullKey) if (prev) clearTimeout(prev.timer) const timer = setTimeout(() => { debounceTimers.delete(fullKey) setStorageItem(key, value, driver) }, debounceMs) debounceTimers.set(fullKey, { timer, key, value, driver }) } /** * Force any pending debounced writes to flush synchronously. Safe to call * on `beforeunload` so a tab close doesn't drop the last keystroke. */ export function flushPendingStorageWrites(): void { if (!isBrowser()) return const pending = [...debounceTimers.values()] debounceTimers.clear() for (const write of pending) { clearTimeout(write.timer) setStorageItem(write.key, write.value, write.driver) } } // ───────────────────────────────────────────────────────────────────────────── // Same-tab + cross-tab subscription // ───────────────────────────────────────────────────────────────────────────── type StorageListener = (newValue: string | null) => void /** In-memory bus — `storage` events never fire in the tab that wrote. */ const sameTabStorageListeners = new Map>() let crossTabStorageInstalled = false function ensureCrossTabStorageListener(): void { if (crossTabStorageInstalled || !isBrowser()) return crossTabStorageInstalled = true // One window listener for the whole app — subscribers register in the map // (vercel-react-best-practices: client-event-listeners). window.addEventListener("storage", (e: StorageEvent) => { if (!e.key) return const listeners = sameTabStorageListeners.get(e.key) if (!listeners?.size) return for (const listener of listeners) { try { listener(e.newValue) } catch { /* listener errors must not break other subscribers */ } } }) } function notifySameTabStorageListeners( fullKey: string, newValue: string | null, ): void { const listeners = sameTabStorageListeners.get(fullKey) if (!listeners?.size) return for (const listener of listeners) { try { listener(newValue) } catch { /* listener errors must not break the writer */ } } } /** * Subscribe to changes for a single DS-namespaced key. * * - **Same tab:** in-memory notify after `setStorageItem` / `removeStorageItem` * (needed so two hooks on one key stay aligned — e.g. FAB offset + invite). * - **Other tabs:** browser `storage` event (single shared listener). * * Returns an unsubscribe function. SSR-safe (returns a no-op on the server). */ export function subscribeToStorageKey( key: string, listener: StorageListener, ): () => void { if (!isBrowser()) return () => {} const fullKey = namespacedKey(key) let bucket = sameTabStorageListeners.get(fullKey) if (!bucket) { bucket = new Set() sameTabStorageListeners.set(fullKey, bucket) } bucket.add(listener) ensureCrossTabStorageListener() return () => { bucket?.delete(listener) if (bucket?.size === 0) sameTabStorageListeners.delete(fullKey) } } /** * Subscribe to a raw `localStorage` key (no DS prefix). Shares the same * window `storage` listener as {@link subscribeToStorageKey}. * * Use for app-owned keys that are not written through `setStorageItem` * (dedicated search recents, Ask Leo composer recents). Same-tab updates * still need a CustomEvent (or a `setStorageItem` write) because the * browser does not fire `storage` in the tab that wrote. */ export function subscribeToExactStorageKey( exactKey: string, listener: StorageListener, ): () => void { if (!isBrowser()) return () => {} let bucket = sameTabStorageListeners.get(exactKey) if (!bucket) { bucket = new Set() sameTabStorageListeners.set(exactKey, bucket) } bucket.add(listener) ensureCrossTabStorageListener() return () => { bucket?.delete(listener) if (bucket?.size === 0) sameTabStorageListeners.delete(exactKey) } } // ───────────────────────────────────────────────────────────────────────────── // Bulk operations // ───────────────────────────────────────────────────────────────────────────── /** * Wipe every key the DS owns (anything starting with `exxat-ds:`). Exposed * for Settings → "Reset preferences" flows. Customer apps SHOULD wire this * to a settings button rather than letting users hunt through DevTools. */ export function clearAllPersistedState(driver: StorageDriver = "local"): void { const store = getDriverImpl(driver) if (!store) return try { const toRemove: string[] = [] for (let i = 0; i < store.length; i++) { const k = store.key(i) if (k && k.startsWith(EXXAT_STORAGE_PREFIX)) toRemove.push(k) } for (const k of toRemove) store.removeItem(k) } catch { /* noop */ } } /** * List every DS-namespaced key currently in storage. Useful for Settings * dashboards that want to show users what they can clear. */ export function listPersistedKeys(driver: StorageDriver = "local"): string[] { const store = getDriverImpl(driver) if (!store) return [] try { const out: string[] = [] for (let i = 0; i < store.length; i++) { const k = store.key(i) if (k && k.startsWith(EXXAT_STORAGE_PREFIX)) out.push(k) } return out } catch { return [] } } // ───────────────────────────────────────────────────────────────────────────── // React hook // ───────────────────────────────────────────────────────────────────────────── interface PersistedEnvelope { v: number d: T } export interface UsePersistedStateOptions { /** Storage driver. Default `"local"`. Use `"session"` for tab-scoped state. */ driver?: StorageDriver /** * Schema version. Bump when the shape of `T` changes. Old payloads with a * different version are passed to `migrate`; if `migrate` is missing or * returns `undefined`, the persisted record is dropped. */ version?: number /** * Migrate a stored payload from a previous version into the current * shape. Returning `undefined` discards the record (state falls back to * `defaultValue`). */ migrate?: (raw: unknown, fromVersion: number) => T | undefined /** Custom serializer. Default is `JSON.stringify` (with envelope). */ serialize?: (value: T) => string /** * Custom deserializer. Default is `JSON.parse`. Return `undefined` to * discard the payload (e.g. shape no longer matches and you don't have a * migration). */ deserialize?: (raw: string) => T | undefined /** * Debounce window for writes. Default 400ms. Pass `0` for synchronous * writes (one-shot toggles). */ debounceMs?: number /** * Listen for `storage` events from other tabs and reflect them locally. * Default `true`. */ syncAcrossTabs?: boolean } const defaultOptions = { driver: "local" as StorageDriver, version: 1, debounceMs: DEFAULT_DEBOUNCE_MS, syncAcrossTabs: true, } function readPersisted( key: string, defaultValue: T, opts: Required, "driver" | "version">> & Pick, "migrate" | "deserialize">, ): T { const raw = getStorageItem(key, opts.driver) if (raw == null) return defaultValue // Try the canonical envelope first. Older payloads (pre-`usePersistedState`) // may not carry an envelope — let the consumer's `deserialize` handle them. try { const parsed = JSON.parse(raw) as unknown if ( parsed != null && typeof parsed === "object" && "v" in (parsed as object) && "d" in (parsed as object) ) { const env = parsed as PersistedEnvelope if (env.v === opts.version) { return env.d as T } if (opts.migrate) { const migrated = opts.migrate(env.d, env.v) if (migrated !== undefined) return migrated } // Stale version, no migration → drop. return defaultValue } // Not an envelope. Fall through to deserialize raw if provided. } catch { // Not JSON — fall through. } if (opts.deserialize) { const out = opts.deserialize(raw) if (out !== undefined) return out } return defaultValue } function writePersisted( key: string, value: T, opts: Required, "driver" | "version" | "debounceMs">> & Pick, "serialize">, ): void { const serialize = opts.serialize ?? ((v: T) => JSON.stringify({ v: opts.version, d: v })) const payload = serialize(value) if (opts.debounceMs <= 0) { setStorageItem(key, payload, opts.driver) } else { scheduleStorageWrite(key, payload, { debounceMs: opts.debounceMs, driver: opts.driver }) } } /** * Persisted state hook. Drop-in replacement for `useState`. * * ```tsx * const [collapsed, setCollapsed] = usePersistedState("sidebar:collapsed", false) * const [theme, setTheme, clearTheme] = usePersistedState("theme", "light", { debounceMs: 0 }) * ``` * * Returns `[value, setValue, clear]` — `clear()` removes the entry from * storage and resets in-memory state to the latest `defaultValue`. * * **Disabling persistence at runtime:** pass `key = ""` (empty string) and * the hook degrades to plain `useState` — no reads, no writes, no `storage` * subscription, no namespacing. This lets components conditionally enable * persistence (e.g. via a `persistKey?: string` prop) without breaking the * rules of hooks. */ export function usePersistedState( key: string, defaultValue: T, options?: UsePersistedStateOptions, ): [T, React.Dispatch>, () => void] { const merged = { ...defaultOptions, ...options } const optsRef = React.useRef(merged) React.useEffect(() => { optsRef.current = merged }) const enabled = key.length > 0 // Lazy initial state: synchronous read on the client. On the server this // returns `defaultValue` (storage is gated by `typeof window`); the layout // effect below catches up on first paint. const [value, setValue] = React.useState(() => enabled ? readPersisted(key, defaultValue, optsRef.current) : defaultValue, ) const hydratedRef = React.useRef(false) // SSR catch-up. If the initial render saw `defaultValue` (because we were // on the server), this fires after mount and re-applies the persisted // value before the first paint. React.useLayoutEffect(() => { if (hydratedRef.current) return hydratedRef.current = true if (!enabled) return const persisted = readPersisted(key, defaultValue, optsRef.current) // Only push back into state if we actually have a different value to // avoid a no-op re-render when the lazy init already saw the value. setValue(prev => (Object.is(prev, persisted) ? prev : persisted)) // `defaultValue` is captured by `readPersisted` only when no payload // exists; new defaultValue identities should not retrigger this effect. // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, enabled]) // Save side effect — debounced inside the storage layer. React.useEffect(() => { if (!hydratedRef.current) return if (!enabled) return writePersisted(key, value, optsRef.current) }, [key, value, enabled]) // Same-tab + cross-tab sync (see `subscribeToStorageKey`). React.useEffect(() => { if (!enabled) return if (!optsRef.current.syncAcrossTabs) return return subscribeToStorageKey(key, () => { const next = readPersisted(key, defaultValue, optsRef.current) // Avoid writer → notify → writer ping-pong on fresh object identities. setValue((prev) => JSON.stringify(prev) === JSON.stringify(next) ? prev : next, ) }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, enabled]) const clear = React.useCallback(() => { if (enabled) removeStorageItem(key, optsRef.current.driver) setValue(defaultValue) // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, enabled]) return [value, setValue, clear] }