Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
import { NotFound } from "./components/not-found";
import { queryClient } from "./lib/query";
import { routeTree } from "./lib/routeTree.gen";
import { ThemeProvider } from "./lib/theme";
import "./styles/globals.css";

const router = createRouter({
Expand All @@ -21,7 +22,9 @@ const root = createRoot(container!);
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
{import.meta.env.DEV && (
<ReactQueryDevtools
initialIsOpen={false}
Expand Down
251 changes: 251 additions & 0 deletions apps/app/lib/theme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider, useTheme } from "./theme";

function ThemeProbe() {
const { theme, setTheme, toggleTheme } = useTheme();

return (
<>
<div data-testid="theme-value">{theme}</div>
<button onClick={() => setTheme("dark")} type="button">
set-dark
</button>
<button onClick={() => setTheme("light")} type="button">
set-light
</button>
<button onClick={toggleTheme} type="button">
toggle-theme
</button>
</>
);
}

describe("ThemeProvider", () => {
const storage = new Map<string, string>();
let originalMatchMedia: typeof window.matchMedia | undefined;
let originalLocalStorage: Storage;

const localStorageMock: Storage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
key: (index: number) => {
const keys = Array.from(storage.keys());
return keys[index] ?? null;
},
get length() {
return storage.size;
},
};

beforeEach(() => {
originalMatchMedia = window.matchMedia;
originalLocalStorage = window.localStorage;

Object.defineProperty(window, "localStorage", {
configurable: true,
writable: true,
value: localStorageMock,
});
window.localStorage.clear();
document.documentElement.classList.remove("dark");
});

afterEach(() => {
cleanup();

if (originalMatchMedia) {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: originalMatchMedia,
});
}

Object.defineProperty(window, "localStorage", {
configurable: true,
writable: true,
value: originalLocalStorage,
});

vi.restoreAllMocks();
});

it("uses persisted localStorage theme on first render", () => {
window.localStorage.setItem("app-theme", "dark");

render(
<ThemeProvider>
<ThemeProbe />
</ThemeProvider>,
);

expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});

it("falls back to system preference when no theme is persisted", () => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

render(
<ThemeProvider>
<ThemeProbe />
</ThemeProvider>,
);

expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});

it("writes updates to localStorage and keeps DOM class in sync", () => {
render(
<ThemeProvider>
<ThemeProbe />
</ThemeProvider>,
);

fireEvent.click(screen.getByRole("button", { name: "set-dark" }));
expect(window.localStorage.getItem("app-theme")).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);

fireEvent.click(screen.getByRole("button", { name: "set-light" }));
expect(window.localStorage.getItem("app-theme")).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});

it("reacts to theme updates from storage events", async () => {
render(
<ThemeProvider>
<ThemeProbe />
</ThemeProvider>,
);

const event = new Event("storage");
Object.defineProperty(event, "key", { value: "app-theme" });
Object.defineProperty(event, "newValue", { value: "dark" });
window.dispatchEvent(event);

await waitFor(() => {
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
});

it("falls back to system preference when storage key is cleared", async () => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

render(
<ThemeProvider>
<ThemeProbe />
</ThemeProvider>,
);

fireEvent.click(screen.getByRole("button", { name: "set-light" }));
window.localStorage.removeItem("app-theme");

const event = new Event("storage");
Object.defineProperty(event, "key", { value: "app-theme" });
Object.defineProperty(event, "newValue", { value: null });
window.dispatchEvent(event);

await waitFor(() => {
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
});
});

it("recovers when storage read throws", () => {
Object.defineProperty(window, "localStorage", {
configurable: true,
writable: true,
value: {
...localStorageMock,
getItem: () => {
throw new Error("read denied");
},
} as Storage,
});

Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

render(
<ThemeProvider>
<ThemeProbe />
</ThemeProvider>,
);

expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
});

it("ignores storage write failures", () => {
const setItem = vi.fn(() => {
throw new Error("write denied");
});

Object.defineProperty(window, "localStorage", {
configurable: true,
writable: true,
value: {
...localStorageMock,
setItem,
} as Storage,
});

render(
<ThemeProvider>
<ThemeProbe />
</ThemeProvider>,
);

fireEvent.click(screen.getByRole("button", { name: "set-dark" }));
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(setItem).toHaveBeenCalled();
});
});
101 changes: 101 additions & 0 deletions apps/app/lib/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
createContext,
type ReactNode,
use,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";

type Theme = "light" | "dark";

interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

const STORAGE_KEY = "app-theme";

function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";

try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") {
return stored;
}
} catch {
// Continue with system preference fallback.
}

if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "dark";
}

return "light";
}

export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(getInitialTheme);

useLayoutEffect(() => {
const root = document.documentElement;

if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}

try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
// Ignore storage write failures (e.g., private browsing mode).
}
}, [theme]);

useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY) return;
if (event.newValue === "light" || event.newValue === "dark") {
setTheme(event.newValue);
return;
}

setTheme(getInitialTheme());
};

window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener("storage", onStorage);
};
}, []);

const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
}, []);

const value = useMemo(
() => ({
theme,
setTheme,
toggleTheme,
}),
[theme],
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useMemo dependency array [theme] omits toggleTheme. While toggleTheme is currently stable (due to useCallback([], [])), this will break silently if toggleTheme ever gains dependencies—and it will trigger an react-hooks/exhaustive-deps lint warning. Include toggleTheme in the dependency array for correctness and future safety. (setTheme from useState is guaranteed stable by React, so it doesn't need to be listed.)

Suggested change
[theme],
[theme, toggleTheme],

Copilot uses AI. Check for mistakes.
);
Comment on lines +171 to +178

return <ThemeContext value={value}>{children}</ThemeContext>;
}

export function useTheme() {
const ctx = use(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx;
}
Loading
Loading