Skip to content

Commit 6cf3e0b

Browse files
fix(console): a language picked on the sign-in page formats the date cells, not the browser's language (#27625) (#27680)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Fixes #27625 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:console 读取 `/auth/me/localization` 时,若本设备有明确的语言选择(如登录页选了中文),请求的 `Accept-Language` 就写这门语言;选择变了就重读一次。服务器档位顺序与 `useDisplayLocale` 的优先级都没动。 - 为什么改:这条请求走的是裸 `fetch`,没有像其它 console 请求那样带上界面语言,于是没存语言的用户在登录页选了中文后,服务器按浏览器的 `en-US` 作答,表格日期每次加载都是 `Nov 18`。 - 风险与回滚:只动 console 一个文件;本设备有语言选择时这条请求多一个请求头,语言变化时多一次 GET。回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/LocalizationFetchProvider.tsx`(`statedLanguage` 一行与 headers 里的 `Accept-Language`);`LocalizationFetchProvider.test.tsx` 新的 describe 块。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/LocalizationFetchProvider.test.tsx`,预期 22 个测试全绿,其中 4 个在 `states the device's chosen language` 块下。 - 需要你做的:无,席位落地。 ## What changes `LocalizationFetchProvider` (console) now states the device's chosen UI language as the `Accept-Language` of its `GET /api/v1/auth/me/localization` read whenever the device holds an explicit language choice (`readStoredLanguage()`, the slot the sign-in page's globe and the avatar menu both write), and reads again when that language changes. With no choice on the device, nothing is stated and the browser's own header goes out, as before. **Cause, measured on a running stack (step 1 of the order).** On `examples/app-showcase`, admin with `sys_user.locale` unset, a browser context in `en-US`: | | before (main 144702c) | after | |---|---|---| | html `lang` attribute after the sign-in page pick of 中文 | `zh` | `zh` | | … after sign-in, on the grid, after reload | `zh` | `zh` | | `Accept-Language` on the localization read (grid load, reload) | `en-US` | `zh` | | answer | `{authenticated:true, locale:"en-US", currency:null, timezone:"UTC"}` | `{… locale:"zh" …}` | | All Tasks date cells, both loads | `Oct 11, Nov 18, Nov 1, Nov 10` | `10月11日, 11月18日, 11月1日, 11月10日` | So the pick already reached the html `lang` attribute; the read never stated it. Every other console API call goes through `createAuthenticatedFetch`, which stamps that attribute as `Accept-Language`; this provider reads through `sharedGetJson`'s bare `fetch`, so it carried the browser's header, the server answered its `Accept-Language` rung in English, and `useDisplayLocale` formatted with that answer. **Within the ruling (triage 6501838569).** No server file, no new key, no new server field; the server's rung order and `useDisplayLocale`'s precedence are untouched. The triage's diagnosis "the read the console makes … does not state the active UI language" is what this changes. Two assumptions of the order were falsified by the measurement and named here: the triage line "The console's `Accept-Language` already follows" the html `lang` attribute does not hold for this one read, and the sign-in pick does reach that attribute (so `languageSeed.ts` and the module holding the pick, `@object-ui/app-shell`'s `LocaleSwitcher` through `useLanguageSelection`, signed out a bare `changeLanguage`, are unchanged). The seat agreed the route before the fix. **Why gate the header on an explicit choice.** Without one, the boot is exactly the one on which `seedTenantLanguage()` asks the same endpoint with no header, and the two share one request only while their headers match (`sharedGetJson` keys on them; objectui#5544). Stating a language there would split them into two requests on every first visit. Without a choice the UI language came from that seed or the browser, so the browser's header is the right signal on those boots. **Re-read on a change.** `statedLanguage` is an effect dependency, so a change of the chosen language reads again. That read cannot join a request still in flight under the old language: the in-flight key includes the headers. The #27498 re-read after a write to the user's own `sys_user` row, and its in-flight opt-out, are unchanged. ## Tests New `describe` block `states the device's chosen language (objectstack#27625)` in `ui/apps/console/src/LocalizationFetchProvider.test.tsx`, under a real `I18nProvider`, probing `useDisplayLocale()` plus `formatDate('2026-10-11', undefined, { locale, alwaysShowYear: true })`: - `a language picked before sign-in is the one the read states, and the dates follow it`: explicit-choice slot `zh`, no column; the request states `zh`, the probe shows `zh|2026年10月11日`, one request. - `with no choice on this device, the read states nothing and the browser's header goes out`. - `a change of the chosen language reads again, stating the new one`: `en` then `changeLanguage('zh')`; two requests stating `en` then `zh`, the probe follows. - `a stored sys_user.locale still wins over the stated language`: the request states `zh`, the server answers its column rung `de-DE`, and the dates format in `de-DE`, not Chinese. The #27498 block (`re-reads after a write to the signed-in user's own row`, 4 cases) and `localizationBootDedup.test.tsx` (objectui#5544, 3 cases) are unchanged and green. <details><summary>Targeted run and ablation</summary> - `pnpm --dir ui exec vitest run --reporter=verbose apps/console/src/LocalizationFetchProvider.test.tsx apps/console/src/__tests__/localizationBootDedup.test.tsx apps/console/src/languageSeed.test.ts` (head 861694d) → `Test Files 3 passed (3)`, `Tests 33 passed (33)`. - Ablation through `scripts/ablation-replace.mjs`, fix committed first: `const statedLanguage = readStoredLanguage() ? language : undefined;` → `… readStoredLanguage() && !'ABLATED_27625' ? language : undefined;` (anchor 1 → 0, blob 5147c4a → a88b06c9). Red: the picked-before-sign-in, change-reads-again and stored-column cases (`Tests 3 failed | 19 passed (22)`); the no-choice case stays green, as it should. Restored: blob equal to HEAD's, `git diff HEAD` empty. - No `dist` involved: the console suite resolves the provider and `@object-ui/*` from source. </details> ## Acceptance notes - Stored rung, read live on the same stack (signed-in requests, explicit `Accept-Language`): column unset + `zh` → `locale:"zh"`; column `ja-JP` + `zh` → `ja-JP`; column `ja-JP` + `en-US` → `ja-JP`; column reset + `zh` → `zh`. The column still answers first. - A language picked on the sign-in page is still not written to `sys_user.locale` at sign-in (signed out the globe is the bare switch, by design in `useLanguageSelection`). Not changed here; with this fix that no longer leaves the dates in the browser's language on this device. - Assignee and reviewer: none set on this PR, per the maintainer's rule (nobody is asked before the seat's ACCEPT and review); the harness's post-PR assign/review step was skipped deliberately. ## Verification <details><summary>Live reproduction (backend :4625 --fresh, console vite :5625 from ui/apps/console source)</summary> Playwright, browser context `en-US`: open `/login`, footer globe → 中文, sign in as admin through the form, open `/apps/showcase_app/showcase_task`, wait, read the html `lang` attribute, the `objectui-locale` slot, every `/auth/me/localization` request's `Accept-Language` and its answer, and the date lines; then reload and read them again. Before: see the table above (head 144702c). After: the same script on the branch with the fix, the table's right column. The one header-less localization request on `/login` before the pick is `seedTenantLanguage()` on a device with no choice yet, answered `{authenticated:false}`, in both runs. </details> <details><summary>Local gates on head 42e6d4b (after merging origin/main); the farm is CI's</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (3 paths vs merge base b761968). Reconciliation `--ran`: 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN. - All 33 derived commands: exit 0, among them `node scripts/check-sdui-manifest.mjs` and its `--self-test`, the changeset gates, `check-closing-keyword-parity.mjs`, and `pnpm --dir ui exec vitest run scripts/` (`Test Files 169 passed | 2 skipped (171)`, `Tests 4902 passed | 2 skipped (4904)`, run with the dev servers stopped). - Outside the derivation: `pnpm check:changeset-fixed` (its roster lives under `.changeset/`) → exit 0; `pnpm --dir ui exec vitest run apps/console/src/` → `Test Files 262 passed (262)`, `Tests 2606 passed (2606)`; `pnpm --filter @object-ui/console run type-check` → exit 0 after `pnpm --filter '@object-ui/console^...' build`; `pnpm exec eslint --no-inline-config` on both touched files → exit 0. - Not run locally, CI's: the Type Check UI / workspace type-check lanes, the 11 wide-population families, the artifact rosters, `pnpm lint`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXAd1Uo5DCp8mUzmWj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aad9ad5 commit 6cf3e0b

