fix(theme): persist one canonical preference
CI and Deploy / test (pull_request) Failing after 4m32s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 13:11:44 +02:00
parent bd5362c8c2
commit 896746319b
5 changed files with 64 additions and 31 deletions
+19 -8
View File
@@ -10,6 +10,7 @@ import {
resolveEffectiveThemeMode,
setThemeModePref,
subscribeToThemePreferenceChanges,
THEME_MODE_KEY,
} from "./themePrefs";
function setSystemDark(matches: boolean) {
@@ -36,7 +37,7 @@ describe("deterministic theme state", () => {
setSystemDark(false);
});
it("uses user preference, then anonymous preference, then the documented System default", () => {
it("uses one browser preference and falls back to System", () => {
expect(getThemeModePref()).toBe("system");
setThemeModePref("light");
expect(getThemeModePref()).toBe("light");
@@ -47,12 +48,12 @@ describe("deterministic theme state", () => {
expect(getThemeModePref()).toBe("dark");
setAuthUserKey("user-b", false);
expect(getThemeModePref()).toBe("light");
window.localStorage.removeItem("themeMode:anon");
expect(getThemeModePref()).toBe("dark");
window.localStorage.removeItem(THEME_MODE_KEY);
expect(getThemeModePref()).toBe("system");
});
it("switches to the correct scoped preference on login and logout without a refresh", () => {
it("does not change theme on login or logout", () => {
window.localStorage.setItem("themeMode:anon", "light");
window.localStorage.setItem("themeMode:user-a", "dark");
const observed: string[] = [];
@@ -61,11 +62,12 @@ describe("deterministic theme state", () => {
setAuthUserKey("user-a", false);
clearAuthClientState(false);
expect(observed).toEqual(["dark", "light"]);
expect(observed).toEqual([]);
expect(getThemeModePref()).toBe("light");
unsubscribe();
});
it("synchronizes relevant cross-tab storage changes without writing them back", () => {
it("synchronizes only the canonical cross-tab preference", () => {
setAuthUserKey("user-a", false);
const sync = jest.fn();
const unsubscribe = subscribeToThemePreferenceChanges(sync);
@@ -73,11 +75,20 @@ describe("deterministic theme state", () => {
window.dispatchEvent(new StorageEvent("storage", { key: "themeMode:user-a", newValue: "dark" }));
window.dispatchEvent(new StorageEvent("storage", { key: "unrelated", newValue: "value" }));
window.dispatchEvent(new StorageEvent("storage", { key: "authUserKey", newValue: "user-b" }));
window.dispatchEvent(new StorageEvent("storage", { key: THEME_MODE_KEY, newValue: "dark" }));
expect(sync).toHaveBeenCalledTimes(2);
expect(sync).toHaveBeenCalledTimes(1);
unsubscribe();
});
it("migrates the previous account-scoped preference", () => {
setAuthUserKey("user-a", false);
window.localStorage.setItem("themeMode:user-a", "light");
expect(getThemeModePref()).toBe("light");
expect(window.localStorage.getItem(THEME_MODE_KEY)).toBe("light");
});
it("uses system preference only for System mode", () => {
expect(resolveEffectiveThemeMode("light", true)).toBe("light");
expect(resolveEffectiveThemeMode("dark", false)).toBe("dark");
@@ -86,7 +97,7 @@ describe("deterministic theme state", () => {
});
it("applies the saved preference before the client application renders", () => {
window.localStorage.setItem("themeMode:anon", "light");
window.localStorage.setItem(THEME_MODE_KEY, "light");
setSystemDark(true);
new Function(THEME_BOOTSTRAP_SCRIPT)();
+15
View File
@@ -306,6 +306,21 @@ export const getTheme = (_mode: "light" | "dark") => {
}),
},
},
MuiAlert: {
styleOverrides: {
root: {
alignItems: "flex-start",
},
standardError: ({ theme }: any) => ({ backgroundColor: theme.vars.palette.error.lighter, color: theme.vars.palette.error.darker, "& .MuiAlert-icon": { color: theme.vars.palette.error.main } }),
standardWarning: ({ theme }: any) => ({ backgroundColor: theme.vars.palette.warning.lighter, color: theme.vars.palette.warning.darker, "& .MuiAlert-icon": { color: theme.vars.palette.warning.main } }),
standardInfo: ({ theme }: any) => ({ backgroundColor: theme.vars.palette.info.lighter, color: theme.vars.palette.info.darker, "& .MuiAlert-icon": { color: theme.vars.palette.info.main } }),
standardSuccess: ({ theme }: any) => ({ backgroundColor: theme.vars.palette.success.lighter, color: theme.vars.palette.success.darker, "& .MuiAlert-icon": { color: theme.vars.palette.success.main } }),
outlinedError: ({ theme }: any) => ({ borderColor: theme.vars.palette.error.main, color: theme.vars.palette.error.darker, "& .MuiAlert-icon": { color: theme.vars.palette.error.main } }),
outlinedWarning: ({ theme }: any) => ({ borderColor: theme.vars.palette.warning.main, color: theme.vars.palette.warning.darker, "& .MuiAlert-icon": { color: theme.vars.palette.warning.main } }),
outlinedInfo: ({ theme }: any) => ({ borderColor: theme.vars.palette.info.main, color: theme.vars.palette.info.darker, "& .MuiAlert-icon": { color: theme.vars.palette.info.main } }),
outlinedSuccess: ({ theme }: any) => ({ borderColor: theme.vars.palette.success.main, color: theme.vars.palette.success.darker, "& .MuiAlert-icon": { color: theme.vars.palette.success.main } }),
},
},
MuiPopover: {
styleOverrides: {
paper: ({ theme }: any) => ({
+3 -1
View File
@@ -5,7 +5,9 @@ export const THEME_BOOTSTRAP_SCRIPT = `(() => {
return value === "light" || value === "dark" || value === "system" ? value : null;
};
const userKey = window.localStorage.getItem("authUserKey") || "anon";
const preference = read("themeMode:" + userKey) || (userKey !== "anon" ? read("themeMode:anon") : null) || "system";
const legacyPreference = read("themeMode:" + userKey) || (userKey !== "anon" ? read("themeMode:anon") : null);
const preference = read("jobtracker.themeMode") || legacyPreference || "system";
if (!read("jobtracker.themeMode") && legacyPreference) window.localStorage.setItem("jobtracker.themeMode", legacyPreference);
const mode = preference === "dark" || (preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
document.documentElement.setAttribute("data-color-scheme", mode);
document.documentElement.style.colorScheme = mode;
+17 -13
View File
@@ -1,19 +1,16 @@
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from "./auth";
import { getAuthUserKey } from "./auth";
export type ThemeModePref = "system" | "light" | "dark";
export type EffectiveThemeMode = "light" | "dark";
export const DEFAULT_THEME_MODE: ThemeModePref = "system";
export const THEME_MODE_KEY = "jobtracker.themeMode";
const THEME_KEY_PREFIX = "themeMode:";
export function getUserKeyFromToken(): string {
return getAuthUserKey();
}
function k(base: string) {
return `${base}:${getUserKeyFromToken()}`;
}
function readThemeMode(key: string): ThemeModePref | null {
try {
const raw = window.localStorage.getItem(key);
@@ -24,19 +21,28 @@ function readThemeMode(key: string): ThemeModePref | null {
}
export function getThemeModePref(): ThemeModePref {
const savedMode = readThemeMode(THEME_MODE_KEY);
if (savedMode) return savedMode;
// Migrate the former account-scoped values once. Theme is a browser UI
// preference, so changing auth state must never change the visible scheme.
const userKey = getUserKeyFromToken();
const savedUserMode = readThemeMode(`${THEME_KEY_PREFIX}${userKey}`);
if (savedUserMode) return savedUserMode;
if (userKey !== "anon") {
const anonymousMode = readThemeMode(`${THEME_KEY_PREFIX}anon`);
if (anonymousMode) return anonymousMode;
const legacyMode = savedUserMode ?? (userKey !== "anon" ? readThemeMode(`${THEME_KEY_PREFIX}anon`) : null);
if (legacyMode) {
try {
window.localStorage.setItem(THEME_MODE_KEY, legacyMode);
} catch {
// The migrated value still applies for this render when storage is unavailable.
}
return legacyMode;
}
return DEFAULT_THEME_MODE;
}
export function setThemeModePref(v: ThemeModePref) {
try {
window.localStorage.setItem(k("themeMode"), v);
window.localStorage.setItem(THEME_MODE_KEY, v);
} catch {
// Keep the in-memory selection even when storage is unavailable.
}
@@ -49,12 +55,10 @@ export function resolveEffectiveThemeMode(preference: ThemeModePref, systemPrefe
export function subscribeToThemePreferenceChanges(sync: () => void) {
const onStorage = (event: StorageEvent) => {
if (event.key === "authUserKey" || event.key?.startsWith(THEME_KEY_PREFIX)) sync();
if (event.key === THEME_MODE_KEY) sync();
};
window.addEventListener(AUTH_USER_CHANGED_EVENT, sync);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(AUTH_USER_CHANGED_EVENT, sync);
window.removeEventListener("storage", onStorage);
};
}