Files
homarr/packages/widgets/src/common/use-local-widget-state.ts
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

147 lines
4.6 KiB
TypeScript

"use client";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
interface StoredWidgetState {
version: number;
value: unknown;
}
interface UseLocalWidgetStateOptions<T> {
key: string | undefined;
version: number;
defaultValue: T | (() => T);
validate?: (value: unknown) => value is T;
migrate?: (value: unknown, storedVersion: number) => T | undefined;
}
const resolveDefaultValue = <T>(value: T | (() => T)): T =>
typeof value === "function" ? (value as () => T)() : value;
export const serializeLocalWidgetState = <T>(version: number, value: T): string => JSON.stringify({ version, value });
export const parseLocalWidgetState = <T>(
serialized: string,
version: number,
validate?: (value: unknown) => value is T,
migrate?: (value: unknown, storedVersion: number) => T | undefined,
): T | undefined => {
try {
const parsed: unknown = JSON.parse(serialized);
if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || !("value" in parsed)) {
return undefined;
}
const stored = parsed as StoredWidgetState;
if (typeof stored.version !== "number") return undefined;
if (stored.version !== version) {
const migrated = migrate?.(stored.value, stored.version);
if (migrated === undefined || (validate && !validate(migrated))) return undefined;
return migrated;
}
if (validate && !validate(stored.value)) return undefined;
return stored.value as T;
} catch {
return undefined;
}
};
export const useLocalWidgetState = <T>({
key,
version,
defaultValue,
validate,
migrate,
}: UseLocalWidgetStateOptions<T>): [T, Dispatch<SetStateAction<T>>, () => void] => {
const defaultValueRef = useRef(defaultValue);
const validateRef = useRef(validate);
const migrateRef = useRef(migrate);
defaultValueRef.current = defaultValue;
validateRef.current = validate;
migrateRef.current = migrate;
const createDefaultValue = useCallback(() => resolveDefaultValue(defaultValueRef.current), []);
const [value, setValueState] = useState<T>(createDefaultValue);
const valueRef = useRef(value);
const setMemoryValue = useCallback((nextValue: T) => {
valueRef.current = nextValue;
setValueState(nextValue);
}, []);
const removeStoredValue = useCallback(() => {
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch {
// Storage can be unavailable in private or restricted browser contexts.
}
}, [key]);
useEffect(() => {
if (!key) {
setMemoryValue(createDefaultValue());
return;
}
try {
const serialized = window.localStorage.getItem(key);
if (serialized === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(serialized, version, validateRef.current, migrateRef.current);
if (parsed === undefined) {
window.localStorage.removeItem(key);
setMemoryValue(createDefaultValue());
return;
}
setMemoryValue(parsed);
} catch {
setMemoryValue(createDefaultValue());
}
}, [createDefaultValue, key, setMemoryValue, version]);
useEffect(() => {
if (!key) return;
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage || event.key !== key) return;
if (event.newValue === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(event.newValue, version, validateRef.current, migrateRef.current);
if (parsed !== undefined) setMemoryValue(parsed);
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, [createDefaultValue, key, setMemoryValue, version]);
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(nextValue) => {
const resolved =
typeof nextValue === "function" ? (nextValue as (previous: T) => T)(valueRef.current) : nextValue;
if (Object.is(resolved, valueRef.current)) return;
setMemoryValue(resolved);
if (key) {
try {
window.localStorage.setItem(key, serializeLocalWidgetState(version, resolved));
} catch {
// Keep the in-memory state usable when persistence is unavailable.
}
}
},
[key, setMemoryValue, version],
);
const resetValue = useCallback(() => {
removeStoredValue();
setMemoryValue(createDefaultValue());
}, [createDefaultValue, removeStoredValue, setMemoryValue]);
return [value, setValue, resetValue];
};