fix(theme): make preference state deterministic
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
import JobTrackerThemeProvider from "./ThemeProvider";
|
||||
import { THEME_BOOTSTRAP_SCRIPT } from "./themeBootstrap";
|
||||
import {
|
||||
getThemeModePref,
|
||||
resolveEffectiveThemeMode,
|
||||
setThemeModePref,
|
||||
subscribeToThemePreferenceChanges,
|
||||
} from "./themePrefs";
|
||||
|
||||
function setSystemDark(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
matches,
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("deterministic theme state", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.removeAttribute("data-color-scheme");
|
||||
document.documentElement.style.colorScheme = "";
|
||||
setSystemDark(false);
|
||||
});
|
||||
|
||||
it("uses user preference, then anonymous preference, then the documented System default", () => {
|
||||
expect(getThemeModePref()).toBe("system");
|
||||
setThemeModePref("light");
|
||||
expect(getThemeModePref()).toBe("light");
|
||||
|
||||
setAuthUserKey("user-a", false);
|
||||
expect(getThemeModePref()).toBe("light");
|
||||
setThemeModePref("dark");
|
||||
expect(getThemeModePref()).toBe("dark");
|
||||
|
||||
setAuthUserKey("user-b", false);
|
||||
expect(getThemeModePref()).toBe("light");
|
||||
window.localStorage.removeItem("themeMode:anon");
|
||||
expect(getThemeModePref()).toBe("system");
|
||||
});
|
||||
|
||||
it("switches to the correct scoped preference on login and logout without a refresh", () => {
|
||||
window.localStorage.setItem("themeMode:anon", "light");
|
||||
window.localStorage.setItem("themeMode:user-a", "dark");
|
||||
const observed: string[] = [];
|
||||
const unsubscribe = subscribeToThemePreferenceChanges(() => observed.push(getThemeModePref()));
|
||||
|
||||
setAuthUserKey("user-a", false);
|
||||
clearAuthClientState(false);
|
||||
|
||||
expect(observed).toEqual(["dark", "light"]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("synchronizes relevant cross-tab storage changes without writing them back", () => {
|
||||
setAuthUserKey("user-a", false);
|
||||
const sync = jest.fn();
|
||||
const unsubscribe = subscribeToThemePreferenceChanges(sync);
|
||||
|
||||
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" }));
|
||||
|
||||
expect(sync).toHaveBeenCalledTimes(2);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("uses system preference only for System mode", () => {
|
||||
expect(resolveEffectiveThemeMode("light", true)).toBe("light");
|
||||
expect(resolveEffectiveThemeMode("dark", false)).toBe("dark");
|
||||
expect(resolveEffectiveThemeMode("system", true)).toBe("dark");
|
||||
expect(resolveEffectiveThemeMode("system", false)).toBe("light");
|
||||
});
|
||||
|
||||
it("applies the saved preference before the client application renders", () => {
|
||||
window.localStorage.setItem("themeMode:anon", "light");
|
||||
setSystemDark(true);
|
||||
|
||||
new Function(THEME_BOOTSTRAP_SCRIPT)();
|
||||
|
||||
expect(document.documentElement).toHaveAttribute("data-color-scheme", "light");
|
||||
expect(document.documentElement.style.colorScheme).toBe("light");
|
||||
});
|
||||
|
||||
it("changes color scheme without remounting application state", async () => {
|
||||
let mounts = 0;
|
||||
function StatefulProbe() {
|
||||
const [count, setCount] = useState(0);
|
||||
useEffect(() => { mounts += 1; }, []);
|
||||
return <button onClick={() => setCount((value) => value + 1)}>Count {count}</button>;
|
||||
}
|
||||
|
||||
const view = render(
|
||||
<JobTrackerThemeProvider preference="light" systemPrefersDark>
|
||||
<StatefulProbe />
|
||||
</JobTrackerThemeProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Count 0" }));
|
||||
|
||||
view.rerender(
|
||||
<JobTrackerThemeProvider preference="dark" systemPrefersDark={false}>
|
||||
<StatefulProbe />
|
||||
</JobTrackerThemeProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(document.documentElement).toHaveAttribute("data-color-scheme", "dark"));
|
||||
expect(screen.getByRole("button", { name: "Count 1" })).toBeInTheDocument();
|
||||
expect(mounts).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user