SkateSpots/src/hooks/useLocalStorage.ts
SkateSpots 9189fc6584 fix(trust): tier 2 — moderation, labels & data integrity (H2–H17)
- H2 useAdminModeration now fetches the 'cleared' label too, so an admin Undo
  actually propagates (newest-verdict-wins) instead of only working on-device.
- H3 admin takedowns publish the NIP-32 'hidden' label (useAdminHide) instead of
  an author-only kind-5 relays ignore; the cross-app hide is real now.
- H5 the /stats outage banner works: queryCanonical reports relay failures via
  onError, so 'partial' can be true and zeros are never presented as truth.
- H6 useShops got its own cache namespace (was colliding with useSkateShops).
- H7 listing NIP-05 is verified against the user's key, not merely self-asserted.
- H8 NIP-22 spot comments/transfer now use uppercase root tags (A/K/P); readers
  accept legacy #a; NIP.md corrected.
- H9 Lab profile edits preserve foreign  spot refs (crew link = 35006 only).
- H10 collection edits preserve foreign NIP-51 tags.
- H11 LAB_KINDS = all 13 provider kinds (was 35001-35005).
- H16 skip-to-content is a button that focuses <main> (was href=#main, which
  HashRouter treated as a route → 404).
- H17 CustomizeNavSheet is translated; useLocalStorage re-reads on key change and
  keeps stateRef in sync cross-tab.
- Backup 'confirmed' flag is now per-account (backup-confirmed:<pubkey>).
2026-10-02 21:30:01 +07:00

101 lines
3.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Generic hook for managing localStorage state.
*
* The setter is **referentially stable** (and safe against stale closures): it
* keeps the latest value in a ref and updates via a functional `setState`. This
* matters because several callers put the setter (directly or through a chain of
* callbacks) in an effect's dependency array — an unstable identity there causes
* the effect to re-run every render, and if it also sets state that is an
* infinite render loop.
*/
export function useLocalStorage<T>(
key: string,
defaultValue: T,
serializer?: {
serialize: (value: T) => string;
deserialize: (value: string) => T;
}
) {
// Keep the serializer in a ref so `serialize`/`deserialize` stay stable even
// when callers pass an inline object literal on every render.
const serializerRef = useRef(serializer);
serializerRef.current = serializer;
const serialize = useCallback(
(value: T) => (serializerRef.current?.serialize ?? JSON.stringify)(value),
[],
);
const deserialize = useCallback(
(value: string) => (serializerRef.current?.deserialize ?? JSON.parse)(value),
[],
);
const [state, setState] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? deserialize(item) : defaultValue;
} catch (error) {
console.warn(`Failed to load ${key} from localStorage:`, error);
return defaultValue;
}
});
// Mirror the latest state so the stable setter can support functional updates
// without a stale closure.
const stateRef = useRef(state);
stateRef.current = state;
const defaultValueRef = useRef(defaultValue);
defaultValueRef.current = defaultValue;
// Re-read when the key CHANGES (e.g. a per-account key once the user logs in
// or switches accounts). The lazy initializer only runs on mount.
const prevKeyRef = useRef(key);
useEffect(() => {
if (prevKeyRef.current === key) return;
prevKeyRef.current = key;
try {
const item = localStorage.getItem(key);
const next = item ? deserialize(item) : defaultValueRef.current;
stateRef.current = next;
setState(next);
} catch (error) {
console.warn(`Failed to load ${key} from localStorage:`, error);
}
}, [key, deserialize]);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(stateRef.current) : value;
stateRef.current = valueToStore;
setState(valueToStore);
localStorage.setItem(key, serialize(valueToStore));
} catch (error) {
console.warn(`Failed to save ${key} to localStorage:`, error);
}
},
[key, serialize],
);
// Sync with localStorage changes from other tabs
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
const next = deserialize(e.newValue);
stateRef.current = next;
setState(next);
} catch (error) {
console.warn(`Failed to sync ${key} from localStorage:`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, deserialize]);
return [state, setValue] as const;
}