From ccad4c85e93860064403cd7185153d17b1735384 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 26 Sep 2026 17:33:18 -0700 Subject: [PATCH] fix: animated-number restarts from the shown value; theme hydrates cleanly --- src/ui/animated-number.test.tsx | 56 +++++++++++++++++++++++++++++++++ src/ui/animated-number.tsx | 12 +++---- src/ui/theme-provider.test.tsx | 40 +++++++++++++++++++++++ src/ui/theme-provider.tsx | 22 +++---------- 4 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 src/ui/animated-number.test.tsx create mode 100644 src/ui/theme-provider.test.tsx diff --git a/src/ui/animated-number.test.tsx b/src/ui/animated-number.test.tsx new file mode 100644 index 00000000..ed3b6546 --- /dev/null +++ b/src/ui/animated-number.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; + +import { AnimatedNumber } from "./animated-number.js"; + +let now = 0; +let frames: FrameRequestCallback[] = []; +const original = { + now: performance.now.bind(performance), + raf: globalThis.requestAnimationFrame, + caf: globalThis.cancelAnimationFrame, +}; + +beforeEach(() => { + now = 0; + frames = []; + performance.now = () => now; + globalThis.requestAnimationFrame = (callback) => frames.push(callback); + globalThis.cancelAnimationFrame = () => { + frames = []; + }; +}); + +afterEach(() => { + performance.now = original.now; + globalThis.requestAnimationFrame = original.raf; + globalThis.cancelAnimationFrame = original.caf; +}); + +function tick(ms: number) { + now += ms; + const pending = frames; + frames = []; + act(() => pending.forEach((callback) => callback(now))); +} + +test("a new value mid-count continues from the displayed number", () => { + const container = document.createElement("div"); + const root = createRoot(container); + const shown = () => container.querySelector("[aria-hidden]")?.textContent; + + act(() => root.render(createElement(AnimatedNumber, { value: 0 }))); + act(() => root.render(createElement(AnimatedNumber, { value: 100 }))); + tick(300); + const midway = Number(shown()); + expect(midway).toBeGreaterThan(0); + expect(midway).toBeLessThan(100); + + act(() => root.render(createElement(AnimatedNumber, { value: 200 }))); + tick(1); + expect(Math.abs(Number(shown()) - midway)).toBeLessThan(2); + tick(600); + expect(shown()).toBe("200"); + act(() => root.unmount()); +}); diff --git a/src/ui/animated-number.tsx b/src/ui/animated-number.tsx index 57cc0c92..d4ecdfac 100644 --- a/src/ui/animated-number.tsx +++ b/src/ui/animated-number.tsx @@ -55,12 +55,12 @@ export function AnimatedNumber({ const start = performance.now(); const step = (now: number) => { const t = Math.min((now - start) / durationMs, 1); - setDisplay(from + (value - from) * easeOut(t)); - if (t < 1) { - frameRef.current = requestAnimationFrame(step); - } else { - fromRef.current = value; - } + const current = from + (value - from) * easeOut(t); + // A value change mid-count continues from what is on screen, not from + // the previous target. + fromRef.current = current; + setDisplay(current); + if (t < 1) frameRef.current = requestAnimationFrame(step); }; frameRef.current = requestAnimationFrame(step); return () => cancelAnimationFrame(frameRef.current); diff --git a/src/ui/theme-provider.test.tsx b/src/ui/theme-provider.test.tsx new file mode 100644 index 00000000..2cdd302b --- /dev/null +++ b/src/ui/theme-provider.test.tsx @@ -0,0 +1,40 @@ +import { afterEach, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; + +import { + DEFAULT_THEME_STORAGE_KEY, + serializeThemePreference, +} from "../lib/theme.js"; +import { ThemeProvider, useTheme } from "./theme-provider.js"; + +afterEach(() => localStorage.clear()); + +function Mode() { + const { mode, resolvedMode } = useTheme(); + return createElement("span", null, `${mode}/${resolvedMode}`); +} + +test("hydrates from the server markup, then applies the stored preference", () => { + const tree = createElement(ThemeProvider, null, createElement(Mode)); + const container = document.createElement("div"); + container.innerHTML = renderToString(tree); + expect(container.textContent).toBe("system/light"); + + localStorage.setItem( + DEFAULT_THEME_STORAGE_KEY, + serializeThemePreference({ mode: "dark", preset: "default" }), + ); + const errors: unknown[] = []; + let root: ReturnType | undefined; + act(() => { + root = hydrateRoot(container, tree, { + onRecoverableError: (error) => errors.push(error), + }); + }); + + expect(errors).toEqual([]); + expect(container.textContent).toBe("dark/dark"); + act(() => root?.unmount()); +}); diff --git a/src/ui/theme-provider.tsx b/src/ui/theme-provider.tsx index e2f2973c..735deb5e 100644 --- a/src/ui/theme-provider.tsx +++ b/src/ui/theme-provider.tsx @@ -34,16 +34,6 @@ export type ThemeContextValue = { const ThemeContext = createContext(null); -function readSystemPrefersDark(): boolean { - if ( - typeof window === "undefined" || - typeof window.matchMedia !== "function" - ) { - return false; - } - return window.matchMedia("(prefers-color-scheme: dark)").matches; -} - function readStoredPreference( storageKey: string, fallback: ThemePreference, @@ -111,14 +101,12 @@ export function ThemeProvider({ [defaultMode, defaultPreset], ); - const [preference, setPreference] = useState(() => - readStoredPreference(storageKey, fallback), - ); - const [systemPrefersDark, setSystemPrefersDark] = useState( - readSystemPrefersDark, - ); + // Both start from what the server renders; the effects below read + // localStorage and matchMedia after mount so hydration never mismatches. + const [preference, setPreference] = useState(fallback); + const [systemPrefersDark, setSystemPrefersDark] = useState(false); - // Re-read when the host changes the storage key (e.g. user signs in). + // Re-read on mount and when the host changes the storage key (e.g. user signs in). useEffect(() => { setPreference(readStoredPreference(storageKey, fallback)); }, [storageKey, fallback]);