3 files changed

Lines changed: 152 additions & 4 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): a language picked on the sign-in page reaches date and number formatting
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A user with no stored `sys_user.locale` who picked 中文 on the sign-in page saw every list grid's date cells as `Nov 18` beside Chinese headers, on every page load. `<html lang>` already followed the pick, but `LocalizationFetchProvider` reads `GET /api/v1/auth/me/localization` through `sharedGetJson`'s bare `fetch`, so that one read never carried the `<html lang>` `Accept-Language` every other console API call carries; the server answered its `Accept-Language` rung in the browser's language, and `useDisplayLocale` formats with that answer.
11+
12+
When the device holds an explicit language choice, the read now states that language as its `Accept-Language`, and reads again when the choice changes. The server's rung order is unchanged (a stored `sys_user.locale` still answers first), and so is `useDisplayLocale`'s precedence. With no choice on the device the browser's header still goes out, so a first visit's language seed and this read keep sharing one request.

‎ui/apps/console/src/LocalizationFetchProvider.test.tsx‎

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@
1717
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1818
import { render, screen, waitFor } from '@testing-library/react';
1919
import { act } from 'react';
20-
import { LOCALE_SEED_STORAGE_KEY, LOCALE_STORAGE_KEY, useDisplayLocale, useLocalization } from '@object-ui/i18n';
20+
import {
21+
I18nProvider,
22+
LOCALE_SEED_STORAGE_KEY,
23+
LOCALE_STORAGE_KEY,
24+
useDisplayLocale,
25+
useLocalization,
26+
useObjectTranslation,
27+
} from '@object-ui/i18n';
2128
import { formatDate, formatDateTime, setDisplayTimeZone } from '@object-ui/core';
2229
import { notifyDataChanged } from '@object-ui/react';
2330
// Test-only and off the `@object-ui/types` barrel; the vitest alias makes this
@@ -358,4 +365,100 @@ describe('LocalizationFetchProvider', () => {
358365
expect(fetchMock()).toHaveBeenCalledTimes(1);
359366
});
360367
});
368+
369+
describe('states the device\'s chosen language (objectstack#27625)', () => {
370+
const fetchMock = () => globalThis.fetch as never as ReturnType<typeof vi.fn>;
371+
/** The `Accept-Language` the Nth request stated; `null` = none, the browser's own goes out. */
372+
const statedOn = (n: number) =>
373+
(fetchMock().mock.calls[n]?.[1]?.headers as Record<string, string> | undefined)?.['Accept-Language'] ?? null;
374+
375+
let switchTo: (code: string) => Promise<void> = async () => {};
376+
377+
/** The date face a grid cell paints, plus a handle on the language switch. */
378+
function DisplayProbe() {
379+
const { changeLanguage } = useObjectTranslation();
380+
switchTo = changeLanguage;
381+
const locale = useDisplayLocale();
382+
return (
383+
<span data-testid="display">
384+
{`${locale}|${formatDate('2026-10-11', undefined, { locale, alwaysShowYear: true })}`}
385+
</span>
386+
);
387+
}
388+
389+
function renderUnderLanguage(language: string) {
390+
return render(
391+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
392+
<LocalizationFetchProvider endpoint={ENDPOINT}>
393+
<DisplayProbe />
394+
</LocalizationFetchProvider>
395+
</I18nProvider>,
396+
);
397+
}
398+
399+
beforeEach(() => { window.localStorage.clear(); authUser = { id: 'u1' }; });
400+
afterEach(() => {
401+
authUser = null;
402+
window.localStorage.clear();
403+
resetInflightGetsForTesting();
404+
});
405+
406+
it('a language picked before sign-in is the one the read states, and the dates follow it', async () => {
407+
// The sign-in page's pick: the explicit-choice slot, no `sys_user.locale`.
408+
window.localStorage.setItem(LOCALE_STORAGE_KEY, 'zh');
409+
// The server answers the `Accept-Language` rung with what it was told.
410+
fetchMock().mockImplementation(async (_url: string, init: RequestInit) =>
411+
ok({
412+
authenticated: true,
413+
currency: null,
414+
locale: (init.headers as Record<string, string>)['Accept-Language'] ?? 'en-US',
415+
timezone: 'UTC',
416+
}),
417+
);
418+
419+
renderUnderLanguage('zh');
420+
421+
await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('zh|2026年10月11日'));
422+
expect(statedOn(0)).toBe('zh');
423+
expect(fetchMock()).toHaveBeenCalledTimes(1);
424+
});
425+
426+
it('with no choice on this device, the read states nothing and the browser\'s header goes out', async () => {
427+
fetchMock().mockResolvedValue(ok({ authenticated: true, currency: null, locale: 'en-US', timezone: 'UTC' }));
428+
429+
renderUnderLanguage('en');
430+
431+
await waitFor(() => expect(fetchMock()).toHaveBeenCalledTimes(1));
432+
expect(statedOn(0)).toBeNull();
433+
});
434+
435+
it('a change of the chosen language reads again, stating the new one', async () => {
436+
window.localStorage.setItem(LOCALE_STORAGE_KEY, 'en');
437+
fetchMock()
438+
.mockResolvedValueOnce(ok({ authenticated: true, currency: null, locale: 'en', timezone: 'UTC' }))
439+
.mockResolvedValueOnce(ok({ authenticated: true, currency: null, locale: 'zh', timezone: 'UTC' }));
440+
441+
renderUnderLanguage('en');
442+
await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('en|Oct 11, 2026'));
443+
444+
await act(async () => { await switchTo('zh'); });
445+
446+
await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('zh|2026年10月11日'));
447+
expect(fetchMock()).toHaveBeenCalledTimes(2);
448+
expect(statedOn(0)).toBe('en');
449+
expect(statedOn(1)).toBe('zh');
450+
});
451+
452+
it('a stored sys_user.locale still wins over the stated language', async () => {
453+
window.localStorage.setItem(LOCALE_STORAGE_KEY, 'zh');
454+
// The column's rung answers first, whatever the header says.
455+
fetchMock().mockResolvedValue(ok({ authenticated: true, currency: null, locale: 'de-DE', timezone: 'UTC' }));
456+
457+
renderUnderLanguage('zh');
458+
459+
await waitFor(() => expect(screen.getByTestId('display').textContent).toMatch(/^de-DE\|/));
460+
expect(screen.getByTestId('display').textContent).not.toContain('年');
461+
expect(statedOn(0)).toBe('zh');
462+
});
463+
});
361464
});

