From 7d29140257f0c5c3caa4d30cdf67132469072461 Mon Sep 17 00:00:00 2001 From: Konstantin Lukas Date: Sat, 7 Sep 2024 19:42:10 +0200 Subject: [PATCH] add tests for useLocalStorage --- README.md | 4 +- package.json | 2 +- src/hooks/useLocalStorage.ts | 9 ++- tests/useLocalStorage.test.ts | 112 ++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/useLocalStorage.test.ts diff --git a/README.md b/README.md index fbf82f7..243928d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Anzol is built alongside automated tests to ensure quality. - useIntersectionObserverArray: Like useIntersectionObserver but for multiple elements. - useEvent: Provides a wrapper around the EventListener API. Use the return value to define the event target. - useLazyLoad: Provides a simple API for fetching data from a resource in batches. +- useLocalStorage: Provides access to local storage, with the additional option to update all usages of this hook + when local storage changes. ## Installation Anzol is available on the NPM registry. To install it, just run: @@ -47,8 +49,6 @@ These features are not yet implement but we plan to do so in the foreseeable fut suggestions. - useClickOutside: Takes a reference to an HTML element and a callback function, and calls that function when the user clicks anywhere outside the given element. -- useLocalStorage: Provides access to local storage, with the additional option to update all usages of this hook -when local storage changes. - usePreferredScheme: Listens for changes in the user's preferred scheme and returns it. - useDarkMode: Similar to usePreferredScheme but allows setting the user scheme manually and automatically updates it when the preferred scheme changes. Uses local storage to save the chosen scheme across reloads. \ No newline at end of file diff --git a/package.json b/package.json index 69c6a21..4d03d1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anzol", - "version": "2.3.0", + "version": "2.4.0", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", "types": "dist/index.d.ts", diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 52ae875..37731aa 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -1,5 +1,6 @@ import type { Dispatch, SetStateAction } from "react"; import { useEffect, useState, useMemo } from "react"; +import { useFirstRender } from "../index"; export interface LocalStorageOptions { /** If there is no value for the specified {@link key}, it automatically set this value on mount. */ @@ -37,7 +38,13 @@ function useLocalStorage(key: string, { listenForChanges = false, }: LocalStorageOptions = {}): [string | null, Dispatch>] { - const [value, setValue] = useState(localStorage.getItem(key) || initialValue || null); + const [value, setValue] = useState(() => { + const storedValue = localStorage.getItem(key); + if (storedValue) return storedValue; + const init = initialValue || null; + if (init) localStorage.setItem(key, init); + return init; + }); const [blockUpdates, setBlockUpdates] = useState(true); const id = useMemo(() => instanceCount++, []); diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts new file mode 100644 index 0000000..07dcf3c --- /dev/null +++ b/tests/useLocalStorage.test.ts @@ -0,0 +1,112 @@ +import { act, renderHook } from "@testing-library/react"; +import { useLocalStorage, type LocalStorageOptions } from "../src"; + +async function renderUseLocalStorage({ + initialKey, + initialOptions, +}: { + initialKey: string, + initialOptions?: LocalStorageOptions +}) { + const { + result, + rerender, + } = await act(async () => { + return renderHook(({ key, options }) => useLocalStorage(key, options), { + initialProps: { + key: initialKey, + options: initialOptions ?? {}, + }, + }); + }); + return { rerender, result }; +} + +test("should set a default value when local storage item doesn't exist", async () => { + const { result } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { initialValue: "Bear" }}); + expect(result.current[0]).toBe("Bear"); + expect(window.localStorage.getItem("animal")).toBe("Bear"); +}); + +test("should not set a default value when local storage item does exist", async () => { + const { result: result1 } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { initialValue: "Bear" }}); + expect(result1.current[0]).toBe("Bear"); + expect(window.localStorage.getItem("animal")).toBe("Bear"); + const { result: result2 } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { initialValue: "Duck" }}); + expect(result2.current[0]).toBe("Bear"); + expect(window.localStorage.getItem("animal")).toBe("Bear"); +}); + +test("should not update other instances by default", async () => { + const { result: result1 } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { initialValue: "Bear" }}); + expect(result1.current[0]).toBe("Bear"); + const { result: result2 } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { initialValue: "Duck" }}); + expect(result2.current[0]).toBe("Bear"); + + act(() => result1.current[1]("Giraffe")); + expect(result1.current[0]).toBe("Giraffe"); + expect(result2.current[0]).toBe("Bear"); +}); + +test("should update other instances only if configured to listen and emit", async () => { + const { + result: result1, + } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { propagateChanges: true }}); + const { + rerender, + result: result2, + } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { listenForChanges: true }}); + + act(() => result1.current[1]("Giraffe")); + expect(result1.current[0]).toBe("Giraffe"); + expect(result2.current[0]).toBe("Giraffe"); + + rerender({ key: "animal", options: { listenForChanges: false }}); + act(() => result1.current[1]("Chimpanzee")); + expect(result1.current[0]).toBe("Chimpanzee"); + expect(result2.current[0]).toBe("Giraffe"); +}); + +test("should allow switching between keys", async () => { + const { + result: result1, + } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { propagateChanges: true, listenForChanges: true }}); + const { + rerender, + result: result2, + } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { propagateChanges: true, listenForChanges: true }}); + + act(() => result1.current[1]("Giraffe")); + expect(result1.current[0]).toBe("Giraffe"); + expect(result2.current[0]).toBe("Giraffe"); + + rerender({ key: "fruit", options: { listenForChanges: true }}); + act(() => result2.current[1]("Apples")); + expect(result1.current[0]).toBe("Giraffe"); + expect(result2.current[0]).toBe("Apples"); +}); + +test("should not emit more events than necessarya", async () => { + Object.defineProperty(window, "localStorage", { + value: { + setItem: jest.fn(), + getItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + }, + writable: true, + }); + const setItemSpy = jest.spyOn(window.localStorage, "setItem"); + + const { + result: result1, + } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { propagateChanges: true, listenForChanges: true }}); + const { + result: result2, + } = await renderUseLocalStorage({ initialKey: "animal", initialOptions: { propagateChanges: true, listenForChanges: true }}); + + expect(setItemSpy).toHaveBeenCalledTimes(0); + act(() => result1.current[1]("Eagle")); + act(() => result2.current[1]("Unicorn")); + expect(setItemSpy).toHaveBeenCalledTimes(2); +}); \ No newline at end of file