mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
perf(ui): harden theme-provider hydration seam + cache per-row Intl formatters
Theme provider now initializes to the SSR-stable default and reconciles the stored preference in a mount effect instead of reading localStorage in the useState initializer — closes the same React #418 hydration seam the sidebar already fixed (the inline script still handles the pre-paint class, so no FOUC). Add a process-wide Intl formatter cache and route the per-row workout/medication list helpers through it so a formatter is constructed once per (locale, options) rather than once per row per render.
This commit is contained in:
1 parent
3fcae204f7
commit
80bbf2ba3b
7 files changed
+80
-29
No files matched your search
@@ -15,6 +15,7 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<RecentWorkoutsTile>`.
|
||||
@@ -70,7 +71,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDayLabel(iso: string, locale: string): string {
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
return getDateTimeFormat(locale, {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
@@ -73,7 +74,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
@@ -84,7 +85,7 @@ function formatNumber(
|
||||
locale: string,
|
||||
fractionDigits = 0,
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
@@ -111,13 +112,13 @@ function formatDateRange(
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = new Intl.DateTimeFormat(locale, {
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = new Intl.DateTimeFormat(locale, {
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
@@ -68,14 +69,14 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
function formatEnergy(kcal: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Math.round(kcal));
|
||||
}
|
||||
@@ -86,7 +87,7 @@ function formatDate(
|
||||
timeFormat: TimeFormatPreference,
|
||||
): string {
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
return getDateTimeFormat(locale, {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati
|
||||
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import {
|
||||
@@ -335,7 +336,7 @@ export function MedicationCard({
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
// The actual user-facing display goes through formatTime / formatDateTime.
|
||||
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
const dayFormatter = getDateTimeFormat("en-CA", {
|
||||
timeZone: userTz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
|
||||
@@ -61,29 +61,36 @@ function applyTheme(resolved: "dark" | "light") {
|
||||
document.documentElement.classList.add(resolved);
|
||||
}
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
|
||||
// A stored "light"/"dark"/"system" choice wins. With no stored
|
||||
// preference the app defaults to dark rather than tracking the OS.
|
||||
if (saved === "light" || saved === "dark" || saved === "system") return saved;
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
|
||||
// A stored "light"/"dark"/"system" choice wins. With no stored
|
||||
// preference the app defaults to dark rather than tracking the OS.
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
if (saved === "system") return "system";
|
||||
return "dark";
|
||||
});
|
||||
// Initialize to the SSR-stable default on BOTH the server and the client's
|
||||
// first render so the hydrated React tree matches; the persisted preference
|
||||
// is reconciled in a mount effect below. Reading localStorage in the
|
||||
// `useState` initializer instead diverges server ("dark") from a client that
|
||||
// stored "light"/"system", which is the exact React #418 hydration seam this
|
||||
// repo has hit before (the sidebar matchMedia/localStorage-in-initializer).
|
||||
// The nonce-bound inline script in layout.tsx has already stamped the correct
|
||||
// class on <html> pre-hydration, so there is no visual FOUC from deferring
|
||||
// the reconcile to an effect.
|
||||
const [theme, setThemeState] = useState<Theme>("dark");
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">("dark");
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
if (saved === "system") return getSystemTheme();
|
||||
return "dark";
|
||||
});
|
||||
|
||||
// Apply theme on mount (inline script handles FOUC, this ensures classes are in sync)
|
||||
// Reconcile the stored preference after mount + keep the class in sync (the
|
||||
// inline script handles the pre-paint class; this keeps React state honest).
|
||||
useEffect(() => {
|
||||
applyTheme(resolvedTheme);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const stored = readStoredTheme();
|
||||
const resolved = stored === "system" ? getSystemTheme() : stored;
|
||||
setThemeState(stored);
|
||||
setResolvedTheme(resolved);
|
||||
applyTheme(resolved);
|
||||
}, []);
|
||||
|
||||
// Listen for OS preference changes when in system mode
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getNumberFormat, getDateTimeFormat } from "../formatter-cache";
|
||||
|
||||
describe("formatter-cache", () => {
|
||||
it("returns the same NumberFormat instance for identical (locale, options)", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
const b = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("returns distinct NumberFormat instances for different options", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
const b = getNumberFormat("en", { maximumFractionDigits: 0 });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("returns distinct instances per locale", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 1 });
|
||||
const b = getNumberFormat("de", { maximumFractionDigits: 1 });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("caches DateTimeFormat and still formats correctly", () => {
|
||||
const a = getDateTimeFormat("en-CA", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const b = getDateTimeFormat("en-CA", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
expect(a).toBe(b);
|
||||
expect(a.format(new Date("2026-05-08T12:00:00Z"))).toBe("2026-05-08");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Reference in new issue
Block a user