useLocalStorage.ts 6.8 Ko
Télécharger le fichier
import { OctaneLogger } from '@octane/renderer';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { GetLocalStorage, SetLocalStorage } from '../api';
const userId = new URLSearchParams(window.location.search).get('userid') || 0;
const storageKeyOf = (key: string): string => (userId ? `${key}.${userId}` : key);
// The live hooks of every storage key: discardLocalStorage resets them, so a value cleared at logout is not written
// back by a pending write, by the flush on page hide, or by the next update built on the old in-memory list.
const liveResets = new Map<string, Set<() => void>>();
/** Removes the stored values of these keys and resets every live hook on them to its initial value. */
export const discardLocalStorage = (keys: string[]): void => {
for (const key of keys) {
const storageKey = storageKeyOf(key);
liveResets.get(storageKey)?.forEach((reset) => reset());
try {
window.localStorage.removeItem(storageKey);
} catch {
/* storage blocked: nothing to remove */
}
}
};
const STORAGE_WRITE_DEBOUNCE_MS = 250;
const QUOTA_TRIM_FACTOR = 0.5; // on quota error, keep the newest 50%.
const MIN_RETAINED_ENTRIES = 50;
const isQuotaError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false;
const name = (error as { name?: string }).name;
if (name === 'QuotaExceededError') return true;
// Firefox legacy:
if (name === 'NS_ERROR_DOM_QUOTA_REACHED') return true;
return false;
};
const trimArrayForQuota = <T>(value: T): T => {
if (!Array.isArray(value)) return value;
if (value.length <= MIN_RETAINED_ENTRIES) return [] as unknown as T;
const keep = Math.max(MIN_RETAINED_ENTRIES, Math.floor(value.length * QUOTA_TRIM_FACTOR));
return value.slice(value.length - keep) as unknown as T;
};
interface UseLocalStorageOptions<T> {
/**
* Optional projection applied right before the value is written to
* localStorage. The in-memory React state is unaffected. Use this to
* strip heavy ephemeral fields (e.g. base64 image URLs) that would
* otherwise blow past the storage quota.
*/
toStorage?: (value: T) => unknown;
}
const useLocalStorageState = <T>(key: string, initialValue: T, options: UseLocalStorageOptions<T> = {}): [T, Dispatch<SetStateAction<T>>] => {
key = storageKeyOf(key);
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = typeof window !== 'undefined' ? GetLocalStorage<T>(key) : undefined;
return item ?? initialValue;
} catch (error) {
return initialValue;
}
});
// Event bursts and async callbacks can hold a setter from an earlier render. Every update is resolved
// against the latest value, including updates React has not rendered yet, so none erases a newer one.
const storedValueRef = useRef(storedValue);
const pendingWriteRef = useRef<T | null>(null);
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const optionsRef = useRef(options);
// Keep the latest toStorage projection without re-running effects.
optionsRef.current = options;
const flushWrite = (value: T) => {
if (typeof window === 'undefined') return;
const project = optionsRef.current.toStorage;
const projected = project ? project(value) : value;
try {
SetLocalStorage(key, projected);
return;
} catch (error) {
if (!isQuotaError(error)) {
OctaneLogger.error(error);
return;
}
}
// Quota exceeded - trim and retry once. Anything that isn't an
// array gets cleared, since we have no generic trimming rule.
try {
const trimmed = trimArrayForQuota(projected as T);
SetLocalStorage(key, trimmed);
OctaneLogger.warn(`[useLocalStorage] quota exceeded for ${key}, trimmed payload`);
} catch (retryError) {
OctaneLogger.error(retryError);
// Last resort: drop the key entirely so future writes have room.
try {
window.localStorage.removeItem(key);
} catch (_) {
/* ignore */
}
}
};
// Debounce: high-frequency chat would otherwise trigger one full
// JSON.stringify + setItem per message. We coalesce bursts into one
// write per STORAGE_WRITE_DEBOUNCE_MS window with the latest value.
const scheduleWrite = (value: T) => {
pendingWriteRef.current = value;
if (writeTimerRef.current) clearTimeout(writeTimerRef.current);
writeTimerRef.current = setTimeout(() => {
writeTimerRef.current = null;
if (pendingWriteRef.current !== null) {
flushWrite(pendingWriteRef.current);
pendingWriteRef.current = null;
}
}, STORAGE_WRITE_DEBOUNCE_MS);
};
// Flush a pending write on tab close / hide so we don't lose the last
// burst of activity.
useEffect(() => {
const flushOnLeave = () => {
if (pendingWriteRef.current === null) return;
if (writeTimerRef.current) clearTimeout(writeTimerRef.current);
writeTimerRef.current = null;
flushWrite(pendingWriteRef.current);
pendingWriteRef.current = null;
};
window.addEventListener('pagehide', flushOnLeave);
window.addEventListener('beforeunload', flushOnLeave);
return () => {
window.removeEventListener('pagehide', flushOnLeave);
window.removeEventListener('beforeunload', flushOnLeave);
};
}, []);
const initialValueRef = useRef(initialValue);
useEffect(() => {
const reset = () => {
if (writeTimerRef.current) clearTimeout(writeTimerRef.current);
writeTimerRef.current = null;
pendingWriteRef.current = null;
storedValueRef.current = initialValueRef.current;
setStoredValue(initialValueRef.current);
};
const resets = liveResets.get(key) ?? new Set<() => void>();
resets.add(reset);
liveResets.set(key, resets);
return () => {
resets.delete(reset);
if (!resets.size) liveResets.delete(key);
};
}, [key]);
const setValue: Dispatch<SetStateAction<T>> = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValueRef.current) : value;
storedValueRef.current = valueToStore;
setStoredValue(valueToStore);
scheduleWrite(valueToStore);
} catch (error) {
OctaneLogger.error(error);
}
};
return [storedValue, setValue];
};
export const useLocalStorage = useLocalStorageState;
