From 62480e4a509a609673faeb1c3b522606817afac2 Mon Sep 17 00:00:00 2001 From: rgarciar1931 <296995245+rgarciar1931@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:25:58 +0200 Subject: [PATCH] fix(scout-report): locale-lock thousands formatting to prevent hydration mismatch DistributionPanel.tsx renders a whose 4 unlocaled .toLocaleString() calls (DIST_N = 5000 and friends) produced locale-dependent output ("5,000" en-US vs "5.000" es-ES). Any /?country= page viewed under a non-en-US browser locale crossed React's hydration-mismatch warning on first paint once the tooltip crossed the 1000s threshold. Add a locale-locked formatThousands(n: number): string helper in lib/format.ts that pins grouping to en-US so SSR (Node) and CSR (browser) render identical bytes regardless of host locale. --- components/DistributionPanel.tsx | 9 +++++++-- lib/format.ts | 7 +++++++ tests/format.test.ts | 32 +++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/components/DistributionPanel.tsx b/components/DistributionPanel.tsx index 67ecce9..347cb28 100644 --- a/components/DistributionPanel.tsx +++ b/components/DistributionPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { DIST_ACTIVE_COUNTS, DIST_ACTIVE_N, DIST_COUNTS, DIST_MIN, DIST_N } from "@/lib/distribution-data"; +import { formatThousands } from "@/lib/format"; import { resolveResultTheme } from "./finishTheme"; import { Tip } from "./ScoutReport"; import type { Card } from "@/lib/scoring/types"; @@ -35,9 +36,13 @@ export default function DistributionPanel({ card }: { card: Card }) { }; const all = top(DIST_COUNTS, DIST_N); const act = top(DIST_ACTIVE_COUNTS, DIST_ACTIVE_N); + // Numbers in a text node become part of the SSR HTML and are compared + // byte-for-byte at hydration — using formatThousands (locale-locked to en-US) + // prevents "5,000" vs "5.000" mismatches when the user's browser locale isn't + // the Node server's default. const tipText = - `Higher than ${(DIST_N - all.atOrAbove).toLocaleString()} of ${DIST_N.toLocaleString()} randomly sampled GitHub users, ` + - `and ${(DIST_ACTIVE_N - act.atOrAbove).toLocaleString()} of the ${DIST_ACTIVE_N.toLocaleString()} who were active in the past year.`; + `Higher than ${formatThousands(DIST_N - all.atOrAbove)} of ${formatThousands(DIST_N)} randomly sampled GitHub users, ` + + `and ${formatThousands(DIST_ACTIVE_N - act.atOrAbove)} of the ${formatThousands(DIST_ACTIVE_N)} who were active in the past year.`; return (
diff --git a/lib/format.ts b/lib/format.ts index 84b55da..ae53aa5 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -16,6 +16,13 @@ export const formatCount = (n: number): string => { return String(Math.round(n)); }; +// Thousands-grouped number for tooltips and copy (5000 → "5,000"). Locale-locked +// to en-US so Node SSR and the client's browser locale always agree — otherwise +// a 5,000-tip on a Spanish-locale browser renders as "5.000" and React throws a +// hydration mismatch on the very first paint of any scout report that crosses +// the thousands threshold in a text node. +export const formatThousands = (n: number): string => n.toLocaleString("en-US"); + // Fixed-precision rounding for deterministic geometry / imperative transforms — // shared so radar, VS burst and tilt all round identically. export const round2 = (n: number) => Math.round(n * 100) / 100; diff --git a/tests/format.test.ts b/tests/format.test.ts index 973e47e..502135b 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatCount, round1, round2 } from "@/lib/format"; +import { formatCount, formatThousands, round1, round2 } from "@/lib/format"; // formatCount feeds the card, the scout report and the duel bars, so the exact // strings matter: they have to stay short enough for the fixed-width slots and @@ -36,6 +36,36 @@ describe("formatCount", () => { }); }); +// These strings get serialized into a hidden text node on the scout +// report. SSR (Node) and CSR (browser) have to agree byte-for-byte on the very +// first paint, so we lock the locale — without "en-US", a Spanish-locale +// browser hydrates "5.000" against server-side "5,000" and React throws a +// hydration mismatch. formatCount was already locale-independent (toFixed + +// "k"); formatThousands carries the same guarantee, just for the +// un-abbreviated thousands-grouping copy we use in tooltips. +describe("formatThousands", () => { + it("renders single-digit numbers verbatim", () => { + expect(formatThousands(0)).toBe("0"); + expect(formatThousands(7)).toBe("7"); + }); + + it("renders three-digit numbers without grouping", () => { + expect(formatThousands(500)).toBe("500"); + expect(formatThousands(999)).toBe("999"); + }); + + it("groups thousands with en-US commas (locked locale)", () => { + expect(formatThousands(1000)).toBe("1,000"); + expect(formatThousands(1234)).toBe("1,234"); + expect(formatThousands(12345)).toBe("12,345"); + expect(formatThousands(1234567)).toBe("1,234,567"); + }); + + it("handles negative numbers consistently", () => { + expect(formatThousands(-1234)).toBe("-1,234"); + }); +}); + describe("round1 / round2", () => { it("rounds to fixed precision", () => { expect(round2(1.005)).toBe(1);