Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .agents/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ Theme is managed by `src/lib/theme.tsx` which provides `ThemeProvider` and `useT
- **Flash prevention:** Inline `<script>` in `<head>` reads localStorage before React hydrates.
- **System detection:** `prefers-color-scheme` media query listener when preference is `"system"`.
- **Default:** `"dark"` (existing users who haven't set a preference get dark mode).
- **localStorage safety:** All `localStorage` calls must be wrapped in try-catch. Browsers may throw `SecurityError` when storage access is denied (privacy settings, embedded contexts, enterprise policies). Fall back to `"dark"` on read, silently ignore on write.

```typescript
// Reading theme in a client component
Expand Down
1 change: 1 addition & 0 deletions src/lib/sentry/sentry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const BARE_CATCH_PERMANENT_ALLOWLIST = new Set([
"src/components/members/invite-form.tsx", // clipboard writeText — intentionally silent on permission denied
"src/components/members/pending-invite-list.tsx", // clipboard writeText — intentionally silent on permission denied
"src/lib/use-persisted-expanded.ts", // localStorage read/write — intentionally silent in private browsing / SSR
"src/lib/theme.tsx", // localStorage read/write — intentionally silent when storage access is denied (SecurityError)
"src/components/editor/demo-editor.tsx", // URL validation (new URL() throws) — same pattern as editor.tsx
"src/components/editor/local-persistence-plugin.tsx", // sessionStorage read/write — intentionally silent in private browsing
]);
Expand Down
91 changes: 91 additions & 0 deletions src/lib/theme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ReactNode } from "react";
import { ThemeProvider, useTheme } from "./theme";

function wrapper({ children }: { children: ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}

describe("ThemeProvider", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute("data-theme");
document.documentElement.classList.remove("dark");
});

it("defaults to dark when localStorage is empty", () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.preference).toBe("dark");
expect(result.current.resolved).toBe("dark");
});

it("reads stored preference from localStorage", () => {
localStorage.setItem("memo-theme", "light");
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.preference).toBe("light");
expect(result.current.resolved).toBe("light");
});

it("ignores invalid stored values", () => {
localStorage.setItem("memo-theme", "invalid-value");
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.preference).toBe("dark");
});

it("persists preference changes to localStorage", () => {
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setPreference("light"));
expect(localStorage.getItem("memo-theme")).toBe("light");
expect(result.current.preference).toBe("light");
});

/**
* Regression test for Sentry MEMO-2H: SecurityError when localStorage is
* blocked (restricted browsers, privacy settings, embedded contexts).
* ThemeProvider must fall back to dark theme instead of crashing.
*/
describe("localStorage SecurityError handling", () => {
let getItemSpy: ReturnType<typeof vi.spyOn>;
let setItemSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
getItemSpy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(
() => {
throw new DOMException(
"Failed to read the 'localStorage' property from 'Window': Access is denied for this document.",
"SecurityError",
);
},
);
setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(
() => {
throw new DOMException(
"Failed to set the 'localStorage' property on 'Window': Access is denied for this document.",
"SecurityError",
);
},
);
});

afterEach(() => {
getItemSpy.mockRestore();
setItemSpy.mockRestore();
});

it("falls back to dark theme when getItem throws SecurityError", () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.preference).toBe("dark");
expect(result.current.resolved).toBe("dark");
});

it("does not crash when setItem throws SecurityError", () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(() => {
act(() => result.current.setPreference("light"));
}).not.toThrow();
expect(result.current.preference).toBe("light");
expect(result.current.resolved).toBe("light");
});
});
});
18 changes: 13 additions & 5 deletions src/lib/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,25 @@ function applyTheme(theme: ResolvedTheme) {
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [preference, setPreferenceState] = useState<ThemePreference>(() => {
if (typeof window === "undefined") return "dark";
const stored = localStorage.getItem(STORAGE_KEY);
return stored === "light" || stored === "dark" || stored === "system"
? stored
: "dark";
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === "light" || stored === "dark" || stored === "system"
? stored
: "dark";
} catch {
return "dark";
}
});
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
preference === "system" ? getSystemTheme() : preference,
);

function setPreference(pref: ThemePreference) {
localStorage.setItem(STORAGE_KEY, pref);
try {
localStorage.setItem(STORAGE_KEY, pref);
} catch {
// Storage unavailable (SecurityError in restricted browsers)
}
setPreferenceState(pref);
const next = pref === "system" ? getSystemTheme() : pref;
setResolved(next);
Expand Down
Loading