Skip to content

getStringsForLocale matches language before script — sr-Latn-RS resolves to sr (Cyrillic) instead of sr-Latn #10255

Description

@stas-m2muchcoffee

Provide a general summary of the issue here

React Aria's LocalizedStringDictionary.getStringsForLocale resolves an locale by language before considering its script subtag. As a result, sr-Latn-RS (Serbian in Latin script) matches the Cyrillic sr entry before the Latin sr-Latn one, so DateField/DatePicker segment placeholders render in Cyrillic for Serbian (Latin) users.

🤔 Expected Behavior?

For a locale that carries an explicit script subtag, LocalizedStringDictionary.getStringsForLocale should prefer a language-script entry over the bare language entry.

For sr-Latn-RS (Serbian, Latin script), given a dictionary that has both sr and sr-Latn, it should resolve to sr-Latn (Latin).

😯 Current Behavior

It resolves to sr (Cyrillic). The resolver checks the language-only match (step 2) before the regional/sibling scan (step 3), so for sr-Latn-RS the language sr matches first and the Latin sr-Latn entry is never reached.

Real-world impact: React Aria DateField/DatePicker segment placeholders render in Cyrillic (гггг/мм/дд) for sr-Latn-RS users, even though the sr-Latn placeholder added.

💁 Possible Solution

Add a language-script lookup before the language-only fallback in getStringsForLocale, plus a getScript helper that mirrors the existing getLanguage (packages/@internationalized/string/src/LocalizedStringDictionary.ts):

function getStringsForLocale<K extends string, T extends LocalizedString>(locale: string, strings: LocalizedStrings<K, T>, defaultLocale = 'en-US') {
  // If there is an exact match, use it.
  if (strings[locale]) {
    return strings[locale];
  }

  let language = getLanguage(locale);

  // If there is no exact match, try a language + script match before falling back to the
  // language alone. For example, sr-Latn-RS (Serbian in Latin script) should match sr-Latn
  // rather than sr, which defaults to Cyrillic.
  let script = getScript(locale);
  if (script && strings[`${language}-${script}`]) {
    return strings[`${language}-${script}`];
  }

  // Attempt to find the closest match by language.
  if (strings[language]) {
    return strings[language];
  }

  for (let key in strings) {
    if (key.startsWith(language + '-')) {
      return strings[key];
    }
  }

  // Nothing close, use english.
  return strings[defaultLocale];
}

function getScript(locale: string): string | undefined {
  // @ts-ignore
  if (Intl.Locale) {
    // @ts-ignore
    return new Intl.Locale(locale).script;
  }

  return undefined;
}

This only fires when the locale has an explicit script and a language-script key exists, so it changes resolution for Latin-script Serbian only and is a no-op for every other locale (the existing regional sibling fallback, e.g. de-ATde-DE, is preserved).

🔦 Context

We use 20+ locales, including sr-Latn-RS (Serbia, Latin script). Date fields showed Cyrillic placeholders while the rest of the UI is Latin.

🖥️ Steps to Reproduce

Minimal, isolated to @internationalized/string:

import {LocalizedStringDictionary} from '@internationalized/string';

const dict = new LocalizedStringDictionary({
  sr: {year: 'гггг'},        // Cyrillic
  'sr-Latn': {year: 'gggg'}  // Latin
});

dict.getStringForLocale('year', 'sr-Latn-RS');
// → 'гггг'  (Cyrillic)   ❌  expected 'gggg' (Latin)
dict.getStringForLocale('year', 'sr-Latn');
// → 'gggg'  (works, because it's an exact match)

In React Aria this surfaces as a DateField with locale="sr-Latn-RS" showing Cyrillic segment placeholders.

Version

@internationalized/string@3.2.9, @react-stately/datepicker@3.17.1 (also reproduces on older 3.2.x / 3.12.x).

What browsers are you seeing the problem on?

Chrome

If other, please specify.

No response

What operating system are you using?

N/A

🧢 Your Company/Team

No response

🕷 Tracking Issue

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions