Server-side country labels fix E2E fills

Client Intl.DisplayNames for ~200 countries slowed hydration. Playwright filled firstName, then the field emptied. Precompute labels server-side.

Sander Korf2 min read
intlnextjsplaywright

Checkout country dropdowns look trivial until you need two hundred localized names sorted for the active locale. The ISO code is the value. The label is what humans read. Swap labels after hydration and you are not just reordering text. You are changing the DOM tree under a form that Playwright already started filling.

I had CreateAccountForm building options with client-side Intl.DisplayNames inside the component. Roughly two hundred lookups. Sorted in the browser. Fine on a fast Mac. Less fine in CI when react-hook-form has not attached yet and the register helper types firstName on faith. Labels arrived a beat later. Options re-sorted. The field emptied mid-fill. The test flaked. I flaked with it.

Phone dial codes use the same ISO value. Only the visible label should localize. Mixing "build labels in useMemo on the client" with "E2E assumes stable DOM" is how you get green locally and red in the pipeline.

The shop layer now resolves labels when locale is known, before the form renders. Message override first, then Intl.DisplayNames, then Centra's English fallback. Forms receive { code, name } with name already sorted. No post-hydration map rebuild. Playwright waits for country options, then selects by value, not visible text that might change.

Country labels stable on first paint

Fake checkout form. Server mode ships sorted Dutch labels from the shop layer. Client mode rebuilds ~200 Intl names after hydration and clears a half-filled first name.
precomputed labelslabels readyvalue stays ISO code

Idle. Type a first name, then watch what happens on label swap.

export function resolveCountryLabel(
	countryCode: string,
	fallbackName: string,
	locale: Locale,
	translateCountry?: CountryLabelTranslator
): string {
	const normalizedCode = countryCode.trim().toUpperCase();
	const messageKey = `countries.${normalizedCode}`;
	if (translateCountry?.has(messageKey)) return translateCountry(messageKey);
	try {
		const localized = getCountryDisplayNames(locale).of(normalizedCode);
		if (localized && isUsableIntlCountryLabel(localized, normalizedCode)) {
			return localized;
		}
	} catch {}
	return fallbackName;
}

Shared E2E helpers now wait for [name="country"] option count, then selectOption({ value: 'NL' }). firstName waits on the input, not on a label sort finishing. Server-side resolution is not micro-optimization. It is contract work: stable DOM for humans, stable selectors for tests, localized copy without a second render pass.


Happy coding! Sander