mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-13 22:37:14 +10:00
refactor(ui,web): finding 1 — inline use-cookie into donation-toast, drop js-cookie from ui
The 107-line useCookie hook had exactly one consumer (donation-toast) that only read + set-with-expiry. Inline the two Cookies.* calls directly and delete the hook + its 128-line test. Drop js-cookie and @types/js-cookie from packages/ui/package.json (apps/web retains its own js-cookie dep). Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
This commit is contained in:
@@ -14,9 +14,9 @@ type ToastOptions = {
|
||||
unstyled: boolean;
|
||||
};
|
||||
|
||||
const useCookieMock = vi.hoisted(() => ({
|
||||
setDismissed: vi.fn(),
|
||||
const cookieMock = vi.hoisted(() => ({
|
||||
value: null as string | null,
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
@@ -26,8 +26,11 @@ const toastMock = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/ui/hooks/use-cookie", () => ({
|
||||
useCookie: vi.fn(() => [useCookieMock.value, useCookieMock.setDismissed, vi.fn()] as const),
|
||||
vi.mock("js-cookie", () => ({
|
||||
default: {
|
||||
get: vi.fn(() => cookieMock.value ?? undefined),
|
||||
set: cookieMock.set,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -51,8 +54,8 @@ describe("DonationToast", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-11T12:00:00.000Z"));
|
||||
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
||||
useCookieMock.value = null;
|
||||
useCookieMock.setDismissed.mockClear();
|
||||
cookieMock.value = null;
|
||||
cookieMock.set.mockClear();
|
||||
toastMock.toast.custom.mockClear();
|
||||
toastMock.toast.dismiss.mockClear();
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
@@ -89,7 +92,7 @@ describe("DonationToast", () => {
|
||||
});
|
||||
|
||||
it("does not show the toast after it has been dismissed", () => {
|
||||
useCookieMock.value = "true";
|
||||
cookieMock.value = "true";
|
||||
|
||||
render(<DonationToast />);
|
||||
|
||||
@@ -110,7 +113,7 @@ describe("DonationToast", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
|
||||
expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", {
|
||||
expect(cookieMock.set).toHaveBeenCalledWith("donation-toast-dismissed", "true", {
|
||||
expires: new Date("2026-06-10T12:05:00.000Z"),
|
||||
});
|
||||
expect(toastMock.toast.dismiss).toHaveBeenCalledWith("donation-toast");
|
||||
@@ -126,7 +129,7 @@ describe("DonationToast", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Donate" }));
|
||||
|
||||
expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", {
|
||||
expect(cookieMock.set).toHaveBeenCalledWith("donation-toast-dismissed", "true", {
|
||||
expires: new Date("2026-06-10T12:05:00.000Z"),
|
||||
});
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HandHeartIcon } from "@phosphor-icons/react";
|
||||
import { useCallback } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTimeout } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useCookie } from "@reactive-resume/ui/hooks/use-cookie";
|
||||
|
||||
const TOAST_ID = "donation-toast";
|
||||
const SHOW_TOAST_DELAY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -14,7 +14,13 @@ const DISMISSED_COOKIE_EXPIRES_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const getDismissedCookieExpiresAt = () => new Date(Date.now() + DISMISSED_COOKIE_EXPIRES_MS);
|
||||
|
||||
export function DonationToast() {
|
||||
const [dismissed, setDismissed] = useCookie(DISMISSED_COOKIE_NAME);
|
||||
// ponytail: inlined from @reactive-resume/ui/hooks/use-cookie — only consumer, one read + one set-with-expiry
|
||||
const [dismissed, setDismissedState] = useState<string | null>(() => Cookies.get(DISMISSED_COOKIE_NAME) ?? null);
|
||||
|
||||
const setDismissed = useCallback((value: string, options?: { expires?: Date }) => {
|
||||
Cookies.set(DISMISSED_COOKIE_NAME, value, options);
|
||||
setDismissedState(value);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(() => {
|
||||
if (dismissed === "true") return;
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"@shadcn/react": "^0.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"js-cookie": "^3.0.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
@@ -39,7 +38,6 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_COOKIE_ATTRIBUTES, useCookie } from "./use-cookie";
|
||||
|
||||
type CookieAttributes = {
|
||||
expires?: Date | number;
|
||||
path?: string;
|
||||
sameSite?: string;
|
||||
secure?: boolean;
|
||||
};
|
||||
|
||||
const cookieMock = vi.hoisted(() => {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get: vi.fn((name: string) => values.get(name)),
|
||||
remove: vi.fn((name: string) => {
|
||||
values.delete(name);
|
||||
}),
|
||||
reset: () => values.clear(),
|
||||
set: vi.fn((name: string, value: string, _attributes?: CookieAttributes) => {
|
||||
values.set(name, value);
|
||||
}),
|
||||
values,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("js-cookie", () => ({
|
||||
default: {
|
||||
get: cookieMock.get,
|
||||
remove: cookieMock.remove,
|
||||
set: cookieMock.set,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useCookie", () => {
|
||||
beforeEach(() => {
|
||||
cookieMock.reset();
|
||||
cookieMock.get.mockClear();
|
||||
cookieMock.set.mockClear();
|
||||
cookieMock.remove.mockClear();
|
||||
});
|
||||
|
||||
it("reads an existing cookie value", () => {
|
||||
cookieMock.values.set("theme", "dark");
|
||||
|
||||
const { result } = renderHook(() => useCookie("theme", "light"));
|
||||
|
||||
expect(result.current[0]).toBe("dark");
|
||||
expect(cookieMock.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the default value with secure defaults when the cookie is missing", () => {
|
||||
const { result } = renderHook(() => useCookie("theme", "dark"));
|
||||
|
||||
expect(result.current[0]).toBe("dark");
|
||||
expect(cookieMock.set).toHaveBeenCalledWith("theme", "dark", DEFAULT_COOKIE_ATTRIBUTES);
|
||||
});
|
||||
|
||||
it("does not write a cookie when no default value is provided", () => {
|
||||
const { result } = renderHook(() => useCookie("theme"));
|
||||
|
||||
expect(result.current[0]).toBeNull();
|
||||
expect(cookieMock.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the cookie value and state with secure defaults", () => {
|
||||
const { result } = renderHook(() => useCookie("theme"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("dark");
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe("dark");
|
||||
expect(cookieMock.set).toHaveBeenCalledWith("theme", "dark", DEFAULT_COOKIE_ATTRIBUTES);
|
||||
});
|
||||
|
||||
it("passes through native expires cookie attributes", () => {
|
||||
const { result } = renderHook(() => useCookie("session"));
|
||||
const expires = new Date("2026-05-11T12:01:00.000Z");
|
||||
|
||||
act(() => {
|
||||
result.current[1]("active", { expires });
|
||||
});
|
||||
|
||||
const attributes = cookieMock.set.mock.calls.at(-1)?.[2] as CookieAttributes;
|
||||
expect(attributes).toMatchObject({ path: "/", sameSite: "lax", secure: true });
|
||||
expect(attributes.expires).toBe(expires);
|
||||
});
|
||||
|
||||
it("does not expose a custom expiresMs option", () => {
|
||||
const { result } = renderHook(() => useCookie("session"));
|
||||
|
||||
act(() => {
|
||||
// @ts-expect-error use the native js-cookie expires attribute instead
|
||||
result.current[1]("active", { expiresMs: 60_000 });
|
||||
});
|
||||
|
||||
const attributes = cookieMock.set.mock.calls.at(-1)?.[2] as CookieAttributes & { expiresMs?: number };
|
||||
expect(attributes).not.toHaveProperty("expiresMs");
|
||||
});
|
||||
|
||||
it("allows explicit cookie attributes to override defaults", () => {
|
||||
const { result } = renderHook(() => useCookie("theme"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("dark", { sameSite: "strict", secure: false });
|
||||
});
|
||||
|
||||
expect(cookieMock.set).toHaveBeenCalledWith("theme", "dark", {
|
||||
...DEFAULT_COOKIE_ATTRIBUTES,
|
||||
sameSite: "strict",
|
||||
secure: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the cookie with secure defaults and clears state", () => {
|
||||
cookieMock.values.set("theme", "dark");
|
||||
const { result } = renderHook(() => useCookie("theme"));
|
||||
|
||||
act(() => {
|
||||
result.current[2]();
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeNull();
|
||||
expect(cookieMock.remove).toHaveBeenCalledWith("theme", DEFAULT_COOKIE_ATTRIBUTES);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import Cookie from "js-cookie";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type CookieAttributes = NonNullable<Parameters<typeof Cookie.set>[2]>;
|
||||
|
||||
type SharedCookieOptions = {
|
||||
domain?: CookieAttributes["domain"];
|
||||
partitioned?: boolean;
|
||||
path?: CookieAttributes["path"];
|
||||
sameSite?: CookieAttributes["sameSite"];
|
||||
secure?: CookieAttributes["secure"];
|
||||
};
|
||||
|
||||
type UseCookieOptions = SharedCookieOptions & {
|
||||
expires?: CookieAttributes["expires"];
|
||||
};
|
||||
type UseCookieRemoveOptions = SharedCookieOptions;
|
||||
|
||||
export const DEFAULT_COOKIE_ATTRIBUTES = {
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
} as const satisfies UseCookieRemoveOptions;
|
||||
|
||||
const canUseCookies = () => typeof document !== "undefined";
|
||||
|
||||
const getCookie = (name: string) => {
|
||||
if (!canUseCookies()) return null;
|
||||
return Cookie.get(name) ?? null;
|
||||
};
|
||||
|
||||
const compactCookieOptions = ({
|
||||
domain,
|
||||
expires,
|
||||
partitioned,
|
||||
path,
|
||||
sameSite,
|
||||
secure,
|
||||
}: UseCookieOptions): CookieAttributes => ({
|
||||
...(domain !== undefined && { domain }),
|
||||
...(expires !== undefined && { expires }),
|
||||
...(partitioned !== undefined && { partitioned }),
|
||||
...(path !== undefined && { path }),
|
||||
...(sameSite !== undefined && { sameSite }),
|
||||
...(secure !== undefined && { secure }),
|
||||
});
|
||||
|
||||
const resolveCookieOptions = (options?: UseCookieOptions): CookieAttributes => {
|
||||
return {
|
||||
...DEFAULT_COOKIE_ATTRIBUTES,
|
||||
...(options && compactCookieOptions(options)),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveRemoveOptions = (options?: UseCookieRemoveOptions): CookieAttributes => {
|
||||
return {
|
||||
...DEFAULT_COOKIE_ATTRIBUTES,
|
||||
...(options && compactCookieOptions(options)),
|
||||
};
|
||||
};
|
||||
|
||||
export function useCookie(
|
||||
name: string,
|
||||
defaultValue?: string,
|
||||
defaultOptions?: UseCookieOptions,
|
||||
): readonly [
|
||||
string | null,
|
||||
(value: string, options?: UseCookieOptions) => void,
|
||||
(options?: UseCookieRemoveOptions) => void,
|
||||
] {
|
||||
const initialValue = useMemo(() => getCookie(name) ?? defaultValue ?? null, [name, defaultValue]);
|
||||
const [value, setValue] = useState<string | null>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = getCookie(name);
|
||||
let nextValue: string | null;
|
||||
|
||||
if (cookie !== null) {
|
||||
nextValue = cookie;
|
||||
} else if (defaultValue === undefined) {
|
||||
nextValue = null;
|
||||
} else {
|
||||
if (canUseCookies()) Cookie.set(name, defaultValue, resolveCookieOptions(defaultOptions));
|
||||
nextValue = defaultValue;
|
||||
}
|
||||
|
||||
setValue(nextValue);
|
||||
}, [name, defaultValue, defaultOptions]);
|
||||
|
||||
const updateCookie = useCallback(
|
||||
(nextValue: string, options?: UseCookieOptions) => {
|
||||
if (canUseCookies()) Cookie.set(name, nextValue, resolveCookieOptions(options));
|
||||
setValue(nextValue);
|
||||
},
|
||||
[name],
|
||||
);
|
||||
|
||||
const deleteCookie = useCallback(
|
||||
(options?: UseCookieRemoveOptions) => {
|
||||
if (canUseCookies()) Cookie.remove(name, resolveRemoveOptions(options));
|
||||
setValue(null);
|
||||
},
|
||||
[name],
|
||||
);
|
||||
|
||||
return [value, updateCookie, deleteCookie] as const;
|
||||
}
|
||||
Generated
-16
@@ -63,9 +63,6 @@ importers:
|
||||
lefthook:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9
|
||||
npm-check-updates:
|
||||
specifier: ^22.2.9
|
||||
version: 22.2.9
|
||||
pg:
|
||||
specifier: ^8.22.0
|
||||
version: 8.22.0
|
||||
@@ -1028,9 +1025,6 @@ importers:
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
js-cookie:
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.8
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -1062,9 +1056,6 @@ importers:
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.20
|
||||
version: 0.5.20(tailwindcss@4.3.2)
|
||||
'@types/js-cookie':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6
|
||||
'@types/react':
|
||||
specifier: ^19.2.17
|
||||
version: 19.2.17
|
||||
@@ -6242,11 +6233,6 @@ packages:
|
||||
normalize-wheel@1.0.1:
|
||||
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
|
||||
|
||||
npm-check-updates@22.2.9:
|
||||
resolution: {integrity: sha512-DVeZ0KirHfliSsHuR2o7cHE+tW439sVHfJjF6cGWeDiY0Wyl3BI/jS4zV0eixtcMOquFbcF1Su/FsxOvk5MoYA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -12792,8 +12778,6 @@ snapshots:
|
||||
|
||||
normalize-wheel@1.0.1: {}
|
||||
|
||||
npm-check-updates@22.2.9: {}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
||||
Reference in New Issue
Block a user