Skip to content

Commit fe26b46

Browse files
committed
fix(app-shell): name built-in locales from the platform locale table
LocaleSwitcher's localeLabel() read a hand-kept name list keyed by the retired zh and pt pack tags, so zh-Hans, zh-Hant and pt-BR fell through to Intl.DisplayNames. Index PLATFORM_LOCALES by tag instead and pin that every row's label is what localeLabel(tag) returns, with and without Intl.DisplayNames. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzTyA5TRApD5ko4rtq
1 parent 115a43a commit fe26b46

3 files changed

Lines changed: 87 additions & 39 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
The Console language menu now names the built-in languages from the platform
9+
locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`): each entry
10+
shows its row's `label`, such as `简体中文`, `繁體中文` and
11+
`Português (Brasil)`. The menu used to keep its own list of names, keyed by
12+
the retired `zh` and `pt` codes, so those three languages were named by the
13+
runtime's `Intl.DisplayNames` instead, which varies by browser and ICU version
14+
and shows the bare code where `Intl.DisplayNames` is missing. A code that is
15+
not on the table, such as an app-shipped `th`, is still named by
16+
`Intl.DisplayNames`. The set of languages the menu offers is unchanged.

‎ui/packages/app-shell/src/layout/LocaleSwitcher.tsx‎

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
* The menu is not a list of its own (objectui#4039): it renders whatever the
88
* i18n provider says is offerable — the app's own locale list ∩ what the
99
* renderer can resolve. This file owns only the *labels*, because the native
10-
* names of the built-in packs are the one piece of that no API supplies:
10+
* names are the one piece of that no API response supplies:
1111
* `GET /api/v1/i18n/locales` returns descriptors whose `label` is the code
1212
* echoed back (`toLocaleDescriptors` in @objectstack/spec), so a server label
13-
* would put `th` in the menu where `ไทย` belongs.
13+
* would put `th` in the menu where `ไทย` belongs. The built-in packs are named
14+
* from the platform locale table instead, every other code by the runtime.
1415
*
1516
* It is also not a device-local setting any more (objectui#10059). A switch made
1617
* while signed in writes `sys_user.locale` — the column the profile page's
@@ -28,45 +29,39 @@ import {
2829
DropdownMenuTrigger,
2930
} from '@object-ui/components';
3031
import { useObjectTranslation } from '@object-ui/i18n';
32+
import { PLATFORM_LOCALES } from '@objectstack/spec/system';
3133
import { confirmUnsavedLeave } from '../chrome/unsavedEdits.js';
3234
import { useLanguageSelection } from '../hooks/useUserLocale.js';
3335

3436
/**
35-
* Native display names for the built-in language packs.
37+
* Native display names for the built-in language packs, read from the
38+
* platform locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`):
39+
* each row's `label` is the language's own name for itself, the name a
40+
* language menu shows. The table is the one list of platform languages, so
41+
* there is no second, hand-kept list of names here. Indexed by the row's
42+
* canonical `tag` (`en`, `zh-Hans`, `pt-BR`, …).
3643
*
37-
* Kept by hand rather than derived from `Intl.DisplayNames`: these are the
38-
* names the console has always shown, they are stable across ICU versions and
39-
* runtimes, and several read better than the CLDR rendering. Any code *not*
40-
* in here — an app-shipped locale like `th` or `pt-BR` — is named by
41-
* `Intl.DisplayNames` instead. A `Map` rather than an object literal so a code
42-
* like `constructor` cannot pick a label off `Object.prototype`.
44+
* A `Map` rather than an object literal so a code like `constructor` cannot
45+
* pick a label off `Object.prototype`.
4346
*/
44-
const BUILT_IN_LABELS = new Map<string, string>([
45-
['en', 'English'],
46-
['zh', '中文'],
47-
['ja', '日本語'],
48-
['ko', '한국어'],
49-
['de', 'Deutsch'],
50-
['fr', 'Français'],
51-
['es', 'Español'],
52-
['pt', 'Português'],
53-
['ru', 'Русский'],
54-
['ar', 'العربية'],
55-
]);
47+
const BUILT_IN_LABEL_BY_TAG: ReadonlyMap<string, string> = new Map(
48+
PLATFORM_LOCALES.map((row): [string, string] => [row.tag, row.label]),
49+
);
5650

