Checkout on a Centra storefront needs a country dropdown with about two hundred localized names, sorted for the active locale. The ISO code is the value. The label is what humans read. CreateAccountForm was building those options with client-side Intl.DisplayNames inside the component. Playwright filled firstName, then the field emptied. Labels arrived a beat later, options re-sorted, and the DOM under the form moved while the test was still typing.
I was not just reordering text. I was changing the DOM tree under a form that Playwright already started filling. Roughly two hundred lookups, sorted in the browser, looks 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. The test flaked. I flaked with it.
Client Intl fights the fill
Phone dial codes use the same ISO value. Only the visible label should localize. Building labels in useMemo on the client while E2E assumes a stable DOM is how I got green locally and red in the pipeline.
I slept Playwright after firstName. The sort still raced the labels. Shared helpers now wait for [name="country"] option count, then selectOption({ value: 'NL' }). The value is the ISO code. It does not move when the label localizes. The Dutch label is not a stable selector.
That wait fixed the flake without fixing the shopper experience. A human typing fast on a slow phone could still feel the list jump. I wanted the options stable before anyone touched the field.
Resolve when locale is known
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.
In the demo, switch client rebuild after mount versus server-resolved labels. Under client rebuild, option order shifts after hydration (watch the selected value flicker). Under server resolution, the option list is stable on first paint.
Compare
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.
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;
}firstName waits on the input, not on a label sort finishing. Country waits on option count, then picks NL by value. Those are different contracts. I am not coupling them with one big waitForTimeout around the form.
Fallback is part of the contract
Intl.DisplayNames can throw. The empty catch is deliberate. isUsableIntlCountryLabel can reject a localized string that is no better than the code. Centra's English name is the last stop. Message override sits first so a dictionary string can win.
Server-side resolution does not invent translations. A missing override plus a rejected Intl label plus a Centra fallback still shows the fallback. The second render pass is gone. Two hundred countries still only localize if override, Intl, or the Centra name is usable.
Phone dial codes stay on the same ISO value. Localize the value and you break the dial code table and the Playwright selector in one move. Labels sort. Codes stay.
The form has to sit still while someone types, including Playwright. I got that by resolving labels before paint.
Happy coding! Sander