Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/show-empty-related-plural-base-key-3863.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
'@object-ui/i18n': patch
'@object-ui/plugin-detail': patch
---

`detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often

This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.

The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.

The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.

Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
118 changes: 118 additions & 0 deletions packages/i18n/src/__tests__/all-locales-key-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,38 @@
* That gate skips any key a pack does not define — including the four
* `OUTBOUND_KEYS` below — precisely because their key sets are this file's
* business, so the two cannot contradict each other on the same fact.
*
* ## The second invariant here: a plural family carries a base key (objectui#3863)
*
* Parity across packs is necessary and NOT sufficient, and `detail.showEmptyRelated`
* was the proof: ten packs, identical key sets, `_one` and `_other` in every one of
* them — full parity, green — and `ru` still rendered ENGLISH at counts 2-20 and `ar`
* at 2-99. The mechanism is one level below key sets. i18next asks
* `Intl.PluralRules` for the ONE suffix a language needs for that number and, when
* the pack has no such slot, walks `fallbackLng` to `en`. `ru` has four categories
* (`one/few/many/other`) and `ar` six (`+ zero/two`); no pack in this repo defines
* `_few`/`_many`/`_two`/`_zero`, so those categories resolved nothing locally.
*
* Enumerating the missing slots per language is the fix that CANNOT be taken here:
* giving `ru` a `_few` would be a key `en` lacks, which the parity assertions above
* fail by design. The fix that composes with parity is the BASE key (no suffix) —
* always in i18next's lookup chain, so every category a pack did not enumerate lands
* on it, in the pack's own language, and the key set stays identical across ten packs.
*
* So this file owns the rule "a plural family must carry a base key" for a measured
* reason rather than by convenience — the two candidate homes were compared:
*
* - `scripts/check-i18n-call-site-keys.mjs` reads exactly ONE pack
* (`collectEnKeys`, `packages/i18n/src/locales/en.ts`). Slot coverage is a
* per-pack fact about `ru` and `ar`; an `en`-only instrument cannot state it, and
* it only sees families reached from a statically parsable `t()` literal — a
* family added to the packs before its call site lands (the objectui#3546
* transition, which ran for months) would be invisible. Tightening its
* `resolvesLeaf` would also make it report a complete-but-baseless family as
* `missing-key`, whose remediation text reads "The key exists in no locale pack" —
* false for a family nine packs define.
* - Here, the rule is pack-intrinsic: it walks all ten packs' own key sets, needs no
* call site to exist, and fails in `pnpm test` at PR time.
*/
import { describe, it, expect } from 'vitest';
import { builtInLocales } from '../locales';
Expand DownExpand Up@@ -141,3 +173,89 @@ describe('all locale packs are at full key parity with en (objectui#2872)', () =
expect(mismatches).toEqual([]);
});
});

/**
* i18next's plural suffixes, CLDR order. Deliberately the same list as
* `scripts/check-i18n-call-site-keys.mjs`'s `PLURAL_SUFFIXES`, and asserted equal to
* `Intl.PluralRules`' own vocabulary below so the two cannot drift apart silently.
*/
const PLURAL_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other'] as const;

const ALL_LOCALES = Object.keys(builtInLocales) as LocaleCode[];

/** Every leaf path of a pack — no `OUTBOUND_KEYS` subtraction: a base key must be a
* real leaf of the SAME pack, whatever the parity exemptions are. */
const leavesOf = (pack: unknown) => new Set(keyPaths(pack));

/**
* The plural families of one pack: base path → the suffixes it defines.
* A leaf whose name merely ends in one of the suffixes IS a family member — that is
* exactly how i18next reads it, so a key accidentally named `foo_one` is a real
* defect here and not a false positive.
*/
function familiesOf(pack: unknown): Map<string, string[]> {
const families = new Map<string, string[]>();
for (const path of leavesOf(pack)) {
const suffix = PLURAL_SUFFIXES.find((s) => path.endsWith(s) && path.length > s.length);
if (suffix === undefined) continue;
const base = path.slice(0, -suffix.length);
families.set(base, [...(families.get(base) ?? []), suffix]);
}
return families;
}

describe('every plural family carries a base key (objectui#3863)', () => {
it('the walk finds the families it is meant to judge — not an empty assertion', () => {
// Without this, deleting every plural family (or breaking `familiesOf`) would
// make the rule below trivially green. The count is `en`'s and parity carries it
// to the other nine; it is a floor, not a pin, so a new family does not have to
// edit this line — only a family DISAPPEARING has to be explained.
expect(familiesOf(builtInLocales.en).size).toBeGreaterThanOrEqual(5);
expect(ALL_LOCALES).toHaveLength(10);
// The suffix list is i18next's, which takes it from `Intl.PluralRules`. Compared
// as sets against the union of all ten packs' languages so a CLDR category this
// repo can actually meet cannot be missing from the list above.
const categories = new Set(
ALL_LOCALES.flatMap((l) => new Intl.PluralRules(l).resolvedOptions().pluralCategories),
);
expect([...categories].map((c) => `_${c}`).sort()).toEqual([...PLURAL_SUFFIXES].sort());
});

it.each(ALL_LOCALES)('%s defines the base key of every plural family it has', (lang) => {
// THE rule. i18next resolves `key_<category>` for the one category the number
// needs; the base key is the only slot that answers for every category the pack
// did not spell out, and it answers IN THIS PACK instead of falling through
// `fallbackLng` to English. A family without it leaks English at exactly the
// counts its language meets first (objectui#3863: `ru` 2-20, `ar` 2-99).
const leaves = leavesOf(builtInLocales[lang]);
const baseless = [...familiesOf(builtInLocales[lang])]
.filter(([base]) => !leaves.has(base))
.map(([base, suffixes]) => `${base} [${suffixes.sort().join(',')}] has no base key`)
.sort();
expect(baseless, `${lang}: ${baseless.length} plural family/families with no base key`).toEqual(
[],
);
});

it('the rule bites — five of the ten packs have categories that only a base key can serve', () => {
// Why the rule is not cosmetic, stated as data rather than prose. `en`/`de` and
// `zh`/`ja`/`ko` genuinely cannot reach the base key (their whole category set is
// covered by `_one`/`_other`), so for them it is parity ballast; for the other
// six it is the slot a real user hits.
const reachable = ALL_LOCALES.filter((l) =>
new Intl.PluralRules(l)
.resolvedOptions()
.pluralCategories.some((c) => c !== 'one' && c !== 'other'),
);
expect(reachable.sort()).toEqual(['ar', 'es', 'fr', 'pt', 'ru']);
// …and `ru`/`ar` reach it at everyday counts, which is what makes this a
// user-visible defect rather than a theoretical one: `fr`/`es`/`pt` only use
// `many` from a million up.
expect(new Intl.PluralRules('ru').select(3)).toBe('few');
expect(new Intl.PluralRules('ru').select(7)).toBe('many');
expect(new Intl.PluralRules('ar').select(2)).toBe('two');
expect(new Intl.PluralRules('ar').select(30)).toBe('many');
expect(new Intl.PluralRules('fr').select(100)).toBe('other');
expect(new Intl.PluralRules('fr').select(1_000_000)).toBe('many');
});
});
Loading
Loading