Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/ui/animated-number.test.tsx
Original file line number Diff line number Diff line change
@@ -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());
});
12 changes: 6 additions & 6 deletions src/ui/animated-number.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions src/ui/theme-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof hydrateRoot> | undefined;
act(() => {
root = hydrateRoot(container, tree, {
onRecoverableError: (error) => errors.push(error),
});
});

expect(errors).toEqual([]);
expect(container.textContent).toBe("dark/dark");
act(() => root?.unmount());
});
22 changes: 5 additions & 17 deletions src/ui/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,6 @@ export type ThemeContextValue = {

const ThemeContext = createContext<ThemeContextValue | null>(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,
Expand Down Expand Up @@ -111,14 +101,12 @@ export function ThemeProvider({
[defaultMode, defaultPreset],
);

const [preference, setPreference] = useState<ThemePreference>(() =>
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<ThemePreference>(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]);
Expand Down
Loading