From 51e204076ffd82f8659a51d1bbcddd41ca12ba81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20=C5=BBegle=C5=84?= Date: Fri, 17 Sep 2021 11:59:28 +0200 Subject: [PATCH] Fix initial list settings (#1401) * Allow ocalStorage values to be initialized by cb * Initialize list settings if not found * Fix settings merging * Refactor types --- package-lock.json | 128 ++++++++++------------ package.json | 3 +- src/apps/views/AppsList/AppsList.tsx | 7 +- src/auth/hooks/useExternalAuthProvider.ts | 5 +- src/auth/hooks/useSaleorAuthProvider.ts | 5 +- src/hooks/useListSettings.test.ts | 80 ++++++++++++++ src/hooks/useListSettings.ts | 20 ++-- src/hooks/useLocalStorage.test.ts | 105 ++++++++++++++++++ src/hooks/useLocalStorage.ts | 73 ++++++++---- 9 files changed, 314 insertions(+), 112 deletions(-) create mode 100644 src/hooks/useListSettings.test.ts create mode 100644 src/hooks/useLocalStorage.test.ts diff --git a/package-lock.json b/package-lock.json index 4de0a3360..2400945fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6931,33 +6931,32 @@ "integrity": "sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg==" }, "@typescript-eslint/eslint-plugin": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.21.0.tgz", - "integrity": "sha512-FPUyCPKZbVGexmbCFI3EQHzCZdy2/5f+jv6k2EDljGdXSRc0cKvbndd2nHZkSLqCNOPk0jB6lGzwIkglXcYVsQ==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.31.0.tgz", + "integrity": "sha512-iPKZTZNavAlOhfF4gymiSuUkgLne/nh5Oz2/mdiUmuZVD42m9PapnCnzjxuDsnpnbH3wT5s2D8bw6S39TC6GNw==", "dev": true, "requires": { - "@typescript-eslint/experimental-utils": "4.21.0", - "@typescript-eslint/scope-manager": "4.21.0", - "debug": "^4.1.1", + "@typescript-eslint/experimental-utils": "4.31.0", + "@typescript-eslint/scope-manager": "4.31.0", + "debug": "^4.3.1", "functional-red-black-tree": "^1.0.1", - "lodash": "^4.17.15", - "regexpp": "^3.0.0", - "semver": "^7.3.2", - "tsutils": "^3.17.1" + "regexpp": "^3.1.0", + "semver": "^7.3.5", + "tsutils": "^3.21.0" } }, "@typescript-eslint/experimental-utils": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.21.0.tgz", - "integrity": "sha512-cEbgosW/tUFvKmkg3cU7LBoZhvUs+ZPVM9alb25XvR0dal4qHL3SiUqHNrzoWSxaXA9gsifrYrS1xdDV6w/gIA==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.31.0.tgz", + "integrity": "sha512-Hld+EQiKLMppgKKkdUsLeVIeEOrwKc2G983NmznY/r5/ZtZCDvIOXnXtwqJIgYz/ymsy7n7RGvMyrzf1WaSQrw==", "dev": true, "requires": { - "@types/json-schema": "^7.0.3", - "@typescript-eslint/scope-manager": "4.21.0", - "@typescript-eslint/types": "4.21.0", - "@typescript-eslint/typescript-estree": "4.21.0", - "eslint-scope": "^5.0.0", - "eslint-utils": "^2.0.0" + "@types/json-schema": "^7.0.7", + "@typescript-eslint/scope-manager": "4.31.0", + "@typescript-eslint/types": "4.31.0", + "@typescript-eslint/typescript-estree": "4.31.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" }, "dependencies": { "eslint-scope": { @@ -6973,55 +6972,55 @@ } }, "@typescript-eslint/parser": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.21.0.tgz", - "integrity": "sha512-eyNf7QmE5O/l1smaQgN0Lj2M/1jOuNg2NrBm1dqqQN0sVngTLyw8tdCbih96ixlhbF1oINoN8fDCyEH9SjLeIA==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.31.0.tgz", + "integrity": "sha512-oWbzvPh5amMuTmKaf1wp0ySxPt2ZXHnFQBN2Szu1O//7LmOvgaKTCIDNLK2NvzpmVd5A2M/1j/rujBqO37hj3w==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "4.21.0", - "@typescript-eslint/types": "4.21.0", - "@typescript-eslint/typescript-estree": "4.21.0", - "debug": "^4.1.1" + "@typescript-eslint/scope-manager": "4.31.0", + "@typescript-eslint/types": "4.31.0", + "@typescript-eslint/typescript-estree": "4.31.0", + "debug": "^4.3.1" } }, "@typescript-eslint/scope-manager": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.21.0.tgz", - "integrity": "sha512-kfOjF0w1Ix7+a5T1knOw00f7uAP9Gx44+OEsNQi0PvvTPLYeXJlsCJ4tYnDj5PQEYfpcgOH5yBlw7K+UEI9Agw==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.31.0.tgz", + "integrity": "sha512-LJ+xtl34W76JMRLjbaQorhR0hfRAlp3Lscdiz9NeI/8i+q0hdBZ7BsiYieLoYWqy+AnRigaD3hUwPFugSzdocg==", "dev": true, "requires": { - "@typescript-eslint/types": "4.21.0", - "@typescript-eslint/visitor-keys": "4.21.0" + "@typescript-eslint/types": "4.31.0", + "@typescript-eslint/visitor-keys": "4.31.0" } }, "@typescript-eslint/types": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.21.0.tgz", - "integrity": "sha512-+OQaupjGVVc8iXbt6M1oZMwyKQNehAfLYJJ3SdvnofK2qcjfor9pEM62rVjBknhowTkh+2HF+/KdRAc/wGBN2w==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.31.0.tgz", + "integrity": "sha512-9XR5q9mk7DCXgXLS7REIVs+BaAswfdHhx91XqlJklmqWpTALGjygWVIb/UnLh4NWhfwhR5wNe1yTyCInxVhLqQ==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.21.0.tgz", - "integrity": "sha512-ZD3M7yLaVGVYLw4nkkoGKumb7Rog7QID9YOWobFDMQKNl+vPxqVIW/uDk+MDeGc+OHcoG2nJ2HphwiPNajKw3w==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.31.0.tgz", + "integrity": "sha512-QHl2014t3ptg+xpmOSSPn5hm4mY8D4s97ftzyk9BZ8RxYQ3j73XcwuijnJ9cMa6DO4aLXeo8XS3z1omT9LA/Eg==", "dev": true, "requires": { - "@typescript-eslint/types": "4.21.0", - "@typescript-eslint/visitor-keys": "4.21.0", - "debug": "^4.1.1", - "globby": "^11.0.1", + "@typescript-eslint/types": "4.31.0", + "@typescript-eslint/visitor-keys": "4.31.0", + "debug": "^4.3.1", + "globby": "^11.0.3", "is-glob": "^4.0.1", - "semver": "^7.3.2", - "tsutils": "^3.17.1" + "semver": "^7.3.5", + "tsutils": "^3.21.0" } }, "@typescript-eslint/visitor-keys": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.21.0.tgz", - "integrity": "sha512-dH22dROWGi5Z6p+Igc8bLVLmwy7vEe8r+8c+raPQU0LxgogPUrRAtRGtvBWmlr9waTu3n+QLt/qrS/hWzk1x5w==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.31.0.tgz", + "integrity": "sha512-HUcRp2a9I+P21+O21yu3ezv3GEPGjyGiXoEUQwZXjR8UxRApGeLyWH4ZIIUSalE28aG4YsV6GjtaAVB3QKOu0w==", "dev": true, "requires": { - "@typescript-eslint/types": "4.21.0", + "@typescript-eslint/types": "4.31.0", "eslint-visitor-keys": "^2.0.0" } }, @@ -14117,26 +14116,18 @@ } }, "eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", "dev": true, "requires": { - "eslint-visitor-keys": "^1.1.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - } + "eslint-visitor-keys": "^2.0.0" } }, "eslint-visitor-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", - "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", "dev": true }, "espree": { @@ -25120,9 +25111,9 @@ } }, "regexpp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", - "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true }, "regexpu-core": { @@ -29125,9 +29116,10 @@ } }, "typescript": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", - "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==" + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", + "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==", + "dev": true }, "ua-parser-js": { "version": "0.7.28", diff --git a/package.json b/package.json index 476fc39a1..5d9a0c223 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,6 @@ "react-sortable-tree": "^2.6.2", "semver-compare": "^1.0.0", "slugify": "^1.4.6", - "typescript": "^4.2.3", "url-join": "^4.0.1", "use-react-router": "^1.0.7" }, @@ -178,6 +177,7 @@ "start-server-and-test": "^1.11.0", "ts-jest": "^24.2.0", "tsconfig-paths-webpack-plugin": "^3.2.0", + "typescript": "^4.3.5", "webpack": "^4.35.3", "webpack-bundle-analyzer": "^4.4.1", "webpack-cli": "^3.3.6", @@ -194,6 +194,7 @@ "fsevents": "^1.2.9" }, "jest": { + "resetMocks": false, "setupFiles": [ "jest-localstorage-mock" ], diff --git a/src/apps/views/AppsList/AppsList.tsx b/src/apps/views/AppsList/AppsList.tsx index 0f086e824..feb062b09 100644 --- a/src/apps/views/AppsList/AppsList.tsx +++ b/src/apps/views/AppsList/AppsList.tsx @@ -53,10 +53,9 @@ interface AppsListProps { export const AppsList: React.FC = ({ params }) => { const { action } = params; - const [activeInstallations, setActiveInstallations] = useLocalStorage( - "activeInstallations", - [] - ); + const [activeInstallations, setActiveInstallations] = useLocalStorage< + Array> + >("activeInstallations", []); const notify = useNotifier(); const intl = useIntl(); const navigate = useNavigator(); diff --git a/src/auth/hooks/useExternalAuthProvider.ts b/src/auth/hooks/useExternalAuthProvider.ts index cee2518f9..d15925e79 100644 --- a/src/auth/hooks/useExternalAuthProvider.ts +++ b/src/auth/hooks/useExternalAuthProvider.ts @@ -1,10 +1,9 @@ import { DEMO_MODE } from "@saleor/config"; import { User } from "@saleor/fragments/types/User"; -import { SetLocalStorage } from "@saleor/hooks/useLocalStorage"; import { commonMessages } from "@saleor/intl"; import { getFullName, getMutationStatus } from "@saleor/misc"; import errorTracker from "@saleor/services/errorTracking"; -import { useEffect, useRef, useState } from "react"; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { useMutation } from "react-apollo"; import { @@ -57,7 +56,7 @@ export interface UseExternalAuthProvider extends UseAuthProvider { ) => Promise; } export interface UseExternalAuthProviderOpts extends UseAuthProviderOpts { - setAuthPlugin: SetLocalStorage; + setAuthPlugin: Dispatch>; authPlugin: string; } diff --git a/src/auth/hooks/useSaleorAuthProvider.ts b/src/auth/hooks/useSaleorAuthProvider.ts index 9be3ef406..0f67fc05b 100644 --- a/src/auth/hooks/useSaleorAuthProvider.ts +++ b/src/auth/hooks/useSaleorAuthProvider.ts @@ -1,6 +1,5 @@ import { DEMO_MODE } from "@saleor/config"; import { User } from "@saleor/fragments/types/User"; -import { SetLocalStorage } from "@saleor/hooks/useLocalStorage"; import { commonMessages } from "@saleor/intl"; import { getFullName, getMutationStatus } from "@saleor/misc"; import errorTracker from "@saleor/services/errorTracking"; @@ -9,7 +8,7 @@ import { login as loginWithCredentialsManagementAPI, saveCredentials } from "@saleor/utils/credentialsManagement"; -import { useEffect, useRef, useState } from "react"; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { useMutation } from "react-apollo"; import { @@ -38,7 +37,7 @@ export interface UseSaleorAuthProvider extends UseAuthProvider { loginByToken: (auth: string, csrf: string, user: User) => void; } export interface UseSaleorAuthProviderOpts extends UseAuthProviderOpts { - setAuthPlugin: SetLocalStorage; + setAuthPlugin: Dispatch>; authPlugin: string; } diff --git a/src/hooks/useListSettings.test.ts b/src/hooks/useListSettings.test.ts new file mode 100644 index 000000000..fd2a318d6 --- /dev/null +++ b/src/hooks/useListSettings.test.ts @@ -0,0 +1,80 @@ +import { defaultListSettings } from "@saleor/config"; +import { ListViews } from "@saleor/types"; +import { renderHook } from "@testing-library/react-hooks"; + +import useListSettings, { listSettingsStorageKey } from "./useListSettings"; + +const key = ListViews.CATEGORY_LIST; +const storedValue = { + ...defaultListSettings, + [key]: { + ...defaultListSettings[key], + rowNumber: 100 + } +}; +const valueWithoutKey = { + ...defaultListSettings, + [key]: undefined +}; +const valueWithoutSettings = { + ...defaultListSettings, + [key]: { + foo: "bar" + } +}; + +beforeEach(() => { + localStorage.clear(); +}); + +describe("useListSettings", () => { + it("properly inits from value", () => { + expect(localStorage.getItem(listSettingsStorageKey)).toBe(null); + + const { result } = renderHook(() => useListSettings(key)); + + expect(result.current.settings).toStrictEqual(defaultListSettings[key]); + }); + + it("omits init if value is present", () => { + localStorage.setItem(listSettingsStorageKey, JSON.stringify(storedValue)); + expect(localStorage.getItem(listSettingsStorageKey)).toBe( + JSON.stringify(storedValue) + ); + + const { result } = renderHook(() => useListSettings(key)); + + expect(result.current.settings).toStrictEqual(storedValue[key]); + }); + + it("properly merges new default values to saved ones", () => { + localStorage.setItem( + listSettingsStorageKey, + JSON.stringify(valueWithoutKey) + ); + expect(localStorage.getItem(listSettingsStorageKey)).toBe( + JSON.stringify(valueWithoutKey) + ); + + const { result } = renderHook(() => useListSettings(key)); + + expect(result.current.settings).toStrictEqual(defaultListSettings[key]); + }); + + it("properly fills missing settings", () => { + localStorage.setItem( + listSettingsStorageKey, + JSON.stringify(valueWithoutSettings) + ); + expect(localStorage.getItem(listSettingsStorageKey)).toBe( + JSON.stringify(valueWithoutSettings) + ); + + const { result } = renderHook(() => useListSettings(key)); + + expect(result.current.settings).toStrictEqual({ + ...valueWithoutSettings[key], + ...defaultListSettings[key] + }); + }); +}); diff --git a/src/hooks/useListSettings.ts b/src/hooks/useListSettings.ts index fb27a309d..4d9705001 100644 --- a/src/hooks/useListSettings.ts +++ b/src/hooks/useListSettings.ts @@ -1,9 +1,10 @@ import useLocalStorage from "@saleor/hooks/useLocalStorage"; -import { useEffect } from "react"; +import merge from "lodash/merge"; import { AppListViewSettings, defaultListSettings } from "./../config"; import { ListSettings, ListViews } from "./../types"; +export const listSettingsStorageKey = "listConfig"; export interface UseListSettings { settings: ListSettings; updateListSettings: >( @@ -15,18 +16,15 @@ export default function useListSettings( listName: ListViews ): UseListSettings { const [settings, setListSettings] = useLocalStorage( - "listConfig", - defaultListSettings - ); + listSettingsStorageKey, + storedListSettings => { + if (typeof storedListSettings !== "object") { + return defaultListSettings; + } - useEffect(() => { - if (settings[listName] === undefined) { - setListSettings(settings => ({ - ...settings, - [listName]: defaultListSettings[listName] - })); + return merge({}, defaultListSettings, storedListSettings); } - }, []); + ); const updateListSettings = ( key: T, diff --git a/src/hooks/useLocalStorage.test.ts b/src/hooks/useLocalStorage.test.ts new file mode 100644 index 000000000..64d704d2c --- /dev/null +++ b/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,105 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import useLocalStorage from "./useLocalStorage"; + +const key = "exampleKey"; +const initialValue = "exampleValue"; +const savedValue = "savedValue"; +const numberValue = 12; +const objectValue = { foo: numberValue }; +const booleanValue = true; +const postfix = "-test"; + +beforeEach(() => { + localStorage.clear(); +}); + +describe("useLocalStorage", () => { + it("properly inits from value", () => { + expect(localStorage.getItem(key)).toBe(null); + + const { result } = renderHook(() => useLocalStorage(key, initialValue)); + + expect(result.current[0]).toBe(initialValue); + }); + + it("omits initializing if value is found", () => { + localStorage.setItem(key, savedValue); + expect(localStorage.getItem(key)).toBe(savedValue); + + const { result } = renderHook(() => useLocalStorage(key, initialValue)); + + expect(result.current[0]).toBe(savedValue); + }); + + it("properly casts value to number", () => { + localStorage.setItem(key, JSON.stringify(numberValue)); + + const { result } = renderHook(() => useLocalStorage(key, initialValue)); + + expect(result.current[0]).toBe(numberValue); + }); + + it("properly casts value to boolean", () => { + localStorage.setItem(key, JSON.stringify(booleanValue)); + + const { result } = renderHook(() => useLocalStorage(key, initialValue)); + + expect(result.current[0]).toBe(booleanValue); + }); + + it("properly casts value to object", () => { + localStorage.setItem(key, JSON.stringify(objectValue)); + + const { result } = renderHook(() => useLocalStorage(key, initialValue)); + + expect(result.current[0]).toStrictEqual(objectValue); + }); + + it("properly inits from callback if value is not found", () => { + const { result } = renderHook(() => + useLocalStorage(key, storedValue => + storedValue ? storedValue + postfix : initialValue + ) + ); + + expect(result.current[0]).toBe(initialValue); + }); + + it("properly inits from callback if value is found", () => { + localStorage.setItem(key, savedValue); + + const { result } = renderHook(() => + useLocalStorage(key, storedValue => + storedValue ? storedValue + postfix : initialValue + ) + ); + + expect(result.current[0]).toBe(savedValue + postfix); + expect(localStorage.getItem(key)).toBe(savedValue + postfix); + }); + + it("properly inits from callback if value is object", () => { + localStorage.setItem(key, JSON.stringify(objectValue)); + + const { result } = renderHook(() => + useLocalStorage(key, storedValue => { + if (typeof storedValue === "object") { + return { + ...storedValue, + bar: "baz" + }; + } + + return objectValue; + }) + ); + + const newValue = { + foo: numberValue, + bar: "baz" + }; + expect(result.current[0]).toStrictEqual(newValue); + expect(localStorage.getItem(key)).toBe(JSON.stringify(newValue)); + }); +}); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 3e17bef8d..aa3caa812 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -1,33 +1,62 @@ -import { useState } from "react"; +import { Dispatch, SetStateAction, useState } from "react"; -export type SetLocalStorageValue = T | ((prevValue: T) => T); -export type SetLocalStorage = (value: SetLocalStorageValue) => void; +export type UseLocalStorage = [T, Dispatch>]; export default function useLocalStorage( key: string, - initialValue: T -): [T, SetLocalStorage] { - const [storedValue, setStoredValue] = useState(() => { - let result: T; + initialValue: SetStateAction +): UseLocalStorage { + const saveToLocalStorage = (valueToStore: T) => { try { - const item = window.localStorage.getItem(key); - result = item ? JSON.parse(item) : initialValue; - } catch { - result = initialValue; - } - - return result; - }); - - const setValue = (value: SetLocalStorageValue) => { - const valueToStore = value instanceof Function ? value(storedValue) : value; - setStoredValue(valueToStore); - - try { - window.localStorage.setItem(key, JSON.stringify(valueToStore)); + if (typeof valueToStore === "string") { + localStorage.setItem(key, valueToStore); + } else { + localStorage.setItem(key, JSON.stringify(valueToStore)); + } } catch { console.warn(`Could not save ${key} to localStorage`); } }; + function getValue(value: T, initOrCb: SetStateAction): T { + if (initOrCb instanceof Function) { + const newValue = initOrCb(value); + saveToLocalStorage(newValue); + return newValue; + } + + return value ?? initOrCb; + } + + const [storedValue, setStoredValue] = useState(() => { + let result: T | null; + const item = localStorage.getItem(key); + + if (item === null) { + return getValue(null, initialValue); + } + + try { + const parsed = JSON.parse(item); + if (!parsed) { + throw new Error("Empty value"); + } + + result = parsed; + } catch { + // Casting to T (which should resolve to string) because JSON.parse would + // throw an error if "foo" was passed, but properly casting "true" or "1" + // to their respective types + result = (item as unknown) as T; + } + + return getValue(result, initialValue); + }); + + const setValue = (value: SetStateAction) => { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + saveToLocalStorage(valueToStore); + }; + return [storedValue, setValue]; }