diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 9397f60..d1decb8 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -49,5 +49,5 @@ jobs: run: pnpm tsc - name: Run tests run: | - pnpx playwright install chromium + pnpm exec playwright install --with-deps pnpm test diff --git a/README.md b/README.md index 0204894..a2dc22c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,17 @@ npm install @charlietango/hooks --save All the hooks are exported on their own, so we don't have a barrel file with all the hooks. This guarantees that you only import the hooks you need, and don't bloat your bundle with unused code. +### `useCookie` + +A hook to interact with the `document.cookie`. It works just like the `useState` hook, but it will persist the value in the cookie. +The hook only sets and gets the `string` value - If you need to store an object, you need to serialize it yourself. + +```ts +import { useCookie } from "@charlietango/hooks/use-cookie"; + +const [value, setValue] = useCookie("mode"); +``` + ### `useDebouncedValue` Debounce a value. The value will only be updated after the delay has passed without the value changing. @@ -149,6 +160,18 @@ if (status === "ready") { } ``` +### `useStorage` + +A hook to interact with the `localStorage` or `sessionStorage`. It works just like the `useState` hook, but it will persist the value in the storage. +The hook only sets and gets the `string` value - If you need to store an object, you need to serialize it yourself. + +```ts +import { useStorage } from "@charlietango/hooks/use-storage"; + +const [value, setValue] = useStorage("mode", { mode: "local" }); +setValue("dark"); +``` + ### `useWindowSize` Get the current window size. If the window resizes, the hook will update the size. diff --git a/src/__tests__/useCookie.test.ts b/src/__tests__/useCookie.test.ts new file mode 100644 index 0000000..f34f0eb --- /dev/null +++ b/src/__tests__/useCookie.test.ts @@ -0,0 +1,53 @@ +import { act, renderHook } from "@testing-library/react"; +import { useCookie } from "../hooks/useCookie"; + +function setValue( + value: string | ((prevValue?: string) => string | undefined) | undefined, + hook: { current: ReturnType }, +) { + act(() => { + hook.current[1](value); + }); +} + +function getValue(hook: { current: ReturnType }) { + return hook.current[0]; +} + +test("should manage cookies", () => { + const { result: hook } = renderHook(() => useCookie("test")); + + setValue("custom value", hook); + + expect(getValue(hook)).toBe("custom value"); + + setValue((prevValue) => `${prevValue}2`, hook); + expect(getValue(hook)).toBe("custom value2"); + + setValue(undefined, hook); + expect(getValue(hook)).toBeUndefined(); +}); + +test("should manage cookies with default value", () => { + const { result: hook } = renderHook(() => + useCookie("test", { defaultValue: "default value" }), + ); + + expect(getValue(hook)).toBe("default value"); + + setValue("custom value", hook); + expect(getValue(hook)).toBe("custom value"); + + setValue(undefined, hook); + expect(getValue(hook)).toBe("default value"); +}); + +test("should sync values across hooks", () => { + const { result: hook } = renderHook(() => useCookie("test")); + const { result: hook2 } = renderHook(() => useCookie("test")); + + setValue("new value", hook); + + expect(getValue(hook)).toBe("new value"); + expect(getValue(hook2)).toBe("new value"); +}); diff --git a/src/__tests__/useStorage.test.ts b/src/__tests__/useStorage.test.ts new file mode 100644 index 0000000..71819f5 --- /dev/null +++ b/src/__tests__/useStorage.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook } from "@testing-library/react"; +import { useStorage } from "../hooks/useStorage"; + +function setValue( + value: + | string + | ((prevValue?: string | null) => string | undefined | null) + | undefined, + hook: { current: ReturnType }, +) { + act(() => { + hook.current[1](value); + }); +} + +function getValue(hook: { current: ReturnType }) { + return hook.current[0]; +} + +test("should set storage", () => { + const { result: hook } = renderHook(() => useStorage("test")); + + setValue("storage value", hook); + expect(getValue(hook)).toBe("storage value"); + + setValue((prevValue) => `${prevValue}2`, hook); + expect(getValue(hook)).toBe("storage value2"); + + setValue(undefined, hook); + expect(getValue(hook)).toBeNull(); +}); + +test("should support a defaultValue", () => { + const { result: hook } = renderHook(() => + useStorage("test", { defaultValue: "default value" }), + ); + + expect(getValue(hook)).toBe("default value"); + + setValue("storage value", hook); + expect(getValue(hook)).toBe("storage value"); + + setValue(undefined, hook); + expect(getValue(hook)).toBe("default value"); +}); + +test("should set session storage", () => { + const { result: hook } = renderHook(() => + useStorage("test", { type: "session" }), + ); + + setValue("storage value", hook); + expect(getValue(hook)).toBe("storage value"); + + setValue((prevValue) => `${prevValue}2`, hook); + expect(getValue(hook)).toBe("storage value2"); + + setValue(undefined, hook); + expect(getValue(hook)).toBeNull(); +}); + +test("should sync values across hooks", () => { + const { result: hook } = renderHook(() => useStorage("test")); + const { result: hook2 } = renderHook(() => useStorage("test")); + + setValue("new value", hook); + + expect(getValue(hook)).toBe("new value"); + expect(getValue(hook2)).toBe("new value"); +}); diff --git a/src/helpers/cookies.ts b/src/helpers/cookies.ts new file mode 100644 index 0000000..b2073bb --- /dev/null +++ b/src/helpers/cookies.ts @@ -0,0 +1,89 @@ +export type CookieOptions = { + /** The number of days until the cookie expires. If not defined, the cookies expires at the end of the session */ + expires?: number | Date; + /** The path the cookie is valid for. Defaults to "/" */ + path?: string; + /** The domain the cookie is valid for. Defaults to current domain */ + domain?: string; + /** The SameSite attribute of the cookie. Defaults to "strict" */ + sameSite?: "strict" | "lax" | "none"; + /** Should the cookie only be sent over HTTPS? Defaults to true (if on a https site) */ + secure?: boolean; +}; + +function stringifyOptions(options: CookieOptions) { + return Object.entries(options) + .map(([key, value]) => { + if (!value) { + return undefined; + } + if (value === true) { + return key; + } + if (key === "expires") { + const expires = options[key] || 0; + if (!expires) return undefined; + if (expires instanceof Date) { + return `expires=${expires.toUTCString()}`; + } + return `expires=${new Date( + Date.now() + expires * 864e5, + ).toUTCString()}`; + } + return `${key}=${value}`; + }) + .filter(Boolean) + .join("; "); +} + +/** + * Set a cookie, with a value and options. + * @param name {string} The name of the cookie. + * @param value {string} + * @param options + */ +export function setCookie( + name: string, + value?: string, + options: CookieOptions = {}, +) { + const optionsWithDefault: CookieOptions = { + path: options.path || "/", + sameSite: options.sameSite || "strict", + secure: options.secure ?? document.location.protocol === "https:", + // If expires is not set, set it to -1 (the past), so the cookie is deleted immediately + expires: !value ? -1 : options.expires ?? 0, + domain: options.domain, + }; + + const encodedValue = encodeURIComponent(value || ""); + const optionsString = stringifyOptions(optionsWithDefault); + + document.cookie = `${name}=${encodedValue}; ${optionsString}`; +} + +/** + * Get a cookie by name. + * @param name {string} The name of the cookie. + * @param cookies {string} The cookies string to parse it from. Set this to `document.cookie` on the client. + */ +export function getCookie(name: string, cookies: string) { + const value = cookies + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; + + return value ? decodeURIComponent(value) : undefined; +} + +/** + * Convert the cookies object to a string. + * @param cookies + */ +export function stringifyCookies( + cookies: Record = {}, +) { + return Object.keys(cookies) + .map((key) => `${key}=${cookies[key]}`) + .join("; "); +} diff --git a/src/hooks/useCookie.ts b/src/hooks/useCookie.ts new file mode 100644 index 0000000..7229cbd --- /dev/null +++ b/src/hooks/useCookie.ts @@ -0,0 +1,55 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { type CookieOptions, getCookie, setCookie } from "../helpers/cookies"; +import { addListener, trigger } from "../helpers/listeners"; + +const SUBSCRIPTION_KEY = "cookies"; + +function subscribe(callback: () => void) { + return addListener(SUBSCRIPTION_KEY, callback); +} + +type Options = { + defaultValue?: string; + cookieOptions?: CookieOptions; +}; + +/** + * Get or set a cookie, and update the value when it changes + * @param key {string} The name of the cookie. + * @param options {Options} Options for the useCookie hook. + */ +export function useCookie(key: string, options: Options = {}) { + const getSnapshot = useCallback(() => { + const cookies = typeof document !== "undefined" ? document.cookie : ""; + + return getCookie(key, cookies); + }, [key]); + + const defaultCookieOptions = options.cookieOptions; + + // biome-ignore lint/correctness/useExhaustiveDependencies: the defaultCookieOptions object is validated as JSON + const setValue = useCallback( + ( + newValue?: string | ((prevValue?: string) => string | undefined), + cookieOptions?: CookieOptions, + ) => { + setCookie( + key, + typeof newValue === "function" ? newValue(getSnapshot()) : newValue, + defaultCookieOptions + ? { ...defaultCookieOptions, ...cookieOptions } + : cookieOptions, + ); + trigger(SUBSCRIPTION_KEY); + }, + [ + key, + getSnapshot, + defaultCookieOptions ? JSON.stringify(defaultCookieOptions) : undefined, + ], + ); + + const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return [value || options.defaultValue, setValue] as const; +} diff --git a/src/hooks/useStorage.ts b/src/hooks/useStorage.ts new file mode 100644 index 0000000..fb238da --- /dev/null +++ b/src/hooks/useStorage.ts @@ -0,0 +1,58 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { addListener, trigger } from "../helpers/listeners"; + +const serverSnapShot = () => null; +type Options = { + type?: "local" | "session"; + /** Default value to use if the key is not set in storage. */ + defaultValue?: string; +}; + +/** + * Get or set a value in local or session storage, and update the value when it changes. + * @param key + * @param options + */ +export function useStorage(key: string, options: Options = {}) { + const type = options.type ?? "local"; + // Key to use for the subscription, so we can trigger snapshot updates for this specific storage key + const subscriptionKey = `storage-${type}-${key}`; + + const getSnapshot = useCallback(() => { + if (type === "local") return window.localStorage.getItem(key); + return window.sessionStorage.getItem(key); + }, [key, type]); + + const subscribe = useCallback( + (callback: () => void) => { + return addListener(subscriptionKey, callback); + }, + [subscriptionKey], + ); + + const setValue = useCallback( + ( + newValue?: + | string + | ((prevValue?: string | null) => string | undefined | null), + ) => { + const storage = + type === "local" ? window.localStorage : window.sessionStorage; + + const value = + typeof newValue === "function" + ? newValue(storage.getItem(key)) + : newValue; + + if (value) storage.setItem(key, value); + else storage.removeItem(key); + + trigger(subscriptionKey); + }, + [subscriptionKey, key, type], + ); + + const value = useSyncExternalStore(subscribe, getSnapshot, serverSnapShot); + + return [value || options.defaultValue || null, setValue] as const; +}