diff --git a/components/nav-top.tsx b/components/nav-top.tsx index 5ded4923..63148c52 100644 --- a/components/nav-top.tsx +++ b/components/nav-top.tsx @@ -9,6 +9,7 @@ import { useRouter } from "next/router"; import SignInModal from "./sign-in/SignInModal"; import { ProfileWithDropdown } from "./utility-components/profile/profile-dropdown"; import { ShopProfile } from "../utils/types/types"; +import { getLocalStorageJson } from "@/utils/safe-json"; const TopNav = ({ setFocusedPubkey, @@ -44,11 +45,14 @@ const TopNav = ({ useEffect(() => { const fetchAndUpdateCartQuantity = async () => { - const cartList = localStorage.getItem("cart") - ? JSON.parse(localStorage.getItem("cart") as string) - : []; - if (cartList) { + const cartList = getLocalStorageJson("cart", [], { + removeOnError: true, + validate: Array.isArray, + }); + if (cartList.length > 0) { setCartQuantity(cartList.length); + } else { + setCartQuantity(0); } }; diff --git a/components/utility-components/checkout-card.tsx b/components/utility-components/checkout-card.tsx index 5fd8de88..a9cbf1cd 100644 --- a/components/utility-components/checkout-card.tsx +++ b/components/utility-components/checkout-card.tsx @@ -45,8 +45,28 @@ import WeightSelector from "./weight-selector"; import BulkSelector from "./bulk-selector"; import ZapsnagButton from "@/components/ZapsnagButton"; import { RawEventModal, EventIdModal } from "./modals/event-modals"; +import { getLocalStorageJson } from "@/utils/safe-json"; const SUMMARY_CHARACTER_LIMIT = 100; +type CartDiscountsMap = Record; + +const isCartDiscountsMap = (value: unknown): value is CartDiscountsMap => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + return Object.values(value).every((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + + const candidate = entry as { code?: unknown; percentage?: unknown }; + return ( + typeof candidate.code === "string" && + typeof candidate.percentage === "number" + ); + }); +}; export default function CheckoutCard({ productData, @@ -177,9 +197,10 @@ export default function CheckoutCard({ useEffect(() => { if (typeof window !== "undefined") { - const cartList = localStorage.getItem("cart") - ? JSON.parse(localStorage.getItem("cart") as string) - : []; + const cartList = getLocalStorageJson("cart", [], { + removeOnError: true, + validate: Array.isArray, + }); if (cartList && cartList.length > 0) { setCart(cartList); } @@ -345,8 +366,15 @@ export default function CheckoutCard({ // Store discount code if applied if (appliedDiscount > 0 && discountCode) { - const storedDiscounts = localStorage.getItem("cartDiscounts"); - const discounts = storedDiscounts ? JSON.parse(storedDiscounts) : {}; + const discounts = getLocalStorageJson( + "cartDiscounts", + {}, + { + removeOnError: true, + removeOnValidationError: true, + validate: isCartDiscountsMap, + } + ); discounts[productData.pubkey] = { code: discountCode, percentage: appliedDiscount, diff --git a/components/wallet/transactions.tsx b/components/wallet/transactions.tsx index 625a2d6a..e1b7e162 100644 --- a/components/wallet/transactions.tsx +++ b/components/wallet/transactions.tsx @@ -12,7 +12,7 @@ import { Transaction } from "@/utils/types/types"; // add found proofs as nutsack deposit with different icon const Transactions = () => { - const [history, setHistory] = useState([]); + const [history, setHistory] = useState([]); useEffect(() => { // Function to fetch and update transactions diff --git a/pages/cart/index.tsx b/pages/cart/index.tsx index a17cb48a..c228ee4f 100644 --- a/pages/cart/index.tsx +++ b/pages/cart/index.tsx @@ -30,6 +30,7 @@ import currencySelection from "../../public/currencySelection.json"; import { ShopMapContext, ProfileMapContext } from "@/utils/context/context"; import { nip19 } from "nostr-tools"; import StorefrontThemeWrapper from "@/components/storefront/storefront-theme-wrapper"; +import { getLocalStorageJson } from "@/utils/safe-json"; interface QuantitySelectorProps { value: number; @@ -40,6 +41,26 @@ interface QuantitySelectorProps { max: number; } +type CartDiscountsMap = Record; + +const isCartDiscountsMap = (value: unknown): value is CartDiscountsMap => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + return Object.values(value).every((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + + const candidate = entry as { code?: unknown; percentage?: unknown }; + return ( + typeof candidate.code === "string" && + typeof candidate.percentage === "number" + ); + }); +}; + function QuantitySelector({ value, onDecrease, @@ -196,13 +217,10 @@ export default function Component() { sessionStorage.getItem("sf_seller_pubkey") || localStorage.getItem("sf_seller_pubkey") || ""; - let fullCart: ProductData[] = []; - try { - const raw = localStorage.getItem("cart"); - if (raw) fullCart = JSON.parse(raw); - } catch { - localStorage.removeItem("cart"); - } + const fullCart = getLocalStorageJson("cart", [], { + removeOnError: true, + validate: Array.isArray, + }); let cartList = fullCart; if (sfPk) { @@ -224,21 +242,28 @@ export default function Component() { } // Load saved discount codes - const storedDiscounts = localStorage.getItem("cartDiscounts"); - if (storedDiscounts) { - let discounts; - try { - discounts = JSON.parse(storedDiscounts); - } catch { - localStorage.removeItem("cartDiscounts"); - return; + const discounts = getLocalStorageJson( + "cartDiscounts", + {}, + { + removeOnError: true, + removeOnValidationError: true, + validate: isCartDiscountsMap, } + ); + if (Object.keys(discounts).length > 0) { const codes: { [pubkey: string]: string } = {}; const applied: { [pubkey: string]: number } = {}; - Object.entries(discounts).forEach(([pubkey, data]: [string, any]) => { - codes[pubkey] = data.code; - applied[pubkey] = data.percentage; + Object.entries(discounts).forEach(([pubkey, data]) => { + if (!data || typeof data !== "object") return; + const code = (data as { code?: unknown }).code; + const percentage = (data as { percentage?: unknown }).percentage; + if (typeof code !== "string" || typeof percentage !== "number") { + return; + } + codes[pubkey] = code; + applied[pubkey] = percentage; }); setDiscountCodes(codes); @@ -336,13 +361,10 @@ export default function Component() { }; const handleRemoveFromCart = (productId: string) => { - let cartContent: ProductData[] = []; - try { - const raw = localStorage.getItem("cart"); - if (raw) cartContent = JSON.parse(raw); - } catch { - localStorage.removeItem("cart"); - } + const cartContent = getLocalStorageJson("cart", [], { + removeOnError: true, + validate: Array.isArray, + }); if (cartContent.length > 0) { const updatedCart = cartContent.filter( (obj: ProductData) => obj.id !== productId @@ -387,13 +409,15 @@ export default function Component() { setDiscountErrors({ ...discountErrors, [pubkey]: "" }); // Save to localStorage - const storedDiscounts = localStorage.getItem("cartDiscounts"); - let discounts: { [pubkey: string]: { code: string; percentage: number } } = {}; - try { - if (storedDiscounts) discounts = JSON.parse(storedDiscounts); - } catch { - localStorage.removeItem("cartDiscounts"); - } + const discounts = getLocalStorageJson( + "cartDiscounts", + {}, + { + removeOnError: true, + removeOnValidationError: true, + validate: isCartDiscountsMap, + } + ); discounts[pubkey] = { code: code, percentage: result.discount_percentage, @@ -422,15 +446,12 @@ export default function Component() { setDiscountErrors({ ...discountErrors, [pubkey]: "" }); // Remove from localStorage - const storedDiscounts = localStorage.getItem("cartDiscounts"); - if (storedDiscounts) { - let discounts; - try { - discounts = JSON.parse(storedDiscounts); - } catch { - localStorage.removeItem("cartDiscounts"); - return; - } + const discounts = getLocalStorageJson("cartDiscounts", {}, { + removeOnError: true, + removeOnValidationError: true, + validate: isCartDiscountsMap, + }); + if (Object.keys(discounts).length > 0) { delete discounts[pubkey]; localStorage.setItem("cartDiscounts", JSON.stringify(discounts)); } diff --git a/utils/__tests__/safe-json.test.ts b/utils/__tests__/safe-json.test.ts new file mode 100644 index 00000000..98c8a136 --- /dev/null +++ b/utils/__tests__/safe-json.test.ts @@ -0,0 +1,111 @@ +import { getLocalStorageJson, parseJsonWithFallback } from "../safe-json"; + +describe("safe-json helpers", () => { + beforeEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + describe("parseJsonWithFallback", () => { + it("returns parsed value for valid JSON", () => { + const parsed = parseJsonWithFallback<{ ok: boolean }>( + '{"ok":true}', + { ok: false } + ); + + expect(parsed).toEqual({ ok: true }); + }); + + it("returns fallback for malformed JSON", () => { + const parsed = parseJsonWithFallback("{bad", []); + + expect(parsed).toEqual([]); + }); + + it("returns fallback when validator fails", () => { + const parsed = parseJsonWithFallback( + "[1,2,3]", + [], + { + validate: (value): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"), + } + ); + + expect(parsed).toEqual([]); + }); + }); + + describe("getLocalStorageJson", () => { + it("returns fallback for missing keys", () => { + const parsed = getLocalStorageJson("missing-key", [] as string[]); + + expect(parsed).toEqual([]); + }); + + it("removes malformed key when removeOnError is enabled", () => { + localStorage.setItem("cart", "{bad-json"); + const removeItemSpy = jest.spyOn(Storage.prototype, "removeItem"); + + const parsed = getLocalStorageJson("cart", [] as unknown[], { + removeOnError: true, + }); + + expect(parsed).toEqual([]); + expect(removeItemSpy).toHaveBeenCalledWith("cart"); + }); + + it("does not remove key on validation mismatch by default", () => { + localStorage.setItem("relays", "[1,2,3]"); + const removeItemSpy = jest.spyOn(Storage.prototype, "removeItem"); + + const parsed = getLocalStorageJson("relays", [], { + removeOnError: true, + validate: (value): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"), + }); + + expect(parsed).toEqual([]); + expect(removeItemSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem("relays")).toBe("[1,2,3]"); + }); + + it("removes invalid key when removeOnValidationError is enabled", () => { + localStorage.setItem("relays", "[1,2,3]"); + const removeItemSpy = jest.spyOn(Storage.prototype, "removeItem"); + + const parsed = getLocalStorageJson("relays", [], { + removeOnValidationError: true, + validate: (value): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"), + }); + + expect(parsed).toEqual([]); + expect(removeItemSpy).toHaveBeenCalledWith("relays"); + }); + + it("emits diagnostic context for parse and validation errors", () => { + const onError = jest.fn(); + + localStorage.setItem("cart", "{bad-json"); + getLocalStorageJson("cart", [] as string[], { + removeOnError: true, + onError, + }); + + localStorage.setItem("relays", "[1,2,3]"); + getLocalStorageJson("relays", [], { + validate: (value): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"), + onError, + }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ reason: "parse_error", key: "cart" }) + ); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ reason: "validation_mismatch", key: "relays" }) + ); + }); + }); +}); diff --git a/utils/nostr/__tests__/local-storage-data.test.ts b/utils/nostr/__tests__/local-storage-data.test.ts new file mode 100644 index 00000000..e62a6d1e --- /dev/null +++ b/utils/nostr/__tests__/local-storage-data.test.ts @@ -0,0 +1,73 @@ +import { + getDefaultBlossomServer, + getDefaultMint, + getDefaultRelays, + getLocalStorageData, +} from "../nostr-helper-functions"; + +describe("getLocalStorageData", () => { + beforeEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it("returns safe defaults for missing keys", () => { + const data = getLocalStorageData(); + + expect(data.relays).toEqual(getDefaultRelays()); + expect(data.mints).toEqual([getDefaultMint()]); + expect(data.blossomServers).toEqual([getDefaultBlossomServer()]); + expect(data.tokens).toEqual([]); + expect(data.history).toEqual([]); + }); + + it("recovers from malformed JSON in critical keys", () => { + localStorage.setItem("relays", "{bad"); + localStorage.setItem("readRelays", "{bad"); + localStorage.setItem("writeRelays", "{bad"); + localStorage.setItem("mints", "{bad"); + localStorage.setItem("blossomServers", "{bad"); + localStorage.setItem("tokens", "{bad"); + localStorage.setItem("history", "{bad"); + localStorage.setItem("bunkerRelays", "{bad"); + localStorage.setItem("signer", "{bad"); + + expect(() => getLocalStorageData()).not.toThrow(); + + const data = getLocalStorageData(); + expect(data.relays).toEqual(getDefaultRelays()); + expect(data.readRelays).toEqual([]); + expect(data.writeRelays).toEqual([]); + expect(data.mints).toEqual([getDefaultMint()]); + expect(data.blossomServers).toEqual([getDefaultBlossomServer()]); + expect(data.tokens).toEqual([]); + expect(data.history).toEqual([]); + expect(data.bunkerRelays).toEqual([]); + }); + + it("falls back to signInMethod signer when stored signer shape is invalid", () => { + localStorage.setItem("signInMethod", "extension"); + localStorage.setItem("signer", JSON.stringify({ type: "nip46" })); + + const data = getLocalStorageData(); + + expect(data.signer).toEqual({ type: "nip07" }); + }); + + it("keeps valid stored signer shape", () => { + localStorage.setItem( + "signer", + JSON.stringify({ + type: "nsec", + encryptedPrivKey: "ncryptsec1mock", + }) + ); + + const data = getLocalStorageData(); + + expect(data.signer).toEqual({ + type: "nsec", + encryptedPrivKey: "ncryptsec1mock", + }); + }); +}); diff --git a/utils/nostr/nostr-helper-functions.ts b/utils/nostr/nostr-helper-functions.ts index 605168e6..9dcb4cd9 100644 --- a/utils/nostr/nostr-helper-functions.ts +++ b/utils/nostr/nostr-helper-functions.ts @@ -24,6 +24,7 @@ import { deleteEventsFromDatabase, } from "@/utils/db/db-client"; import { newPromiseWithTimeout } from "@/utils/timeout"; +import { getLocalStorageJson } from "@/utils/safe-json"; function containsRelay(relays: string[], relay: string): boolean { return relays.some((r) => r.includes(relay)); @@ -1306,21 +1307,65 @@ export interface LocalStorageInterface { writeRelays: string[]; mints: string[]; blossomServers: string[]; - tokens: []; - history: []; + tokens: any[]; + history: any[]; wot: number; encryptedPrivateKey?: string; clientPrivkey?: string; bunkerRemotePubkey?: string; bunkerRelays?: string[]; bunkerSecret?: string; - signer?: { [key: string]: string }; + signer?: + | { type: "nip07" } + | { type: "nip46"; bunker: string; appPrivKey?: string } + | { type: "nsec"; encryptedPrivKey: string; pubkey?: string }; nwcString?: string | null; nwcInfo?: string | null; migrationComplete?: boolean; } +function isStoredSignerData( + value: unknown +): value is NonNullable { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const candidate = value as { + type?: unknown; + bunker?: unknown; + appPrivKey?: unknown; + encryptedPrivKey?: unknown; + pubkey?: unknown; + }; + + if (candidate.type === "nip07") { + return true; + } + + if (candidate.type === "nip46") { + return ( + typeof candidate.bunker === "string" && + (candidate.appPrivKey === undefined || + typeof candidate.appPrivKey === "string") + ); + } + + if (candidate.type === "nsec") { + return ( + typeof candidate.encryptedPrivKey === "string" && + (candidate.pubkey === undefined || typeof candidate.pubkey === "string") + ); + } + + return false; +} + export const getLocalStorageData = (): LocalStorageInterface => { + const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((entry) => typeof entry === "string"); + const isArray = (value: unknown): value is unknown[] => Array.isArray(value); + let signInMethod; let encryptedPrivateKey; let relays; @@ -1335,7 +1380,7 @@ export const getLocalStorageData = (): LocalStorageInterface => { let bunkerRemotePubkey; let bunkerRelays; let bunkerSecret; - let signer; + let signer: LocalStorageInterface["signer"] | undefined; let migrationComplete; let nwcString; let nwcInfo; @@ -1355,8 +1400,10 @@ export const getLocalStorageData = (): LocalStorageInterface => { localStorage.removeItem("cashuWalletRelays"); } - const relaysString = localStorage.getItem(LOCALSTORAGECONSTANTS.relays); - relays = relaysString ? (JSON.parse(relaysString) as string[]) : []; + relays = getLocalStorageJson(LOCALSTORAGECONSTANTS.relays, [], { + removeOnError: true, + validate: isStringArray, + }); const defaultRelays = getDefaultRelays(); @@ -1374,36 +1421,44 @@ export const getLocalStorageData = (): LocalStorageInterface => { } } - readRelays = localStorage.getItem(LOCALSTORAGECONSTANTS.readRelays) - ? ( - JSON.parse( - localStorage.getItem(LOCALSTORAGECONSTANTS.readRelays) as string - ) as string[] - ).filter((r) => r) - : []; - - writeRelays = localStorage.getItem(LOCALSTORAGECONSTANTS.writeRelays) - ? ( - JSON.parse( - localStorage.getItem(LOCALSTORAGECONSTANTS.writeRelays) as string - ) as string[] - ).filter((r) => r) - : []; - - mints = localStorage.getItem(LOCALSTORAGECONSTANTS.mints) - ? JSON.parse(localStorage.getItem("mints") as string) - : null; + readRelays = getLocalStorageJson( + LOCALSTORAGECONSTANTS.readRelays, + [], + { + removeOnError: true, + validate: isStringArray, + } + ).filter((r) => r); + + writeRelays = getLocalStorageJson( + LOCALSTORAGECONSTANTS.writeRelays, + [], + { + removeOnError: true, + validate: isStringArray, + } + ).filter((r) => r); + + mints = getLocalStorageJson(LOCALSTORAGECONSTANTS.mints, [], { + removeOnError: true, + validate: isStringArray, + }); - if (mints === null) { + if (mints.length === 0) { mints = [getDefaultMint()]; localStorage.setItem(LOCALSTORAGECONSTANTS.mints, JSON.stringify(mints)); } - blossomServers = localStorage.getItem(LOCALSTORAGECONSTANTS.blossomServers) - ? JSON.parse(localStorage.getItem("blossomServers") as string) - : null; + blossomServers = getLocalStorageJson( + LOCALSTORAGECONSTANTS.blossomServers, + [], + { + removeOnError: true, + validate: isStringArray, + } + ); - if (blossomServers === null) { + if (blossomServers.length === 0) { blossomServers = [getDefaultBlossomServer()]; localStorage.setItem( LOCALSTORAGECONSTANTS.blossomServers, @@ -1411,13 +1466,24 @@ export const getLocalStorageData = (): LocalStorageInterface => { ); } - tokens = localStorage.getItem(LOCALSTORAGECONSTANTS.tokens) - ? JSON.parse(localStorage.getItem("tokens") as string) - : localStorage.setItem(LOCALSTORAGECONSTANTS.tokens, JSON.stringify([])); + tokens = getLocalStorageJson(LOCALSTORAGECONSTANTS.tokens, [], { + removeOnError: true, + validate: isArray, + }); + if (tokens.length === 0 && !localStorage.getItem(LOCALSTORAGECONSTANTS.tokens)) { + localStorage.setItem(LOCALSTORAGECONSTANTS.tokens, JSON.stringify([])); + } - history = localStorage.getItem(LOCALSTORAGECONSTANTS.history) - ? JSON.parse(localStorage.getItem("history") as string) - : localStorage.setItem(LOCALSTORAGECONSTANTS.history, JSON.stringify([])); + history = getLocalStorageJson(LOCALSTORAGECONSTANTS.history, [], { + removeOnError: true, + validate: isArray, + }); + if ( + history.length === 0 && + !localStorage.getItem(LOCALSTORAGECONSTANTS.history) + ) { + localStorage.setItem(LOCALSTORAGECONSTANTS.history, JSON.stringify([])); + } wot = localStorage.getItem(LOCALSTORAGECONSTANTS.wot) ? Number(localStorage.getItem(LOCALSTORAGECONSTANTS.wot)) @@ -1431,23 +1497,27 @@ export const getLocalStorageData = (): LocalStorageInterface => { ) ? localStorage.getItem(LOCALSTORAGECONSTANTS.bunkerRemotePubkey) : undefined; - bunkerRelays = localStorage.getItem(LOCALSTORAGECONSTANTS.bunkerRelays) - ? ( - JSON.parse( - localStorage.getItem(LOCALSTORAGECONSTANTS.bunkerRelays) as string - ) as string[] - ).filter((r) => r) - : []; + bunkerRelays = getLocalStorageJson( + LOCALSTORAGECONSTANTS.bunkerRelays, + [], + { + removeOnError: true, + validate: isStringArray, + } + ).filter((r) => r); bunkerSecret = localStorage.getItem(LOCALSTORAGECONSTANTS.bunkerSecret) ? localStorage.getItem(LOCALSTORAGECONSTANTS.bunkerSecret) : undefined; - const signerData: string | null = localStorage.getItem( - LOCALSTORAGECONSTANTS.signer + signer = getLocalStorageJson( + LOCALSTORAGECONSTANTS.signer, + undefined, + { + removeOnError: true, + validate: isStoredSignerData, + } ); - if (signerData) { - signer = JSON.parse(signerData); - } else { + if (!signer) { switch (signInMethod) { case "extension": signer = { @@ -1463,14 +1533,17 @@ export const getLocalStorageData = (): LocalStorageInterface => { signer = { type: "nip46", bunker: bunker, - appPrivKey: clientPrivkey, + appPrivKey: + typeof clientPrivkey === "string" ? clientPrivkey : undefined, }; break; case "nsec": - signer = { - type: "nsec", - encryptedPrivKey: encryptedPrivateKey, - }; + if (typeof encryptedPrivateKey === "string") { + signer = { + type: "nsec", + encryptedPrivKey: encryptedPrivateKey, + }; + } break; } } @@ -1490,7 +1563,7 @@ export const getLocalStorageData = (): LocalStorageInterface => { relays: relays || [], readRelays: readRelays || [], writeRelays: writeRelays || [], - mints, + mints: mints || [], blossomServers: blossomServers || [], tokens: tokens || [], history: history || [], diff --git a/utils/safe-json.ts b/utils/safe-json.ts new file mode 100644 index 00000000..ba5e9923 --- /dev/null +++ b/utils/safe-json.ts @@ -0,0 +1,88 @@ +type JsonValidator = (value: unknown) => value is T; + +type JsonErrorReason = + | "ssr" + | "parse_error" + | "validation_mismatch" + | "fallback_validation_mismatch"; + +interface JsonErrorContext { + reason: JsonErrorReason; + key?: string; + error?: unknown; +} + +interface StorageParseOptions { + removeOnError?: boolean; + removeOnValidationError?: boolean; + onError?: (context: JsonErrorContext) => void; + validate?: JsonValidator; +} + +export function parseJsonWithFallback( + raw: string | null, + fallback: T, + options?: StorageParseOptions +): T { + const reportError = (context: JsonErrorContext) => { + options?.onError?.(context); + }; + + if (options?.validate && !options.validate(fallback)) { + reportError({ reason: "fallback_validation_mismatch" }); + } + + if (!raw) return fallback; + + try { + const parsed: unknown = JSON.parse(raw); + if (options?.validate && !options.validate(parsed)) { + reportError({ reason: "validation_mismatch" }); + return fallback; + } + return parsed as T; + } catch (error) { + reportError({ reason: "parse_error", error }); + return fallback; + } +} + +export function getLocalStorageJson( + key: string, + fallback: T, + options?: StorageParseOptions +): T { + const reportError = (context: JsonErrorContext) => { + options?.onError?.({ ...context, key }); + }; + + if (options?.validate && !options.validate(fallback)) { + reportError({ reason: "fallback_validation_mismatch" }); + } + + if (typeof window === "undefined") { + reportError({ reason: "ssr" }); + return fallback; + } + + const raw = localStorage.getItem(key); + if (raw === null) return fallback; + + try { + const parsed: unknown = JSON.parse(raw); + if (options?.validate && !options.validate(parsed)) { + if (options.removeOnValidationError) { + localStorage.removeItem(key); + } + reportError({ reason: "validation_mismatch" }); + return fallback; + } + return parsed as T; + } catch (error) { + if (options?.removeOnError) { + localStorage.removeItem(key); + } + reportError({ reason: "parse_error", error }); + return fallback; + } +}