import * as Schema from "effect/Schema"; import * as Record from "effect/Record"; import { useSyncExternalStore } from "react"; export class LocalStorageOperationError extends Schema.TaggedError()( "LocalStorageOperationError", { operation: Schema.Literals(["read", "decode", "encode", "update", "write", "remove", "notify"]), storageKey: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to ${this.operation} local storage item ${this.storageKey}.`; } } const fallbackStorage: Storage = (() => { const store = new Map(); return { clear: () => store.clear(), getItem: (_) => store.get(_) ?? null, key: (_) => Record.keys(store).at(_) ?? null, get length() { return store.size; }, removeItem: (_) => store.delete(_), setItem: (_, value) => store.set(_, value), }; })(); const getStorage = (): Storage => typeof window !== "undefined" ? window.localStorage : fallbackStorage; const read = (key: string) => { try { return getStorage().getItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); } }; const decode = (key: string, schema: Schema.Codec, value: string) => { try { return Schema.decodeSync(Schema.fromJsonString(schema))(value); } catch (cause) { throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause }); } }; const encode = (key: string, schema: Schema.Codec, value: T) => { try { return Schema.encodeSync(Schema.fromJsonString(schema))(value); } catch (cause) { throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause }); } }; export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { const item = read(key); return item === null ? null : decode(key, schema, item); }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { const valueToSet = encode(key, schema, value); try { getStorage().setItem(key, valueToSet); } catch (cause) { throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); } }; export const removeLocalStorageItem = (key: string) => { try { getStorage().removeItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); } }; const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; interface LocalStorageChangeDetail { key: string; } function dispatchLocalStorageChange(key: string) { if (typeof window === "undefined") return; try { window.dispatchEvent( new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { detail: { key }, }), ); } catch (cause) { throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause }); } } function readSerializedValue(key: string): string | null { try { return read(key); } catch (error) { console.error("[LOCALSTORAGE] Could not read stored value.", error); return null; } } function decodeOrInitial( key: string, schema: Schema.Codec, serializedValue: string | null, initialValue: T, ): T { if (serializedValue === null) { return initialValue; } try { return decode(key, schema, serializedValue); } catch (error) { console.error("[LOCALSTORAGE] Could not decode stored value.", error); return initialValue; } } export function writeLocalStorageValue( key: string, value: T | ((val: T) => T), initialValue: T, schema: Schema.Codec, ): void { try { let valueToStore: T; if (typeof value === "function") { // Fall back to the initial value so corrupt storage cannot block later writes. let currentValue = initialValue; try { currentValue = getLocalStorageItem(key, schema) ?? initialValue; } catch (error) { console.error("[LOCALSTORAGE] Could not read stored value.", error); } try { valueToStore = (value as (val: T) => T)(currentValue); } catch (cause) { throw new LocalStorageOperationError({ operation: "update", storageKey: key, cause, }); } } else { valueToStore = value; } if (valueToStore === null) { removeLocalStorageItem(key); } else { setLocalStorageItem(key, valueToStore, schema); } dispatchLocalStorageChange(key); } catch (error) { console.error("[LOCALSTORAGE] Could not update stored value.", error); } } export function useLocalStorage( key: string, initialValue: T, schema: Schema.Codec, ): [T, (value: T | ((val: T) => T)) => void] { const getSnapshot = () => readSerializedValue(key); const subscribe = (onStoreChange: () => void) => { const handleStorageChange = (event: StorageEvent) => { if (event.key === key) { onStoreChange(); } }; const handleLocalChange = (event: CustomEvent) => { if (event.detail.key === key) { onStoreChange(); } }; window.addEventListener("storage", handleStorageChange); window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); return () => { window.removeEventListener("storage", handleStorageChange); window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); }; }; const serializedValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const storedValue = decodeOrInitial(key, schema, serializedValue, initialValue); const setValue = (value: T | ((val: T) => T)) => { writeLocalStorageValue(key, value, initialValue, schema); }; return [storedValue, setValue]; }