Skip to content

Commit 4c1e4d1

Browse files
committed
Merge branch 'main' into feat/update-dependencies
2 parents eb69902 + a91c3af commit 4c1e4d1

File tree

7 files changed

+349
-1
lines changed

7 files changed

+349
-1
lines changed

.github/workflows/nodejs.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,5 @@ jobs:
4949
run: pnpm tsc
5050
- name: Run tests
5151
run: |
52-
pnpx playwright install chromium
52+
pnpm exec playwright install --with-deps
5353
pnpm test

README.md

+23
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ npm install @charlietango/hooks --save
2626
All the hooks are exported on their own, so we don't have a barrel file with all the hooks.
2727
This guarantees that you only import the hooks you need, and don't bloat your bundle with unused code.
2828

29+
### `useCookie`
30+
31+
A hook to interact with the `document.cookie`. It works just like the `useState` hook, but it will persist the value in the cookie.
32+
The hook only sets and gets the `string` value - If you need to store an object, you need to serialize it yourself.
33+
34+
```ts
35+
import { useCookie } from "@charlietango/hooks/use-cookie";
36+
37+
const [value, setValue] = useCookie("mode");
38+
```
39+
2940
### `useDebouncedValue`
3041

3142
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") {
149160
}
150161
```
151162

163+
### `useStorage`
164+
165+
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.
166+
The hook only sets and gets the `string` value - If you need to store an object, you need to serialize it yourself.
167+
168+
```ts
169+
import { useStorage } from "@charlietango/hooks/use-storage";
170+
171+
const [value, setValue] = useStorage("mode", { mode: "local" });
172+
setValue("dark");
173+
```
174+
152175
### `useWindowSize`
153176

154177
Get the current window size. If the window resizes, the hook will update the size.

src/__tests__/useCookie.test.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { useCookie } from "../hooks/useCookie";
3+
4+
function setValue(
5+
value: string | ((prevValue?: string) => string | undefined) | undefined,
6+
hook: { current: ReturnType<typeof useCookie> },
7+
) {
8+
act(() => {
9+
hook.current[1](value);
10+
});
11+
}
12+
13+
function getValue(hook: { current: ReturnType<typeof useCookie> }) {
14+
return hook.current[0];
15+
}
16+
17+
test("should manage cookies", () => {
18+
const { result: hook } = renderHook(() => useCookie("test"));
19+
20+
setValue("custom value", hook);
21+
22+
expect(getValue(hook)).toBe("custom value");
23+
24+
setValue((prevValue) => `${prevValue}2`, hook);
25+
expect(getValue(hook)).toBe("custom value2");
26+
27+
setValue(undefined, hook);
28+
expect(getValue(hook)).toBeUndefined();
29+
});
30+
31+
test("should manage cookies with default value", () => {
32+
const { result: hook } = renderHook(() =>
33+
useCookie("test", { defaultValue: "default value" }),
34+
);
35+
36+
expect(getValue(hook)).toBe("default value");
37+
38+
setValue("custom value", hook);
39+
expect(getValue(hook)).toBe("custom value");
40+
41+
setValue(undefined, hook);
42+
expect(getValue(hook)).toBe("default value");
43+
});
44+
45+
test("should sync values across hooks", () => {
46+
const { result: hook } = renderHook(() => useCookie("test"));
47+
const { result: hook2 } = renderHook(() => useCookie("test"));
48+
49+
setValue("new value", hook);
50+
51+
expect(getValue(hook)).toBe("new value");
52+
expect(getValue(hook2)).toBe("new value");
53+
});

src/__tests__/useStorage.test.ts

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { useStorage } from "../hooks/useStorage";
3+
4+
function setValue(
5+
value:
6+
| string
7+
| ((prevValue?: string | null) => string | undefined | null)
8+
| undefined,
9+
hook: { current: ReturnType<typeof useStorage> },
10+
) {
11+
act(() => {
12+
hook.current[1](value);
13+
});
14+
}
15+
16+
function getValue(hook: { current: ReturnType<typeof useStorage> }) {
17+
return hook.current[0];
18+
}
19+
20+
test("should set storage", () => {
21+
const { result: hook } = renderHook(() => useStorage("test"));
22+
23+
setValue("storage value", hook);
24+
expect(getValue(hook)).toBe("storage value");
25+
26+
setValue((prevValue) => `${prevValue}2`, hook);
27+
expect(getValue(hook)).toBe("storage value2");
28+
29+
setValue(undefined, hook);
30+
expect(getValue(hook)).toBeNull();
31+
});
32+
33+
test("should support a defaultValue", () => {
34+
const { result: hook } = renderHook(() =>
35+
useStorage("test", { defaultValue: "default value" }),
36+
);
37+
38+
expect(getValue(hook)).toBe("default value");
39+
40+
setValue("storage value", hook);
41+
expect(getValue(hook)).toBe("storage value");
42+
43+
setValue(undefined, hook);
44+
expect(getValue(hook)).toBe("default value");
45+
});
46+
47+
test("should set session storage", () => {
48+
const { result: hook } = renderHook(() =>
49+
useStorage("test", { type: "session" }),
50+
);
51+
52+
setValue("storage value", hook);
53+
expect(getValue(hook)).toBe("storage value");
54+
55+
setValue((prevValue) => `${prevValue}2`, hook);
56+
expect(getValue(hook)).toBe("storage value2");
57+
58+
setValue(undefined, hook);
59+
expect(getValue(hook)).toBeNull();
60+
});
61+
62+
test("should sync values across hooks", () => {
63+
const { result: hook } = renderHook(() => useStorage("test"));
64+
const { result: hook2 } = renderHook(() => useStorage("test"));
65+
66+
setValue("new value", hook);
67+
68+
expect(getValue(hook)).toBe("new value");
69+
expect(getValue(hook2)).toBe("new value");
70+
});

src/helpers/cookies.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
export type CookieOptions = {
2+
/** The number of days until the cookie expires. If not defined, the cookies expires at the end of the session */
3+
expires?: number | Date;
4+
/** The path the cookie is valid for. Defaults to "/" */
5+
path?: string;
6+
/** The domain the cookie is valid for. Defaults to current domain */
7+
domain?: string;
8+
/** The SameSite attribute of the cookie. Defaults to "strict" */
9+
sameSite?: "strict" | "lax" | "none";
10+
/** Should the cookie only be sent over HTTPS? Defaults to true (if on a https site) */
11+
secure?: boolean;
12+
};
13+
14+
function stringifyOptions(options: CookieOptions) {
15+
return Object.entries(options)
16+
.map(([key, value]) => {
17+
if (!value) {
18+
return undefined;
19+
}
20+
if (value === true) {
21+
return key;
22+
}
23+
if (key === "expires") {
24+
const expires = options[key] || 0;
25+
if (!expires) return undefined;
26+
if (expires instanceof Date) {
27+
return `expires=${expires.toUTCString()}`;
28+
}
29+
return `expires=${new Date(
30+
Date.now() + expires * 864e5,
31+
).toUTCString()}`;
32+
}
33+
return `${key}=${value}`;
34+
})
35+
.filter(Boolean)
36+
.join("; ");
37+
}
38+
39+
/**
40+
* Set a cookie, with a value and options.
41+
* @param name {string} The name of the cookie.
42+
* @param value {string}
43+
* @param options
44+
*/
45+
export function setCookie(
46+
name: string,
47+
value?: string,
48+
options: CookieOptions = {},
49+
) {
50+
const optionsWithDefault: CookieOptions = {
51+
path: options.path || "/",
52+
sameSite: options.sameSite || "strict",
53+
secure: options.secure ?? document.location.protocol === "https:",
54+
// If expires is not set, set it to -1 (the past), so the cookie is deleted immediately
55+
expires: !value ? -1 : options.expires ?? 0,
56+
domain: options.domain,
57+
};
58+
59+
const encodedValue = encodeURIComponent(value || "");
60+
const optionsString = stringifyOptions(optionsWithDefault);
61+
62+
document.cookie = `${name}=${encodedValue}; ${optionsString}`;
63+
}
64+
65+
/**
66+
* Get a cookie by name.
67+
* @param name {string} The name of the cookie.
68+
* @param cookies {string} The cookies string to parse it from. Set this to `document.cookie` on the client.
69+
*/
70+
export function getCookie(name: string, cookies: string) {
71+
const value = cookies
72+
.split("; ")
73+
.find((row) => row.startsWith(`${name}=`))
74+
?.split("=")[1];
75+
76+
return value ? decodeURIComponent(value) : undefined;
77+
}
78+
79+
/**
80+
* Convert the cookies object to a string.
81+
* @param cookies
82+
*/
83+
export function stringifyCookies(
84+
cookies: Record<string, string | undefined> = {},
85+
) {
86+
return Object.keys(cookies)
87+
.map((key) => `${key}=${cookies[key]}`)
88+
.join("; ");
89+
}

src/hooks/useCookie.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useCallback, useSyncExternalStore } from "react";
2+
import { type CookieOptions, getCookie, setCookie } from "../helpers/cookies";
3+
import { addListener, trigger } from "../helpers/listeners";
4+
5+
const SUBSCRIPTION_KEY = "cookies";
6+
7+
function subscribe(callback: () => void) {
8+
return addListener(SUBSCRIPTION_KEY, callback);
9+
}
10+
11+
type Options = {
12+
defaultValue?: string;
13+
cookieOptions?: CookieOptions;
14+
};
15+
16+
/**
17+
* Get or set a cookie, and update the value when it changes
18+
* @param key {string} The name of the cookie.
19+
* @param options {Options} Options for the useCookie hook.
20+
*/
21+
export function useCookie(key: string, options: Options = {}) {
22+
const getSnapshot = useCallback(() => {
23+
const cookies = typeof document !== "undefined" ? document.cookie : "";
24+
25+
return getCookie(key, cookies);
26+
}, [key]);
27+
28+
const defaultCookieOptions = options.cookieOptions;
29+
30+
// biome-ignore lint/correctness/useExhaustiveDependencies: the defaultCookieOptions object is validated as JSON
31+
const setValue = useCallback(
32+
(
33+
newValue?: string | ((prevValue?: string) => string | undefined),
34+
cookieOptions?: CookieOptions,
35+
) => {
36+
setCookie(
37+
key,
38+
typeof newValue === "function" ? newValue(getSnapshot()) : newValue,
39+
defaultCookieOptions
40+
? { ...defaultCookieOptions, ...cookieOptions }
41+
: cookieOptions,
42+
);
43+
trigger(SUBSCRIPTION_KEY);
44+
},
45+
[
46+
key,
47+
getSnapshot,
48+
defaultCookieOptions ? JSON.stringify(defaultCookieOptions) : undefined,
49+
],
50+
);
51+
52+
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
53+
54+
return [value || options.defaultValue, setValue] as const;
55+
}

src/hooks/useStorage.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useCallback, useSyncExternalStore } from "react";
2+
import { addListener, trigger } from "../helpers/listeners";
3+
4+
const serverSnapShot = () => null;
5+
type Options = {
6+
type?: "local" | "session";
7+
/** Default value to use if the key is not set in storage. */
8+
defaultValue?: string;
9+
};
10+
11+
/**
12+
* Get or set a value in local or session storage, and update the value when it changes.
13+
* @param key
14+
* @param options
15+
*/
16+
export function useStorage(key: string, options: Options = {}) {
17+
const type = options.type ?? "local";
18+
// Key to use for the subscription, so we can trigger snapshot updates for this specific storage key
19+
const subscriptionKey = `storage-${type}-${key}`;
20+
21+
const getSnapshot = useCallback(() => {
22+
if (type === "local") return window.localStorage.getItem(key);
23+
return window.sessionStorage.getItem(key);
24+
}, [key, type]);
25+
26+
const subscribe = useCallback(
27+
(callback: () => void) => {
28+
return addListener(subscriptionKey, callback);
29+
},
30+
[subscriptionKey],
31+
);
32+
33+
const setValue = useCallback(
34+
(
35+
newValue?:
36+
| string
37+
| ((prevValue?: string | null) => string | undefined | null),
38+
) => {
39+
const storage =
40+
type === "local" ? window.localStorage : window.sessionStorage;
41+
42+
const value =
43+
typeof newValue === "function"
44+
? newValue(storage.getItem(key))
45+
: newValue;
46+
47+
if (value) storage.setItem(key, value);
48+
else storage.removeItem(key);
49+
50+
trigger(subscriptionKey);
51+
},
52+
[subscriptionKey, key, type],
53+
);
54+
55+
const value = useSyncExternalStore(subscribe, getSnapshot, serverSnapShot);
56+
57+
return [value || options.defaultValue || null, setValue] as const;
58+
}

0 commit comments

Comments
 (0)