- {/* 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 && (
-
+
Updated
-
+
)}
-
+
);
}
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");
+ });
+ });
+ });
});