[progress][meter] SSR hydration mismatch on `aria-valuetext` due to runtime-locale `Intl.NumberFormat`
- Dominant language
- TypeScript
- Stars
- 10.9k
- Forks
- 543
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 101
Description
# Bug report
## Current behavior
`Progress.Root` and `Meter.Root` generate `aria-valuetext` by calling `Intl.NumberFormat(locale, { style: 'percent' })` in `formatNumberValue`. When the `locale` prop is not passed, the formatter defaults to the runtime locale — which differs between the SSR environment and the client browser. React then reports a hydration mismatch:
```
Warning: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
```
The server (Bun/Node) renders `"30%"` while a browser with a locale that uses NBSP before `%` (e.g. `fr-FR`, `nl-NL`) renders `"30 %"`. The two inputs to `Intl.NumberFormat` are identical — only the default runtime locale differs.
Source: `packages/react/src/utils/formatNumber.ts` — `formatNumberValue(value, locale, format)` falls through to `Intl.NumberFormat(undefined, { style: 'percent' })` when no `locale` or `format` is provided.
The same `formattedValue` is also exposed via `ProgressRootContext` / `MeterRootContext` to `Progress.Value` / `Meter.Value`, so the visible text can mismatch too when those subcomponents are used.
## Expected behavior
`Progress.Root` and `Meter.Root` should render the same `aria-valuetext` on the server and the client out of the box — either by producing a locale-independent default, or by deferring locale-sensitive formatting until after hydration.
## Reproducible example
Minimal repro (any SSR framework — reproduced against TanStack Start on Bun, same outcome on Next.js App Router):
```tsx
import { Progress } from '@base-ui/react/progress'
export default function Page() {
return (
)
}
```
Trigger by running the server with a locale that formats percents without NBSP (e.g. `LANG=C` or `en-US`) and opening the page in a browser with one that does (e.g. `fr-FR`, `nl-NL`, `de-DE`). Console shows the hydration mismatch on `aria-valuetext`.
## Base UI version
v1.3.0
## Which browser are you using?
Chrome (reproduces on any browser whose locale differs from the server's default locale)
## Which OS are you using?
macOS (server); any OS on the client
## Which assistive tech are you using (if applicable)?
N/A — bug surfaces as a React hydration warning; screen reader impact is a brief mismatch until React discards the server tree.
## Additional context
**Root cause** — `packages/react/src/utils/formatNumber.ts`:
```ts
export function formatNumberValue(value, locale, format) {
if (value == null) return ''
if (!format) {
return formatNumber(value / 100, locale, { style: 'percent' })
}
return formatNumber(value, locale, format)
}
```
When neither `locale` nor `format` is passed by the consumer, `Intl.NumberFormat` uses the runtime default locale. This is non-deterministic across SSR boundaries.
`Slider` likely has the same problem via the same utility, though I haven't reproduced it — flagging for triage.
**Possible fixes** (happy to send a PR once the preferred direction is decided):
1. **Locale-independent default** — when `locale` and `format` are both unset, skip `Intl.NumberFormat` and let the existing `getDefaultAriaValueText` fallback (`${value}%`) kick in. Breaking for consumers relying on localized `Progress.Value` output without passing `locale`.
2. **Mount-gated formatting** — render a deterministic fallback on the first render, swap in the localized value after mount (`useEffect`). Non-breaking, one extra render.
3. **`suppressHydrationWarning`** on the root — matches the approach in #4482. Silences the warning but screen readers may still read a mismatched value before hydration completes.
**Consumer workaround** — pass an explicit `locale` to `Progress.Root` / `Meter.Root`:
```tsx
```
This works because both server and client now use the same formatter input.
Contributor guide
Assessment
This issue has not been assessed yet.