-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' into feat/update-dependencies
- Loading branch information
Showing
7 changed files
with
349 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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("; "); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |