import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useLocalStorage } from "./useLocalStorage.js"; // Helper to create a fresh in-memory localStorage stub function createLocalStorageStub() { let store: Record = {}; return { getItem: vi.fn((key: string) => store[key] ?? null), setItem: vi.fn((key: string, value: string) => { store[key] = value; }), removeItem: vi.fn((key: string) => { delete store[key]; }), clear: vi.fn(() => { store = {}; }), }; } describe("useLocalStorage", () => { let localStorageStub: ReturnType; beforeEach(() => { localStorageStub = createLocalStorageStub(); vi.spyOn(window, "localStorage", "get").mockReturnValue(localStorageStub as unknown as Storage); }); afterEach(() => { vi.restoreAllMocks(); }); describe("initial value", () => { it("returns the defaultValue when localStorage is empty", () => { const { result } = renderHook(() => useLocalStorage("test-key", "default")); expect(result.current[0]).toBe("default"); }); it("returns the stored value when localStorage already has data", () => { localStorageStub.getItem.mockReturnValue(JSON.stringify("stored")); const { result } = renderHook(() => useLocalStorage("test-key", "default")); expect(result.current[0]).toBe("stored"); }); it("merges stored object with defaultValue for schema evolution", () => { const stored = { existing: "value" }; localStorageStub.getItem.mockReturnValue(JSON.stringify(stored)); const defaultValue = { existing: "old", newField: "fallback" }; const { result } = renderHook(() => useLocalStorage("obj-key", defaultValue)); expect(result.current[0]).toEqual({ existing: "value", newField: "fallback" }); }); it("does not merge when the stored value is not a plain object", () => { localStorageStub.getItem.mockReturnValue(JSON.stringify([1, 2, 3])); const { result } = renderHook(() => useLocalStorage("arr-key", [0])); expect(result.current[0]).toEqual([1, 2, 3]); }); it("returns defaultValue when stored JSON is malformed", () => { localStorageStub.getItem.mockReturnValue("not-valid-json{{{"); const { result } = renderHook(() => useLocalStorage("bad-key", 99)); expect(result.current[0]).toBe(99); }); }); describe("set function — direct value", () => { it("updates the in-memory state", () => { const { result } = renderHook(() => useLocalStorage("key", "initial")); act(() => { result.current[1]("updated"); }); expect(result.current[0]).toBe("updated"); }); it("persists the new value to localStorage", () => { const { result } = renderHook(() => useLocalStorage("key", "initial")); act(() => { result.current[1]("saved"); }); expect(localStorageStub.setItem).toHaveBeenCalledWith("key", JSON.stringify("saved")); }); it("handles numeric values", () => { const { result } = renderHook(() => useLocalStorage("num", 0)); act(() => { result.current[1](42); }); expect(result.current[0]).toBe(42); expect(localStorageStub.setItem).toHaveBeenCalledWith("num", "42"); }); it("handles boolean values", () => { const { result } = renderHook(() => useLocalStorage("bool", false)); act(() => { result.current[1](true); }); expect(result.current[0]).toBe(true); }); it("handles object values", () => { const { result } = renderHook(() => useLocalStorage>("obj", { a: 1 })); act(() => { result.current[1]({ a: 2, b: 3 }); }); expect(result.current[0]).toEqual({ a: 2, b: 3 }); }); }); describe("set function — updater callback", () => { it("passes the current value to the updater", () => { const { result } = renderHook(() => useLocalStorage("count", 5)); act(() => { result.current[1]((prev) => prev + 1); }); expect(result.current[0]).toBe(6); }); it("supports multiple sequential updater calls", () => { const { result } = renderHook(() => useLocalStorage("count", 0)); act(() => { result.current[1]((prev) => prev + 1); result.current[1]((prev) => prev + 1); }); expect(result.current[0]).toBe(2); }); it("can spread objects in updater", () => { const { result } = renderHook(() => useLocalStorage>("map", {})); act(() => { result.current[1]((prev) => ({ ...prev, x: 10 })); }); act(() => { result.current[1]((prev) => ({ ...prev, y: 20 })); }); expect(result.current[0]).toEqual({ x: 10, y: 20 }); }); }); describe("changeEventName cross-instance sync", () => { it("dispatches a CustomEvent on write when changeEventName is provided", () => { const dispatchSpy = vi.spyOn(window, "dispatchEvent"); const { result } = renderHook(() => useLocalStorage("key", "a", "myEvent")); act(() => { result.current[1]("b"); }); const dispatched = dispatchSpy.mock.calls.find( ([e]) => e instanceof CustomEvent && e.type === "myEvent", ); expect(dispatched).toBeDefined(); }); it("does not dispatch a CustomEvent when changeEventName is omitted", () => { const dispatchSpy = vi.spyOn(window, "dispatchEvent"); const { result } = renderHook(() => useLocalStorage("key", "a")); act(() => { result.current[1]("b"); }); const customEvents = dispatchSpy.mock.calls.filter(([e]) => e instanceof CustomEvent); expect(customEvents).toHaveLength(0); }); it("updates state when the matching CustomEvent fires on window", () => { const { result } = renderHook(() => useLocalStorage("key", "initial", "syncEvent")); act(() => { window.dispatchEvent(new CustomEvent("syncEvent", { detail: "from-other-instance" })); }); expect(result.current[0]).toBe("from-other-instance"); }); it("does not respond to events after unmount", () => { const { result, unmount } = renderHook(() => useLocalStorage("key", "initial", "syncEvent")); unmount(); // Should not throw or update after unmount act(() => { window.dispatchEvent(new CustomEvent("syncEvent", { detail: "post-unmount" })); }); // Value should remain whatever it was at unmount time expect(result.current[0]).toBe("initial"); }); }); describe("key change", () => { it("reads from the new key when the key prop changes", () => { localStorageStub.getItem.mockImplementation((k: string) => { if (k === "key-a") return JSON.stringify("value-a"); if (k === "key-b") return JSON.stringify("value-b"); return null; }); const { result, rerender } = renderHook( ({ key }: { key: string }) => useLocalStorage(key, "default"), { initialProps: { key: "key-a" } }, ); expect(result.current[0]).toBe("value-a"); rerender({ key: "key-b" }); expect(result.current[0]).toBe("value-b"); }); }); });