Skip to content

Fix theme persistence and auth session validation.#986

Open
biplab-sutradhar wants to merge 5 commits intostagefrom
fix/theme-auth
Open

Fix theme persistence and auth session validation.#986
biplab-sutradhar wants to merge 5 commits intostagefrom
fix/theme-auth

Conversation

@biplab-sutradhar
Copy link
Copy Markdown
Contributor

@biplab-sutradhar biplab-sutradhar commented Mar 30, 2026

closes #987

Description

Updated ThemeProvider to support light, dark, and device modes
optimized AuthProvider session validation to avoid slow initial loading

What type of PR is this? (Check all applicable)

  • 🍕 Feature
  • 🐛 Bug Fix
  • 📄 Documentation Update
  • 👨‍💻 Code Refactor
  • 🔥 Performance Improvements
  • ✅ Test
  • 🛠️ CI/CD

Screenshots (if applicable)

Theme persistence

thememode.mp4

Auth session validation

auth.mp4

Checklist

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the UI context layer to improve theme persistence (adding light / dark / device modes) and refactors AuthProvider startup session validation to reduce initial loading delays.

Changes:

  • Replaced the boolean dark mode state with a string-based theme state (light/dark/device) and persists it to localStorage.
  • Updated the theme toggle UI to cycle through the three theme modes.
  • Refactored AuthProvider session validation flow and updated associated unit tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/ui/src/utils/Constants.js Adds a theme-cycling mapping used by the toggle component.
packages/ui/src/contexts/ThemeContext.jsx Implements theme state + device-mode handling via matchMedia, persists theme to localStorage, and changes provided context shape.
packages/ui/src/contexts/AuthContext.jsx Changes session validation flow and removes gating of children rendering.
packages/ui/src/components/darkModeToggle/DarkModeToggle.jsx Updates toggle behavior to cycle light → dark → device.
packages/ui/__tests__/contexts/ThemeContext.test.jsx Updates tests for the new theme API and device-mode behavior.
packages/ui/__tests__/contexts/AuthContext.test.jsx Updates mocks/tests to reflect refactored session validation and logout calls.
packages/ui/__tests__/components/DarkModeToggle.test.jsx Updates toggle tests for the new cycling behavior and context API.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +7 to +8
const saved = localStorage.getItem("theme");
if (saved) return saved;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Theme initialization reads localStorage.getItem("theme") without validation/migration. This can regress existing users who have the old darkMode key, and it also allows invalid stored values (e.g. "foo") which later break icon[theme] / nextTheme[theme] and prevents applying any theme. Consider validating against an allow-list (light/dark/device), migrating darkMode -> theme on first load, and falling back to a safe default when the stored value is unrecognized.

Suggested change
const saved = localStorage.getItem("theme");
if (saved) return saved;
const allowedThemes = new Set(["light", "dark", "device"]);
const saved = localStorage.getItem("theme");
if (saved && allowedThemes.has(saved)) {
return saved;
}
const legacyDarkMode = localStorage.getItem("darkMode");
if (legacyDarkMode === "true") {
localStorage.setItem("theme", "dark");
return "dark";
}
if (legacyDarkMode === "false") {
localStorage.setItem("theme", "light");
return "light";
}
localStorage.setItem("theme", "light");

Copilot uses AI. Check for mistakes.
Comment on lines 44 to 47
const contextValue = useMemo(
() => ({ isDarkMode, toggleTheme }),
[isDarkMode]
() => ({ theme, setTheme, toggleTheme }),
[theme]
);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

ThemeProvider no longer exposes isDarkMode in the context value (it now provides { theme, setTheme, toggleTheme }). There are existing consumers (e.g. Graph.jsx) that read isDarkMode from ThemeContext, which will now be undefined and cause dark-theme styling to never apply. To avoid breaking existing components, either update all consumers in this PR or keep backward compatibility by also providing a derived isDarkMode boolean.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +32
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => applyTheme(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Device mode calls window.matchMedia(...) and uses addEventListener/removeEventListener without checking availability. In environments where matchMedia is missing or where only addListener/removeListener exist, selecting theme === "device" will throw. Consider guarding for window.matchMedia and supporting the older listener API (or falling back to light/dark) to avoid runtime crashes.

Suggested change
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => applyTheme(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
// Guard against environments without window or matchMedia
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
// Fallback: default to light theme when device preference is unavailable
applyTheme(false);
return;
}
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => applyTheme(mq.matches);
update();
// Prefer modern event listener API if available
if (typeof mq.addEventListener === "function" && typeof mq.removeEventListener === "function") {
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}
// Fallback to older addListener/removeListener APIs
if (typeof mq.addListener === "function" && typeof mq.removeListener === "function") {
mq.addListener(update);
return () => mq.removeListener(update);
}
// If no listener API is available, we already applied the theme once
return;

Copilot uses AI. Check for mistakes.
Comment on lines +365 to +369
export const nextTheme = {
light: "dark",
dark: "device",
device: "light",
};
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

nextTheme is exported with a lower-camel name while every other exported constant in this module is UPPER_SNAKE_CASE. To stay consistent and make intent clearer, consider renaming to something like NEXT_THEME/THEME_CYCLE (and updating imports), or colocating this mapping in the theme module since it’s not a general UI string/asset constant.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to 21
<button
className={styles.darkModeToggle}
onClick={() => setTheme(nextTheme[theme])}
>
{icon[theme]}
</button>
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

onClick={() => setTheme(nextTheme[theme])} will set theme to undefined if the current theme value is unexpected (e.g. corrupted localStorage value). This leaves the UI without an icon and prevents theme application. Consider adding a fallback (e.g. default to "light") and/or ensure the provider guarantees theme is always one of the supported values before it reaches this component.

Copilot uses AI. Check for mistakes.
Comment on lines 26 to 53
@@ -41,21 +37,19 @@ export const AuthProvider = ({ children }) => {
setIsAuthenticated(false);
toast.success(MESSAGES.SIGN_OUT_SUCCESS);
}
}, [setIsAuthenticated, makeRequest, toast]);
}, [makeRequest, toast]);

const authValue = useMemo(
() => ({
isAuthenticated,
setIsAuthenticated,
logout,
}),
[isAuthenticated, setIsAuthenticated, logout]
[isAuthenticated, logout]
);

return (
<AuthContext.Provider value={authValue}>
{isAuthCheckComplete ? children : null}
</AuthContext.Provider>
<AuthContext.Provider value={authValue}>{children}</AuthContext.Provider>
);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

AuthProvider now renders children immediately and removed the auth-check completion gate. With isAuthenticated defaulting to false, routes wrapped in ProtectedRoute will redirect to / before validateSession() resolves, breaking session persistence on page refresh. Consider restoring an isAuthCheckComplete/isValidatingSession flag (and using it in ProtectedRoute to show a spinner instead of redirecting) or keep the provider-level gating until validation finishes.

Copilot uses AI. Check for mistakes.
Comment on lines +170 to +188
it("cleans up matchMedia listener on unmount when using device mode", async () => {
const removeListener = vi.fn();
globalThis.matchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: removeListener,
});

render(
const { unmount } = render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);

const themeStatus = screen.getByTestId("theme-status").textContent;
expect(themeStatus).toBe("Light Mode");

globalThis.matchMedia = originalMatchMedia;
});

it("provides correct context value", async () => {
let contextValue = null;

const ContextChecker = () => {
contextValue = useContext(ThemeContext);
return null;
};
fireEvent.click(screen.getByTestId("set-device-btn"));

render(
<ThemeProvider>
<ContextChecker />
</ThemeProvider>
);
unmount();

await waitFor(() => {
expect(contextValue).toHaveProperty("isDarkMode");
expect(contextValue).toHaveProperty("toggleTheme");
expect(typeof contextValue.isDarkMode).toBe("boolean");
expect(typeof contextValue.toggleTheme).toBe("function");
});
expect(removeListener).toHaveBeenCalled();
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

This test can be flaky because it unmounts immediately after switching to device mode; React effects that register the matchMedia listener may not have run yet, so there may be no cleanup to execute on unmount. Consider awaiting an assertion that the device-mode effect ran (e.g. theme applied or addEventListener called) before calling unmount(), then assert removeEventListener was called.

Copilot uses AI. Check for mistakes.
@biplab-sutradhar biplab-sutradhar added the enhancement New feature or request label Apr 1, 2026
@biplab-sutradhar biplab-sutradhar marked this pull request as ready for review April 3, 2026 16:33
@biplab-sutradhar biplab-sutradhar requested review from AryaDharkar and Smayur0 and removed request for AryaDharkar April 3, 2026 16:47
Copy link
Copy Markdown
Member

@sachinkmrsin sachinkmrsin left a comment

Choose a reason for hiding this comment

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

Code looks correct, please address the issues raised by copilot.

@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhance Theme System (Light/Dark/Device) & Optimize AuthProvider Initial Validation

3 participants