‎ui/apps/console/src/LocalizationFetchProvider.tsx‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,35 @@
4545
* profile page's. That re-read starts after the write has settled, and it does
4646
* not join a request still in flight from before it (the shared in-flight
4747
* registry would hand back the stale answer), so it is the one that lands.
48+
*
49+
* ## State the device's chosen language (objectstack#27625)
50+
*
51+
* Every other console API call carries the active UI language as its
52+
* `Accept-Language` (`createAuthenticatedFetch` stamps `<html lang>`), but this
53+
* read goes through `sharedGetJson`'s bare `fetch`, so it carried the BROWSER's
54+
* header instead. A user with no `sys_user.locale` who picked 中文 on the
55+
* sign-in page therefore got the `Accept-Language` rung answered in the
56+
* browser's language, and every date cell said `Nov 18` under Chinese headers,
57+
* on every load.
58+
*
59+
* So when the device holds an explicit language choice, this read states that
60+
* language. The server's rungs are unchanged: a stored `sys_user.locale` still
61+
* answers first, and the header only decides the rung below it. Without a
62+
* choice the browser's header stays: that is exactly the boot on which
63+
* `seedTenantLanguage()` asks this endpoint too, and the two share one request
64+
* only while their headers match (objectui#5544). The stated language is part
65+
* of the request, so a change of it reads again, and the new read cannot join
66+
* one still in flight under the old language: the in-flight key includes the
67+
* headers.
4868
*/
4969
import { useEffect, useState } from 'react';
50-
import { LocalizationProvider, cacheLanguageSeed, type LocalizationValue } from '@object-ui/i18n';
70+
import {
71+
LocalizationProvider,
72+
cacheLanguageSeed,
73+
readStoredLanguage,
74+
useObjectTranslation,
75+
type LocalizationValue,
76+
} from '@object-ui/i18n';
5177
import { getSessionOwnerChangeCount, useAuth } from '@object-ui/auth';
5278
import { useDataInvalidation } from '@object-ui/react';
5379
import {
@@ -87,6 +113,10 @@ export function LocalizationFetchProvider({
87113
const userId = user?.id ? String(user.id) : undefined;
88114
// Bumps on every write to the signed-in user's own row (objectstack#27498).
89115
const userRowWrites = useDataInvalidation(userId ? 'sys_user' : undefined, userId);
116+
// The device's chosen UI language, stated as this read's `Accept-Language`;
117+
// none chosen, the browser's own header goes out (objectstack#27625).
118+
const { language } = useObjectTranslation();
119+
const statedLanguage = readStoredLanguage() ? language : undefined;
90120

91121
useEffect(() => {
92122
let cancelled = false;
@@ -113,7 +143,10 @@ export function LocalizationFetchProvider({
113143
// resolving one of them empty.
114144
const json = await sharedGetJson<MeLocalizationResponse>(endpoint, {
115145
credentials: 'include',
116-
headers: { Accept: 'application/json' },
146+
headers: {
147+
Accept: 'application/json',
148+
...(statedLanguage ? { 'Accept-Language': statedLanguage } : {}),
149+
},
117150
signal: reread?.signal,
118151
});
119152
// Refresh the UI-language seed cache (objectui#4035) — the
@@ -164,7 +197,7 @@ export function LocalizationFetchProvider({
164197
cancelled = true;
165198
reread?.abort();
166199
};
167-
}, [endpoint, userRowWrites]);
200+
}, [endpoint, userRowWrites, statedLanguage]);
168201

169202
return <LocalizationProvider value={value}>{children}</LocalizationProvider>;
170203
}

0 commit comments

Comments
 (0)