Skip to content

fix(scout-report): locale-lock thousands formatting to prevent hydration mismatch#76

Open
rgarciar1931 wants to merge 1 commit into
Younesfdj:masterfrom
rgarciar1931:fix/scout-report-hydration-thousands-locale
Open

fix(scout-report): locale-lock thousands formatting to prevent hydration mismatch#76
rgarciar1931 wants to merge 1 commit into
Younesfdj:masterfrom
rgarciar1931:fix/scout-report-hydration-thousands-locale

Conversation

@rgarciar1931

Copy link
Copy Markdown

Summary

Locale-lock the tipText thousands-grouping formatter in lib/format.ts to en-US so SSR (Node) and CSR (browser) always serialize identical bytes for the same number — eliminating React's Hydration failed because the server rendered text didn't match the client warning on real-scout pages under non-en-US browser locales once values cross the thousands threshold.

Problem

components/DistributionPanel.tsx builds a <Tip text={tipText}> with four unlocaled .toLocaleString() calls:

"Higher than ${(DIST_N - all.atOrAbove).toLocaleString()} of ${DIST_N.toLocaleString()} randomly sampled GitHub users, and ${(DIST_ACTIVE_N - act.atOrAbove).toLocaleString()} of ${DIST_ACTIVE_N.toLocaleString()} who were active in the past year."

On Node SSR, Intl defaults to en-US ("5,000"). On a Spanish-locale browser the same call returns "5.000". Both sides are "correct" in their own locale — but React compares the rendered text node byte-for-byte at hydration, and only one matches. When the displayed numbers cross the 1000-threshold (DIST_N = 5000, DIST_ACTIVE_N = 2500), the mismatch is unavoidable unless the helper is locale-locked.

This affected every real-scout page viewed under es-ES, de-DE, fr-FR, etc. Tokenless sample pages (Linus Torvalds, ThePrimeagen, t3dotgg) didn't trigger it because their values stay below 1000 in the rendered copy; the bug only surfaced for our own scout flow once a hosted GitHub account was added.

What changed

  • lib/format.ts — added formatThousands(n: number): string => n.toLocaleString("en-US"). Co-located next to formatCount / round2 / round1, with a comment that names the SSR/CSR parity invariant. Upstream's formatCount rewrite (the compact helper + millions tier) is preserved verbatim and runs first; our locale-lock helper sits below.
  • components/DistributionPanel.tsx — replaced all four .toLocaleString() calls in tipText with formatThousands(...). A short comment near the call site notes the hydrate-at-risk consequence of unlocaled grouping, so a future reader doesn't reach for .toLocaleString() again in this neighborhood.
  • tests/format.test.ts (new) — 4 cases pinning en-US comma grouping:
    • single-digit numbers (0, 7) rendered verbatim,
    • three-digit numbers without grouping,
    • thousands-with-comma grouping (1000"1,000", 1234"1,234", 12345"12,345", 1234567"1,234,567"),
    • negative-number consistency (-1234"-1,234").

Why this fix is minimal and centered on the helper

formatCount was already locale-independent (toFixed + "k" / "M"), so the rest of the card didn't drift. The new helper covers the only remaining locale-dependent call site in the React tree, and the codebase has no other .toLocaleString() calls without an explicit locale argument. (Verified: only components/DistributionPanel.tsx had unlocaled calls; components/ScoutForm.tsx:134 already passes "en-US"; formatCount uses toFixed.)

Validation

  • npm run lint — clean.
  • npm test — all tests pass on fix/scout-report-hid... tip 62480e4. Test count grew from 165 → 241 after merging upstream's tests/format.test.ts additions in the rebase.

Error screenshot

image

…ion mismatch

DistributionPanel.tsx renders a <Tip text={tipText}> whose 4 unlocaled .toLocaleString() calls (DIST_N = 5000 and friends) produced locale-dependent output ("5,000" en-US vs "5.000" es-ES). Any /<username>?country=<locale> 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.

@eeshsaxena eeshsaxena left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks correct to me. I checked the mechanism: (5000).toLocaleString() with no argument uses the runtime's default locale, so Node's SSR output and a non-en-US browser disagree (5,000 vs 5.000/5000) - which is exactly what trips React's hydration check. Pinning to en-US makes both sides deterministic.

I also looked for other call sites that could hit the same thing: the only other toLocaleString in the app is components/ScoutForm.tsx:134, and it already passes "en-US". So after this the app is consistent, and formatThousands is a good single home for that guarantee rather than repeating the locale string.

Tests cover the grouping. Deferring to the maintainer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants