1import * as Schema from "effect/Schema";
2import * as Record from "effect/Record";
3import { useSyncExternalStore } from "react";
4
5export class LocalStorageOperationError extends Schema.TaggedError<LocalStorageOperationError>()(
6 "LocalStorageOperationError",
7 {
8 operation: Schema.Literals(["read", "decode", "encode", "update", "write", "remove", "notify"]),
9 storageKey: Schema.String,
10 cause: Schema.Defect(),
11 },
12) {
13 override get message(): string {
14 return `Failed to ${this.operation} local storage item ${this.storageKey}.`;
15 }
16}
17
18const fallbackStorage: Storage = (() => {
19 const store = new Map<string, string>();
20 return {
21 clear: () => store.clear(),
22 getItem: (_) => store.get(_) ?? null,
23 key: (_) => Record.keys(store).at(_) ?? null,
24 get length() {
25 return store.size;
26 },
27 removeItem: (_) => store.delete(_),
28 setItem: (_, value) => store.set(_, value),
29 };
30})();
31
32const getStorage = (): Storage =>
33 typeof window !== "undefined" ? window.localStorage : fallbackStorage;
34
35const read = (key: string) => {
36 try {
37 return getStorage().getItem(key);
38 } catch (cause) {
39 throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause });
40 }
41};
42
43const decode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: string) => {
44 try {
45 return Schema.decodeSync(Schema.fromJsonString(schema))(value);
46 } catch (cause) {
47 throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause });
48 }
49};
50
51const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => {
52 try {
53 return Schema.encodeSync(Schema.fromJsonString(schema))(value);
54 } catch (cause) {
55 throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause });
56 }
57};
58
59export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {
60 const item = read(key);
61 return item === null ? null : decode(key, schema, item);
62};
63
64export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {
65 const valueToSet = encode(key, schema, value);
66 try {
67 getStorage().setItem(key, valueToSet);
68 } catch (cause) {
69 throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause });
70 }
71};
72
73export const removeLocalStorageItem = (key: string) => {
74 try {
75 getStorage().removeItem(key);
76 } catch (cause) {
77 throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause });
78 }
79};
80
81const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";
82
83interface LocalStorageChangeDetail {
84 key: string;
85}
86
87function dispatchLocalStorageChange(key: string) {
88 if (typeof window === "undefined") return;
89 try {
90 window.dispatchEvent(
91 new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
92 detail: { key },
93 }),
94 );
95 } catch (cause) {
96 throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause });
97 }
98}
99
100function readSerializedValue(key: string): string | null {
101 try {
102 return read(key);
103 } catch (error) {
104 console.error("[LOCALSTORAGE] Could not read stored value.", error);
105 return null;
106 }
107}
108
109function decodeOrInitial<T, E>(
110 key: string,
111 schema: Schema.Codec<T, E>,
112 serializedValue: string | null,
113 initialValue: T,
114): T {
115 if (serializedValue === null) {
116 return initialValue;
117 }
118 try {
119 return decode(key, schema, serializedValue);
120 } catch (error) {
121 console.error("[LOCALSTORAGE] Could not decode stored value.", error);
122 return initialValue;
123 }
124}
125
126export function readLocalStorageValue<T, E>(
127 key: string,
128 initialValue: T,
129 schema: Schema.Codec<T, E>,
130): T {
131 return decodeOrInitial(key, schema, readSerializedValue(key), initialValue);
132}
133
134/** Fires on writes from this tab and from other tabs. */
135export function subscribeLocalStorageKey(key: string, onChange: () => void): () => void {
136 const handleStorageChange = (event: StorageEvent) => {
137 if (event.key === key || event.key === null) {
138 onChange();
139 }
140 };
141 const handleLocalChange = (event: CustomEvent<LocalStorageChangeDetail>) => {
142 if (event.detail.key === key) {
143 onChange();
144 }
145 };
146
147 window.addEventListener("storage", handleStorageChange);
148 window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener);
149 return () => {
150 window.removeEventListener("storage", handleStorageChange);
151 window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener);
152 };
153}
154
155export function writeLocalStorageValue<T, E>(
156 key: string,
157 value: T | ((val: T) => T),
158 initialValue: T,
159 schema: Schema.Codec<T, E>,
160): void {
161 try {
162 let valueToStore: T;
163 if (typeof value === "function") {
164 // Fall back to the initial value so corrupt storage cannot block later writes.
165 let currentValue = initialValue;
166 try {
167 currentValue = getLocalStorageItem(key, schema) ?? initialValue;
168 } catch (error) {
169 console.error("[LOCALSTORAGE] Could not read stored value.", error);
170 }
171 try {
172 valueToStore = (value as (val: T) => T)(currentValue);
173 } catch (cause) {
174 throw new LocalStorageOperationError({
175 operation: "update",
176 storageKey: key,
177 cause,
178 });
179 }
180 } else {
181 valueToStore = value;
182 }
183 if (valueToStore === null) {
184 removeLocalStorageItem(key);
185 } else {
186 setLocalStorageItem(key, valueToStore, schema);
187 }
188 dispatchLocalStorageChange(key);
189 } catch (error) {
190 console.error("[LOCALSTORAGE] Could not update stored value.", error);
191 }
192}
193
194export function useLocalStorage<T, E>(
195 key: string,
196 initialValue: T,
197 schema: Schema.Codec<T, E>,
198): [T, (value: T | ((val: T) => T)) => void] {
199 const getSnapshot = () => readSerializedValue(key);
200
201 const subscribe = (onStoreChange: () => void) => subscribeLocalStorageKey(key, onStoreChange);
202
203 const serializedValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
204 const storedValue = decodeOrInitial(key, schema, serializedValue, initialValue);
205
206 const setValue = (value: T | ((val: T) => T)) => {
207 writeLocalStorageValue(key, value, initialValue, schema);
208 };
209
210 return [storedValue, setValue];
211}