5751
/**
5852
* Name a locale in its own language.
5953
*
60-
* Built-in names win where they exist; everything else goes through
61-
* `Intl.DisplayNames` in the locale itself, so a menu of app locales reads the
62-
* way the built-in ten always have (native, not translated into the current
63-
* UI language). Every failure degrades to the bare code, which is still a
64-
* selectable, honest entry: `Intl.DisplayNames` is absent on old runtimes,
65-
* throws `RangeError` on a malformed tag, and returns the input unchanged for
66-
* a tag it has no name for.
54+
* A platform locale table tag is named by its row's `label`, which is the
55+
* same on every runtime and ICU version. Any other code (an app-shipped
56+
* locale like `th`) goes through `Intl.DisplayNames` in the locale itself, so
57+
* a menu of app locales reads the way the built-in ones do (native, not
58+
* translated into the current UI language). Every failure degrades to the
59+
* bare code, which is still a selectable, honest entry: `Intl.DisplayNames`
60+
* is absent on old runtimes, throws `RangeError` on a malformed tag, and
61+
* returns the input unchanged for a tag it has no name for.
6762
*/
6863
export function localeLabel(code: string): string {
69-
const builtIn = BUILT_IN_LABELS.get(code);
64+
const builtIn = BUILT_IN_LABEL_BY_TAG.get(code);
7065
if (builtIn) return builtIn;
7166
try {
7267
const name = new Intl.DisplayNames([code], { type: 'language' }).of(code);

‎ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx‎

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ vi.mock('lucide-react', async (importOriginal) => ({
3737
Globe: () => <span />,
3838
}));
3939

40-
import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system';
40+
import { PLATFORM_LOCALES, PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system';
4141
import { LocaleSwitcher, localeLabel } from '../LocaleSwitcher';
4242

4343
/**
44-
* The eight built-in native names an app shipping only `en`+`zh` must NOT show:
45-
* every platform locale table language but English and Chinese, named as the
46-
* menu names them (`pt-BR` by `Intl.DisplayNames`, objectstack#30511).
44+
* The eight built-in native names an app shipping only `en`+`zh-Hans` must NOT
45+
* show: every platform locale table language but English and Chinese, named as
46+
* the menu names them (from the table's own labels).
4747
*/
4848
const PHANTOM_LABELS = PLATFORM_LOCALE_TAGS
4949
.filter((tag) => tag !== 'en' && !tag.startsWith('zh-'))
@@ -75,12 +75,12 @@ beforeEach(() => {
7575

7676
describe('LocaleSwitcher — menu composed from the app locale list', () => {
7777
it('offers an app-shipped locale outside the built-in ten, named by Intl.DisplayNames', async () => {
78-
mountConsole({ locales: async () => ['en', 'zh', 'th'] });
78+
mountConsole({ locales: async () => ['en', 'zh-Hans', 'th'] });
7979

80-
// `th` has no built-in pack and no hand-written label; CLDR names it.
80+
// `th` has no built-in pack and no platform locale table row; CLDR names it.
8181
await waitFor(() => expect(screen.getByText('ไทย')).toBeInTheDocument());
8282
expect(screen.getByText('English')).toBeInTheDocument();
83-
expect(screen.getByText('中文')).toBeInTheDocument();
83+
expect(screen.getByText('简体中文')).toBeInTheDocument();
8484
});
8585

8686
it('offers an app-shipped locale supplied through config.resources', async () => {
@@ -106,10 +106,10 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => {
106106
});
107107

108108
it('shows ONLY what the app ships — the phantom eight are gone', async () => {
109-
mountConsole({ locales: async () => ['en', 'zh'] });
109+
mountConsole({ locales: async () => ['en', 'zh-Hans'] });
110110

111111
await waitFor(() => expect(screen.getByText('English')).toBeInTheDocument());
112-
expect(screen.getByText('中文')).toBeInTheDocument();
112+
expect(screen.getByText('简体中文')).toBeInTheDocument();
113113
for (const phantom of PHANTOM_LABELS) {
114114
expect(screen.queryByText(phantom)).not.toBeInTheDocument();
115115
}
@@ -154,7 +154,7 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => {
154154
expect(container.firstChild).toBeNull();
155155
expect(screen.queryByText('English')).not.toBeInTheDocument();
156156

157-
answer(['en', 'zh']);
157+
answer(['en', 'zh-Hans']);
158158

159159
await waitFor(() => expect(screen.getByText('English')).toBeInTheDocument());
160160
expect(screen.queryByText('日本語')).not.toBeInTheDocument();
@@ -175,3 +175,40 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => {
175175
}
176176
});
177177
});
178+
179+
describe('localeLabel — the built-in packs are named by the platform locale table', () => {
180+
it('names every table tag with its row label', () => {
181+
for (const row of PLATFORM_LOCALES) {
182+
expect(localeLabel(row.tag), row.tag).toBe(row.label);
183+
}
184+
});
185+
186+
it('names every table tag with its row label on a runtime without Intl.DisplayNames', () => {
187+
// The table label must not be whatever the runtime's CLDR happens to say:
188+
// with the runtime naming nothing, every row still reads its own label.
189+
const displayNames = Intl.DisplayNames;
190+
Object.defineProperty(Intl, 'DisplayNames', {
191+
configurable: true,
192+
writable: true,
193+
value: undefined,
194+
});
195+
try {
196+
for (const row of PLATFORM_LOCALES) {
197+
expect(localeLabel(row.tag), row.tag).toBe(row.label);
198+
}
199+
// An app-shipped code still degrades to the bare code, not a crash.
200+
expect(localeLabel('th')).toBe('th');
201+
} finally {
202+
Object.defineProperty(Intl, 'DisplayNames', {
203+
configurable: true,
204+
writable: true,
205+
value: displayNames,
206+
});
207+
}
208+
});
209+
210+
it('cannot read a label off Object.prototype', () => {
211+
expect(localeLabel('constructor')).toBe('constructor');
212+
expect(localeLabel('__proto__')).toBe('__proto__');
213+
});
214+
});

0 commit comments

Comments
 (0)