From 80bbf2ba3b435a6c2baba6333cf3da87acf49b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:48:24 +0200 Subject: [PATCH] perf(ui): harden theme-provider hydration seam + cache per-row Intl formatters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../dashboard/recent-workouts-tile.tsx | 3 +- src/components/insights/workout-detail.tsx | 9 ++-- src/components/insights/workout-list.tsx | 7 +-- .../medications/medication-card.tsx | 3 +- src/components/providers.tsx | 47 ++++++++++-------- .../intl/__tests__/formatter-cache.test.ts | 40 +++++++++++++++ src/lib/intl/formatter-cache.ts | Bin 0 -> 1614 bytes 7 files changed, 80 insertions(+), 29 deletions(-) create mode 100644 src/lib/intl/__tests__/formatter-cache.test.ts create mode 100644 src/lib/intl/formatter-cache.ts diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index a4557b5c0..2368e1962 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -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 — ``. @@ -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", diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx index 4087e4bd3..6ca8783df 100644 --- a/src/components/insights/workout-detail.tsx +++ b/src/components/insights/workout-detail.tsx @@ -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), diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 3f17d9fb5..712aaf77b 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -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", diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 7f0084300..8c52a5091 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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", diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 10ec7fae9..db1f37cd7 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -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(() => { - 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 pre-hydration, so there is no visual FOUC from deferring + // the reconcile to an effect. + const [theme, setThemeState] = useState("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 diff --git a/src/lib/intl/__tests__/formatter-cache.test.ts b/src/lib/intl/__tests__/formatter-cache.test.ts new file mode 100644 index 000000000..e55e3b643 --- /dev/null +++ b/src/lib/intl/__tests__/formatter-cache.test.ts @@ -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"); + }); +}); diff --git a/src/lib/intl/formatter-cache.ts b/src/lib/intl/formatter-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc624f4584445c39ac68b00c497a9de95ffe3767 GIT binary patch literal 1614 zcmb`G!EVz)5Qcm9Qw&uPc5u>rL%D#E0MR1R2k>S+ZkF1+*6z9?sv_|;JQz>HziT@r zxJ5DFoX=(n&FHf=lAW7xRLLZTTr*Y1Qt@8hx4GKwDUqkD5pE#a!IQ#)kcb%=_Q%gE2G^47sK5*c=xuGUg=vBdJvrzAusZH zwxk?M)3m}0ukN|t5`Sx1JG?K+@jY8n2c=p$KUba`T65j9b+qbK?a62+e3CVr{>i$; zXIgBGy)&Jkhrl8a5`|^S+RukSZthjhMNUP-rIJW*^jr#C;^^o1AK8L#*6f&WIn?RM zvhS=W(UU8K;)sHrArG2z->%X4a6WX`3RR*^+1FX39HphFv}cx&$g&{l)}rMo%K|~w z?>O+S7u4Gj%MlhAo2K((70JUC&o%7LQO%%bBX)9l;Lg5UA+2%14oMd(bfV(Xv5%vr z#op7?lT$eR;mh^a0%k%>RoiqhIxFDPco?z)Lr9*Y$!!kTP^+l3u}LzkuZ-q{VRk*jpudsz#k8*LemnCdd*mlQAhz!rdrg;AoIUh2 zusb<>c*6c&u?#i9fU5C``IFWH6{nB`4_(3}vQY)6QvsXVL?@_EpT uId-@=Y$}eH$u4;e;xX&zU_6ePfIR;FUr?5l$@v$U#|1rs^N|$K0Qw8m$t#Bd literal 0 HcmV?d00001