Skip to content

fix(i18n): give detail.showEmptyRelated a base key so ru and ar stop rendering English (#3863) - #4367

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3863-plural-base-key
Aug 11, 2026
Merged

fix(i18n): give detail.showEmptyRelated a base key so ru and ar stop rendering English (#3863)#4367
yinlianghui merged 1 commit into
mainfrom
claude/issue-3863-plural-base-key

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3863

detail.showEmptyRelated 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, finding no such slot, walks fallbackLng to en — so the collapsed-empties button rendered English at:

languagecountsCLDR category
ru2-4few
ru5-20, 25-30, …many
ar0 / 2 / 3-10 / 11-99zero / two / few / many

The call site is packages/plugin-detail/src/renderers/record-reference-rail.tsx:350, whose count is emptyTitles.length — so 2 to 4 are the most common values it ever takes, and a Russian user essentially always read English.

The fix — a base key, per #3546 slice 6

Enumerating the missing slots is the fix that cannot be taken: giving ru a _few makes it "a key en lacks", which all-locales-key-parity.test.ts fails by design. The base key (no suffix) is always in i18next's lookup chain, so every category a pack did not enumerate lands on it, in that pack's own language, while the ten key sets stay identical. Exactly the shape #3546 slice 6 established for perm.facet.* — that slice filed this family rather than fixing it, and this is that debt.

Measured against the installed i18next (26.3.6, matching the card):

ru _one+_other 0:many=EN 1:one=RU 2:few=EN 3:few=EN 5:many=EN 11:many=EN 21:one=RU 100:many=EN
ru +base 0:many=RU 1:one=RU 2:few=RU 3:few=RU 5:many=RU 11:many=RU 21:one=RU 100:many=RU
ar _one+_other 0:zero=EN 1:one=AR 2:two=EN 3:few=EN 5:few=EN 11:many=EN 21:many=EN 100:other=AR
ar +base 0:zero=AR 1:one=AR 2:two=AR 3:few=AR 5:few=AR 11:many=AR 21:many=AR 100:other=AR

Base-key phrasing, decided per pack by whether the slot is reachable at all:

packscategoriesbase valuewhy
ruone/few/many/other+ Пустых: {{count}}reachable at everyday counts, so it must be count-invariant: the «Существительное: {{count}}» form this pack already writes 22 times (lookup.recordCount, notifications.groupCount)
arzero/one/two/few/many/other+ {{count}} فارغ(فارغة)reachable at everyday counts; the «{{count}} مفرد(جمع)» marker this pack uses throughout (perm.facet.objects, and the sibling toggle showEmptyFields four lines below)
fresptone/many/otherrepeats _othermany starts at 1e6, where the plural form is already correct
endezhjakocovered by one/otherrepeats _otherunreachable — parity ballast so the ten key sets stay identical

No English copy moves: en's three slots are all + {{count}} empty, byte-identical to the call site's inline defaultValue.

plugin-detail's defaults table needed the same row for a different reason: createSafeTranslation's fallbackT reads defaults[key] || key and never appends a plural suffix, so the two suffixed rows were unreachable through it and the provider-less path answered with the raw key.

The guard, and where it lives

Parity across packs turned out to be necessary and not sufficient — ten identical key sets stayed green throughout, because the defect sits 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 in all-locales-key-parity.test.ts.

Both candidate homes were measured, and the call-site gate loses on three counts:

  1. scripts/check-i18n-call-site-keys.mjs reads exactly one pack — collectEnKeys(), line 492, packages/i18n/src/locales/en.ts. Slot coverage is a per-pack fact about ru and ar; an en-only instrument cannot state it.
  2. It only sees families reached from a statically parsable t() literal. A family added to the packs before its call site lands (the 258 个 t() 调用点引用的 key 在任何语言包里都不存在(#3530 守卫首跑实测),其中 8 处直接把 raw key 渲染给用户 #3546 transition, which ran for months) is invisible to it.
  3. Tightening its resolvesLeaf would 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 — and would contradict the gate's own documented semantics in four places (lines 120-121, 145-146, 177, 233), plus require rewriting the gate's own fixtures — scripts/__tests__/check-i18n-call-site-keys.test.ts uses this very family (a shared fixture pack at line 134, deliberately baseless, and four call-site fixtures) to exercise the plural abstentions. The parity home leaves all five untouched, and the gate stays green.

The parity home 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.

Pre-fix red run (guard-class evidence, #4118)

The new assertion against unmodified packs — 10 failures, one per pack:

FAIL packages/i18n/src/__tests__/all-locales-key-parity.test.ts > every plural family
carries a base key (objectui#3863) > en defines the base key of every plural family it has
… zh … ja … ko … de … fr … es … pt … ru … ar (10/10)
AssertionError: ar: 1 plural family/families with no base key: expected [ Array(1) ] to deeply equal []
- []
+ [ "detail.showEmptyRelated [_one,_other] has no base key" ]
Test Files 1 failed (1)
Tests 10 failed | 22 passed (32)

Reverse verification — deleting ru's base key

Reproduces the card's failure text shape, and four independent assertions fire:

AssertionError: expected '+ 3 empty' to be '+ Пустых: 3'
AssertionError: ru: 1 plural family/families with no base key
AssertionError: ru is missing 1 key(s): expected [ 'detail.showEmptyRelated' ] to deeply equal []
AssertionError: ru.detail.showEmptyRelated: expected 'undefined' to be 'string'
no pack leaks the English string at any count:
+ [ "ru @ 0: + 0 empty", "ru @ 2: + 2 empty", "ru @ 3: + 3 empty",
+ "ru @ 5: + 5 empty", "ru @ 11: + 11 empty", "ru @ 100: + 100 empty" ]
Tests 7 failed | 56 passed (63)

6 of 8 counts leak; 1 and 21 survive because both are one in Russian — the trap a "1 is singular, everything else is plural" model falls into. ru.ts was restored with git checkout HEAD -- (never git stash).

Verification

checkresult
vitest run packages/i18n/41 files, 734 tests passed
vitest run packages/plugin-detail/77 files, 759 tests passed
check:i18n-keysgreen — 2352/2352 literal keys resolve. Measured before/after on the same tree: inline defaults went 888 match / 4 not comparable to 889 match / 3 not comparable, i.e. the base key moved this family from not comparable into the judged set, and it matches (en pack 2858 to 2859 keys)
check:i18n-driftgreen — 0 en value(s) changed (1 key(s) added, 0 removed — those are all-locales-key-parity's), the additive verdict
key parity suitegreen — base-key approach keeps the ten key sets identical, by design
check:control-bytesgreen (4079 files); files also self-scanned for raw control bytes
type-check (both packages)green — tsc --noEmit && tsc -p tsconfig.test.json, both commands
lint (both packages)0 errors; my five files individually eslint-clean
check-changeset-presence / check-changeset-no-majorgreen — 1 changeset, patch only

Scope

Ten locale packs (one key family), plugin-detail's en defaults table, the parity gate, one new render suite, one changeset. #3865 (same hook, the safe-translation defaults table) is cross-linked and deliberately not touched — no file overlap.


Generated by Claude Code

The repo's only pre-existing i18next plural family defined just _one/_other in
all ten packs. i18next asks Intl.PluralRules for the one suffix a language needs
and walks fallbackLng to en when the pack lacks it, so ru at counts 2-4 (few) and
5-20 (many) and ar at 0/2/3-10/11-99 rendered English — at exactly the counts the
collapsed-empties button takes most often.
Adds the base key (no suffix) to all ten packs: it is always in i18next's lookup
chain, so every unenumerated category resolves in-language, and the ten key sets
stay identical (which full key parity requires). ru uses the count-invariant
«Noun: {{count}}» form, ar the «{{count}} sg(pl)» marker. Same shape as #3546
slice six. plugin-detail's defaults table gets the base row too — fallbackT
resolves defaults[key] literally and never appends a plural suffix.
Guard: 'a plural family must carry a base key', asserted over all ten packs in
all-locales-key-parity.test.ts — pack-intrinsic, so the next plural family fails
at PR time with no call site needed. Red on all ten packs before this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 11, 2026 10:06pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)29.7 KB350 KB
Entry fileindex-CjpTyiV1.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.12KB108.41KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)150.04KB39.79KB
fields (index.js)228.37KB56.62KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.33KB1.20KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)62.18KB17.67KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)121.60KB31.63KB
plugin-designer (index.js)210.91KB42.67KB
plugin-detail (index.js)239.00KB59.76KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)188.00KB49.94KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)110.10KB26.74KB
plugin-map (index.js)18.05KB5.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.60KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

detail.showEmptyRelated 的复数族只有 _one/_other,没有基础 key —— ru 在 2-4/5-20、ar 在 2-99 直接渲染英文

2 participants

@yinlianghui@claude