Skip to content

Commit

Permalink
Merge branch 'main' into feat/update-dependencies
Browse files Browse the repository at this point in the history
  • Loading branch information
thebuilder committed Dec 4, 2024
2 parents eb69902 + a91c3af commit 4c1e4d1
Show file tree
Hide file tree
Showing 7 changed files with 349 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@ jobs:
run: pnpm tsc
- name: Run tests
run: |
pnpx playwright install chromium
pnpm exec playwright install --with-deps
pnpm test
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions src/__tests__/useCookie.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useCookie> },
) {
act(() => {
hook.current[1](value);
});
}

function getValue(hook: { current: ReturnType<typeof useCookie> }) {
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");
});
70 changes: 70 additions & 0 deletions src/__tests__/useStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useStorage> },
) {
act(() => {
hook.current[1](value);
});
}

function getValue(hook: { current: ReturnType<typeof useStorage> }) {
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");
});
89 changes: 89 additions & 0 deletions src/helpers/cookies.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {},
) {
return Object.keys(cookies)
.map((key) => `${key}=${cookies[key]}`)
.join("; ");
}
55 changes: 55 additions & 0 deletions src/hooks/useCookie.ts
Original file line number Diff line number Diff line change
@@ -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;
}
58 changes: 58 additions & 0 deletions src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit 4c1e4d1

Please sign in to comment.