diff --git a/frontend/src/components/RealTimeBalanceSync.test.tsx b/frontend/src/components/RealTimeBalanceSync.test.tsx new file mode 100644 index 0000000..189d815 --- /dev/null +++ b/frontend/src/components/RealTimeBalanceSync.test.tsx @@ -0,0 +1,183 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { RealTimeBalanceSync } from "./RealTimeBalanceSync"; + +vi.mock("framer-motion", async () => { + const actual = await vi.importActual("framer-motion"); + return { + ...actual, + motion: { + section: ({ children, variants, initial, animate, exit, layout, whileTap, ...props }: any) => + React.createElement("section", props, children), + button: ({ children, whileTap, ...props }: any) => + React.createElement("button", props, children), + p: ({ children, variants, initial, animate, exit, ...props }: any) => + React.createElement("p", props, children), + ul: ({ children, variants, initial, animate, ...props }: any) => + React.createElement("ul", props, children), + li: ({ children, variants, initial, animate, exit, layout, ...props }: any) => + React.createElement("li", props, children), + span: ({ children, variants, initial, animate, transition, ...props }: any) => + React.createElement("span", props, children), + }, + AnimatePresence: ({ children }: any) => React.createElement(React.Fragment, null, children), + useReducedMotion: () => false, + }; +}); + +const mockBalances = [ + { code: "XLM", balance: "100.50" }, + { code: "USDC", balance: "250.00" }, +]; + +describe("RealTimeBalanceSync", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders heading and refresh button", () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ balances: [] }), + }); + + render( + + ); + + expect(screen.getByText("Real-time Balances")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /refresh/i })).toBeInTheDocument(); + }); + + it("displays balances from server", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ balances: mockBalances }), + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText("XLM")).toBeInTheDocument(); + expect(screen.getByText("USDC")).toBeInTheDocument(); + }); + + expect(screen.getByText("100.50")).toBeInTheDocument(); + expect(screen.getByText("250.00")).toBeInTheDocument(); + }); + + it("shows syncing state while loading", async () => { + global.fetch = vi.fn().mockImplementation( + () => new Promise(() => {}) + ); + + render( + + ); + + expect(screen.getByText("Syncing\u2026")).toBeInTheDocument(); + }); + + it("shows error state when fetch fails", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent(/network error/i); + }); + }); + + it("shows empty state when no balances", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ balances: [] }), + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText("No balances available.")).toBeInTheDocument(); + }); + }); + + it("shows last updated time after successful fetch", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ balances: mockBalances }), + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/updated/i)).toBeInTheDocument(); + }); + }); + + it("includes aria-live region for screen readers", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ balances: mockBalances }), + }); + + render( + + ); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion).toBeInTheDocument(); + expect(liveRegion).toHaveAttribute("role", "status"); + }); + + it("calls refresh when refresh button is clicked", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ balances: mockBalances }), + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText("XLM")).toBeInTheDocument(); + }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ balances: [{ code: "BTC", balance: "1.5" }] }), + }); + + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); + + await waitFor(() => { + expect(screen.getByText("BTC")).toBeInTheDocument(); + }); + }); + + it("marks section as aria-busy when loading", () => { + global.fetch = vi.fn().mockImplementation( + () => new Promise(() => {}) + ); + + render( + + ); + + const section = screen.getByLabelText("Real-time balance information"); + expect(section).toHaveAttribute("aria-busy", "true"); + }); +}); diff --git a/frontend/src/components/RealTimeBalanceSync.tsx b/frontend/src/components/RealTimeBalanceSync.tsx index c33653d..965c45c 100644 --- a/frontend/src/components/RealTimeBalanceSync.tsx +++ b/frontend/src/components/RealTimeBalanceSync.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useId } from "react"; +import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; import { useBalanceSync } from "@/hooks/useBalanceSync"; interface RealTimeBalanceSyncProps { @@ -12,14 +13,36 @@ interface RealTimeBalanceSyncProps { className?: string; } -/** - * RealTimeBalanceSync - * - * Displays live Stellar account balances with: - * - Polling + optimistic update support via useBalanceSync (issues #955, #956) - * - Full screen-reader accessibility: aria-live region, aria-label, aria-busy, - * role="status", and descriptive per-balance labels (issue #955) - */ +const containerVariants = { + hidden: { opacity: 0, y: -8 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.3, ease: "easeOut" }, + }, +}; + +const listVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.05, delayChildren: 0.05 }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, x: -12 }, + visible: { + opacity: 1, + x: 0, + transition: { duration: 0.25, ease: "easeOut" }, + }, + exit: { + opacity: 0, x: 12, + transition: { duration: 0.15 }, + }, +}; + export function RealTimeBalanceSync({ merchantId, apiKey, @@ -29,6 +52,7 @@ export function RealTimeBalanceSync({ className = "", }: RealTimeBalanceSyncProps) { const liveId = useId(); + const shouldReduceMotion = useReducedMotion(); const { balances, @@ -45,13 +69,17 @@ export function RealTimeBalanceSync({ enabled: true, }); + const animProps = shouldReduceMotion + ? { initial: false, animate: {}, variants: undefined } + : { variants: containerVariants, initial: "hidden", animate: "visible" }; + return ( -
- {/* Hidden live region — announced to screen readers on every update (issue #955) */}
- {/* Header */}

Real-time Balances

- + {isLoading ? "Syncing\u2026" : "Refresh"} +
- {/* Error state */} - {error && ( -

- {error} -

- )} + + {error ? ( + + {error} + + ) : null} + - {/* Balance list */} {balances.length === 0 && !isLoading ? ( -

+ No balances available. -

+ ) : ( -
    - {balances.map((b) => ( -
  • - {b.code} - - {parseFloat(b.balance).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 7, - })} - -
  • - ))} -
+ + {balances.map((b) => ( + + {b.code} + + {parseFloat(b.balance).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 7, + })} + + + ))} + + )} - {/* Last updated — hidden from visual but readable by AT */} {lastUpdated && ( -

+ -

+ )} -
+ ); } diff --git a/frontend/src/components/ThemeToggle.test.tsx b/frontend/src/components/ThemeToggle.test.tsx index e436cf6..86efa24 100644 --- a/frontend/src/components/ThemeToggle.test.tsx +++ b/frontend/src/components/ThemeToggle.test.tsx @@ -1,3 +1,118 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { ThemeProvider } from "@/lib/theme-context"; +import ThemeToggle from "./ThemeToggle"; + +vi.mock("framer-motion", async () => { + const actual = await vi.importActual("framer-motion"); + return { + ...actual, + motion: { + button: ({ children, whileHover, whileTap, transition, ...props }: any) => + React.createElement("button", props, children), + svg: ({ children, initial, animate, exit, transition, ...props }: any) => + React.createElement("svg", props, children), + div: ({ children, initial, animate, exit, transition, ...props }: any) => + React.createElement("div", props, children), + }, + AnimatePresence: ({ children }: any) => React.createElement(React.Fragment, null, children), + useReducedMotion: () => false, + }; +}); + +describe("ThemeToggle", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.className = ""; + + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }, + writable: true, + }); + + Object.defineProperty(globalThis, "window", { + value: { + matchMedia: vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }, + writable: true, + }); + + Object.defineProperty(globalThis, "document", { + value: { + documentElement: { + classList: { + remove: vi.fn(), + add: vi.fn(), + }, + }, + querySelector: vi.fn(() => null), + }, + writable: true, + }); + }); + afterEach(() => { vi.restoreAllMocks(); - }); \ No newline at end of file + }); + + it("renders loading state initially then toggle button", async () => { + render( + + + + ); + + expect(screen.getByLabelText("Loading theme settings")).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByLabelText(/theme toggle/i)).toBeInTheDocument(); + }); + }); + + it("displays error state and allows retry", async () => { + const mockSetItem = globalThis.localStorage.setItem as ReturnType; + mockSetItem.mockImplementation(() => { + throw new Error("Storage error"); + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByLabelText(/theme toggle/i)).toBeInTheDocument(); + }); + + const button = screen.getByLabelText(/theme toggle/i); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByLabelText(/error/i)).toBeInTheDocument(); + }); + }); + + it("provides screen reader announcements", async () => { + render( + + + + ); + + await waitFor(() => { + const announcements = document.querySelectorAll('[role="status"]'); + expect(announcements.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/src/hooks/useBalanceSync.ts b/frontend/src/hooks/useBalanceSync.ts index ae0316e..82ec3b2 100644 --- a/frontend/src/hooks/useBalanceSync.ts +++ b/frontend/src/hooks/useBalanceSync.ts @@ -18,7 +18,6 @@ export interface BalanceSyncState { isLoading: boolean; lastUpdated: Date | null; error: string | null; - /** Balance values that were set optimistically before the next poll confirms them. */ optimisticBalances: Record; } @@ -26,7 +25,8 @@ export type BalanceSyncAction = | { type: "FETCH_START" } | { type: "FETCH_SUCCESS"; balances: Balance[]; at: Date } | { type: "FETCH_ERROR"; error: string } - | { type: "OPTIMISTIC_UPDATE"; code: string; balance: string }; + | { type: "OPTIMISTIC_UPDATE"; code: string; balance: string } + | { type: "RESET" }; export const initialBalanceSyncState: BalanceSyncState = { balances: [], @@ -36,10 +36,7 @@ export const initialBalanceSyncState: BalanceSyncState = { optimisticBalances: {}, }; -// Consolidating the related pieces of state into a reducer keeps the -// fetch lifecycle (start → success → error) explicit and prevents the -// inconsistent intermediate renders that separate setState calls can cause. -export function balanceSyncReducer( +function balanceSyncReducer( state: BalanceSyncState, action: BalanceSyncAction, ): BalanceSyncState { @@ -53,7 +50,6 @@ export function balanceSyncReducer( isLoading: false, lastUpdated: action.at, error: null, - // Clear optimistic overrides once the server value arrives optimisticBalances: {}, }; case "FETCH_ERROR": @@ -66,15 +62,13 @@ export function balanceSyncReducer( [action.code]: action.balance, }, }; + case "RESET": + return { ...initialBalanceSyncState }; default: return state; } } -/** - * Merge confirmed balances with any pending optimistic overrides so callers - * always see a consistent view without waiting for the next poll. - */ function applyOptimisticOverrides( balances: Balance[], optimistic: Record, @@ -91,13 +85,6 @@ function applyOptimisticOverrides( return merged; } -/** - * Hook for real-time balance synchronization with polling, race condition - * prevention, and optimistic updates (issue #956). - * - * Screen-reader-ready: returns `ariaLabel` and `liveRegionText` for callers - * to wire up accessible live regions (issue #955). - */ export function useBalanceSync( merchantId: string | null | undefined, apiKey: string | null | undefined, @@ -113,6 +100,8 @@ export function useBalanceSync( const [state, dispatch] = useReducer(balanceSyncReducer, initialBalanceSyncState); const abortControllerRef = useRef(null); + const onUpdateRef = useRef(onUpdate); + onUpdateRef.current = onUpdate; const fetchBalances = useCallback(async () => { if (!enabled) return; @@ -152,24 +141,23 @@ export function useBalanceSync( } dispatch({ type: "FETCH_SUCCESS", balances: newBalances, at: new Date() }); - onUpdate?.(newBalances); + onUpdateRef.current?.(newBalances); } catch (error) { if (error instanceof Error && error.name === "AbortError") return; const message = error instanceof Error ? error.message : "Balance sync failed"; console.error("Balance sync error:", error); dispatch({ type: "FETCH_ERROR", error: message }); } - }, [merchantId, apiKey, enabled, onUpdate, address, horizonUrl]); + }, [merchantId, apiKey, enabled, address, horizonUrl]); - /** - * Optimistically update a single balance (issue #956). - * The value is shown immediately; the next poll from the server clears the - * override and replaces it with the authoritative value. - */ const applyOptimistic = useCallback((code: string, balance: string) => { dispatch({ type: "OPTIMISTIC_UPDATE", code, balance }); }, []); + const reset = useCallback(() => { + dispatch({ type: "RESET" }); + }, []); + useEffect(() => { fetchBalances(); @@ -187,9 +175,8 @@ export function useBalanceSync( state.optimisticBalances, ); - // Derive an accessible description for live regions (issue #955) const liveRegionText = state.isLoading - ? "Syncing balances…" + ? "Syncing balances\u2026" : state.error ? `Balance sync error: ${state.error}` : state.lastUpdated @@ -203,12 +190,11 @@ export function useBalanceSync( error: state.error, refresh: fetchBalances, applyOptimistic, + reset, isStale: state.lastUpdated ? Date.now() - state.lastUpdated.getTime() > pollingInterval * 2 : true, - /** Aria-label for the balance region — pass to aria-label on the wrapper. */ ariaLabel: "Real-time balance information", - /** Text to place in an aria-live region so screen readers announce updates. */ liveRegionText, }; } diff --git a/frontend/src/lib/theme-context.test.tsx b/frontend/src/lib/theme-context.test.tsx index 4d0992e..c06ada8 100644 --- a/frontend/src/lib/theme-context.test.tsx +++ b/frontend/src/lib/theme-context.test.tsx @@ -563,6 +563,109 @@ describe("Dark Mode Theme Engine", () => { expect(screen.getByTestId("theme")).toHaveTextContent("light"); }); }); + + it("optimistically updates the document class before localStorage persists", async () => { + renderDarkMode({ defaultTheme: "light" }); + + await waitFor(() => { + expect(screen.getByTestId("is-mounted")).toHaveTextContent("true"); + }); + + fireEvent.click(screen.getByText("Set Dark")); + + await waitFor(() => { + expect(globalThis.document.documentElement.classList.add).toHaveBeenCalledWith("dark"); + expect(globalThis.localStorage.setItem).toHaveBeenCalledWith("merchant-theme-preference", "dark"); + }); + + const removeCallOrder = (globalThis.document.documentElement.classList.remove as ReturnType).mock + .invocationCallOrder[0]; + const addCallOrder = (globalThis.document.documentElement.classList.add as ReturnType).mock + .invocationCallOrder[0]; + const storageCallOrder = (globalThis.localStorage.setItem as ReturnType).mock + .invocationCallOrder[0]; + + expect(addCallOrder).toBeLessThan(storageCallOrder); + }); + + it("applies document class update on rollback when persist fails", async () => { + const mockSetItem = globalThis.localStorage.setItem as ReturnType; + mockSetItem.mockImplementation(() => { + throw new Error("Storage error"); + }); + + renderDarkMode({ defaultTheme: "light" }); + + await waitFor(() => { + expect(screen.getByTestId("is-mounted")).toHaveTextContent("true"); + }); + + fireEvent.click(screen.getByText("Set Dark")); + + await waitFor(() => { + expect(globalThis.document.documentElement.classList.add).toHaveBeenCalledWith("light"); + expect(screen.getByTestId("error")).toHaveTextContent("Storage error"); + }); + }); + + it("sets error and maintains current theme when localStorage.setItem throws after multiple toggles", async () => { + const mockSetItem = globalThis.localStorage.setItem as ReturnType; + let failNext = false; + mockSetItem.mockImplementation((key: string, value: string) => { + if (failNext) { + throw new Error("Storage full"); + } + }); + + renderDarkMode({ defaultTheme: "light" }); + + await waitFor(() => { + expect(screen.getByTestId("is-mounted")).toHaveTextContent("true"); + }); + + fireEvent.click(screen.getByText("Set Dark")); + await waitFor(() => { + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + }); + + failNext = true; + fireEvent.click(screen.getByText("Set Light")); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Storage full"); + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + }); + }); + + it("clears error on successful theme set after a failure", async () => { + const mockSetItem = globalThis.localStorage.setItem as ReturnType; + let shouldFail = true; + mockSetItem.mockImplementation(() => { + if (shouldFail) { + throw new Error("Temporary error"); + } + }); + + renderDarkMode({ defaultTheme: "light" }); + + await waitFor(() => { + expect(screen.getByTestId("is-mounted")).toHaveTextContent("true"); + }); + + fireEvent.click(screen.getByText("Set Dark")); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Temporary error"); + }); + + shouldFail = false; + fireEvent.click(screen.getByText("Set Light")); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("no-error"); + expect(screen.getByTestId("theme")).toHaveTextContent("light"); + }); + }); }); describe("Error Cases", () => { diff --git a/frontend/src/lib/theme-context.tsx b/frontend/src/lib/theme-context.tsx index 44e4828..ce7f96e 100644 --- a/frontend/src/lib/theme-context.tsx +++ b/frontend/src/lib/theme-context.tsx @@ -1,12 +1,11 @@ "use client"; -import { createContext, useContext, useEffect, useState, useCallback, ReactNode, useMemo } from "react"; +import { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode, useMemo } from "react"; import { ThemeProvider as NextThemesProvider } from "next-themes"; export type ThemeMode = "light" | "dark" | "system"; export type ResolvedTheme = "light" | "dark"; -/** Returns whether the OS currently prefers a dark color scheme. */ function systemPrefersDark(): boolean { return ( typeof globalThis !== "undefined" && @@ -15,14 +14,12 @@ function systemPrefersDark(): boolean { ); } -/** Resolves a {@link ThemeMode} to a concrete light/dark theme. */ export function resolveTheme(mode: ThemeMode, forced?: ResolvedTheme): ResolvedTheme { if (forced) return forced; if (mode === "system") return systemPrefersDark() ? "dark" : "light"; return mode; } -/** Applies the resolved theme to the document root class and meta theme-color. */ function applyThemeToDocument(resolved: ResolvedTheme): void { if (typeof globalThis === "undefined" || !globalThis.document) return; globalThis.document.documentElement.classList.remove("light", "dark"); @@ -67,97 +64,89 @@ export function ThemeProvider({ const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const themeRef = useRef(theme); + const resolvedRef = useRef(resolvedTheme); + + useEffect(() => { themeRef.current = theme; }, [theme]); + useEffect(() => { resolvedRef.current = resolvedTheme; }, [resolvedTheme]); + const clearError = useCallback(() => { setError(null); }, []); const setTheme = useCallback((newTheme: ThemeMode) => { - const previousTheme = theme; - const previousResolved = resolvedTheme; + const previousTheme = themeRef.current; + const previousResolved = resolvedRef.current; - try { - setThemeState(newTheme); + const resolved = resolveTheme(newTheme); + setThemeState(newTheme); + setResolvedTheme(resolved); + applyThemeToDocument(resolved); + + try { if (typeof globalThis !== "undefined" && globalThis.window) { globalThis.localStorage.setItem(storageKey, newTheme); - - const resolved = resolveTheme(newTheme); - setResolvedTheme(resolved); - applyThemeToDocument(resolved); } - setError(null); } catch (err) { - // Revert optimistic update — restore the previous theme, the document - // class AND the meta theme-color so no part of the optimistic change - // lingers after a failed persist. - setThemeState(previousTheme !== undefined ? previousTheme : defaultTheme); + setThemeState(previousTheme); if (previousResolved) { setResolvedTheme(previousResolved); - if (typeof globalThis !== "undefined" && globalThis.window) { - applyThemeToDocument(previousResolved); - globalThis.document.documentElement.classList.remove("light", "dark"); - globalThis.document.documentElement.classList.add(previousResolved); - - const metaThemeColor = globalThis.document.querySelector('meta[name="theme-color"]'); - if (metaThemeColor) { - metaThemeColor.setAttribute("content", previousResolved === "dark" ? "#0A0A0A" : "#FFFFFF"); - } - } + applyThemeToDocument(previousResolved); } - const errorMessage = err instanceof Error ? err.message : "Failed to set theme"; setError(errorMessage); console.error("Theme setting error:", err); } - }, [storageKey, theme, resolvedTheme, defaultTheme]); + }, [storageKey, defaultTheme]); const toggleTheme = useCallback(() => { const themes: ThemeMode[] = ["light", "dark", "system"]; - const currentIndex = theme ? themes.indexOf(theme) : 0; + const currentIndex = themeRef.current ? themes.indexOf(themeRef.current) : 0; const nextIndex = (currentIndex + 1) % themes.length; setTheme(themes[nextIndex]); - }, [theme, setTheme]); + }, [setTheme]); + + const applyTheme = useCallback((mode: ThemeMode) => { + try { + const resolved = resolveTheme(mode, forcedTheme); + setResolvedTheme(resolved); + applyThemeToDocument(resolved); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to apply theme"; + setError(errorMessage); + console.error("Theme apply error:", err); + } + }, [forcedTheme]); useEffect(() => { setIsMounted(true); - - const storedTheme = typeof globalThis !== "undefined" && globalThis.localStorage + + const storedTheme = typeof globalThis !== "undefined" && globalThis.localStorage ? (globalThis.localStorage.getItem(storageKey) as ThemeMode | null) : null; const initialTheme = storedTheme || defaultTheme; - + setThemeState(initialTheme); - - const updateResolvedTheme = () => { - try { - const resolved = resolveTheme(initialTheme, forcedTheme); - - setResolvedTheme(resolved); - applyThemeToDocument(resolved); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to initialize theme"; - setError(errorMessage); - console.error("Theme initialization error:", err); - } finally { - setIsLoading(false); + applyTheme(initialTheme); + + setIsLoading(false); + }, [storageKey, defaultTheme, applyTheme]); + + useEffect(() => { + if (!enableSystem || forcedTheme || !isMounted) return; + + const mediaQuery = globalThis.window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = () => { + if (themeRef.current === "system") { + applyTheme("system"); } }; - updateResolvedTheme(); - - if (enableSystem && !forcedTheme) { - const mediaQuery = globalThis.window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - if (theme === "system") { - updateResolvedTheme(); - } - }; - - mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); - } - }, [storageKey, defaultTheme, enableSystem, forcedTheme, theme]); + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [enableSystem, forcedTheme, isMounted, applyTheme]); const value: ThemeContextType = useMemo(() => ({ theme, diff --git a/frontend/src/lib/theme-engine.test.tsx b/frontend/src/lib/theme-engine.test.tsx index 3efa761..97eeaa8 100644 --- a/frontend/src/lib/theme-engine.test.tsx +++ b/frontend/src/lib/theme-engine.test.tsx @@ -135,4 +135,40 @@ describe("Dark Mode Theme Engine (#800)", () => { fireEvent.click(screen.getByText("toggle")); await waitFor(() => expect(screen.getByTestId("theme")).toHaveTextContent("system")); }); + + describe("Optimistic updates", () => { + it("applies optimistic UI update before persistence completes", () => { + renderEngine(); + fireEvent.click(screen.getByText("set-dark")); + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + expect(screen.getByTestId("resolved")).toHaveTextContent("dark"); + }); + + it("rolls back to previous theme when storage fails", async () => { + const orig = Storage.prototype.setItem; + Storage.prototype.setItem = vi.fn(() => { throw new Error("Storage error"); }); + + renderEngine(); + await waitFor(() => expect(screen.getByTestId("mounted")).toHaveTextContent("true")); + + fireEvent.click(screen.getByText("set-dark")); + + await waitFor(() => { + expect(screen.getByTestId("theme")).toHaveTextContent("system"); + }); + + Storage.prototype.setItem = orig; + }); + + it("persists theme on successful storage", async () => { + renderEngine(); + await waitFor(() => expect(screen.getByTestId("mounted")).toHaveTextContent("true")); + + fireEvent.click(screen.getByText("set-dark")); + + await waitFor(() => { + expect(localStorage.getItem("merchant-theme-preference")).toBe("dark"); + }); + }); + }); });