Skip to content

Commit 91e9797

Browse files
fix(console): after a language switch, date cells format in the new language, not the stale server locale (#27624)
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 #27498 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:头像菜单切换语言后,console 的本地化提供者在「本人 sys_user 行被写入」时重新读取 `/auth/me/localization`;日期格式化器仍只有一个(`formatDate` 经 `useDisplayLocale`),未新增第二个。 - 为什么改:切换语言会先换界面语言、触发 console 重挂载,再写 `sys_user.locale`;重挂载发出的读取可能先于写入到达服务器,拿回旧的 `en-US` 并整个会话保留,于是中文界面下表格日期显示 `Oct 11`,整页加载才变 `2026年10月11日`。 - 风险与回滚:只动 console 一个文件,写入本人 sys_user 行时多一次 GET;回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/LocalizationFetchProvider.tsx`(新的 `useDataInvalidation` 依赖与 `signal` 一行);`LocalizationFetchProvider.test.tsx` 新的 describe 块。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/LocalizationFetchProvider.test.tsx`,预期 18 个测试全绿,其中 4 个新用例名以 `re-reads after a write` 开头。 - 需要你做的:无,席位落地。 ## What changes `LocalizationFetchProvider` (console) now reads `GET /api/v1/auth/me/localization` again whenever the signed-in user's own `sys_user` row is written through the data-invalidation bus, and that re-read opts out of joining a request still in flight from before the write. **Cause, measured on a running stack.** The endpoint's `locale` is the caller's language: their own `sys_user.locale` when set, else the request's `Accept-Language`, else the deployment default. `useDisplayLocale` ranks it above the active UI language, and every grid / record date renderer formats with it. The avatar-menu switch (`useLanguageSelection`) changes the UI language first and writes `sys_user.locale` second. The UI-language change remounts the metadata subtree (`key={language}` in `ConnectedShellInner`), which remounts this provider, and the remount's read can reach the server before the column write commits. It then answers the previous language (for a user with no stored language yet, the browser's `Accept-Language`, `en-US`), and nothing ever read again. Result: Chinese headers, chips and footer (all `t`-driven) beside `Oct 11` / `Nov 1` / `Nov 18` dates, until a full page load re-read the now-written column. **Why the record page differed.** It is not a second formatter and not grid-specific. The record page reads the same `useDisplayLocale`; the walkthrough's record page was a fresh page load, whose boot read sees the committed column. Reached in the same session by clicking the row, the record page also read `Oct 11, 2026` before this change (measured below). **Fix at the cause.** The provider subscribes with `useDataInvalidation('sys_user', userId)` (the same bus `useSignedInUserLocale` already reads; every adapter write reaches it through `useMutationInvalidationBridge`). The language switch's own write therefore triggers a read that starts after the write has settled. Because `sharedGetJson` shares a promise with any overlapping GET of the same URL, a re-read passes an `AbortController` signal, which opts it out of sharing (and aborts it if superseded or unmounted); the boot read keeps sharing with `seedTenantLanguage()` (objectui#5544). The existing `cancelled` guard drops a stale answer that settles late; the catch now returns first when cancelled so an abort is not treated as a transient failure. The profile page's language card writes the same row and is covered by the same subscription. The ruling "Format grid date cells through the same locale-aware formatter the record page uses. No second date formatter." holds: no formatter, renderer or `useDisplayLocale` line changed. ## Tests New `describe` block in `ui/apps/console/src/LocalizationFetchProvider.test.tsx`, probing `useDisplayLocale()` plus `formatDate('2026-10-11', undefined, { locale, alwaysShowYear: true })`, the face a grid cell paints: - `a read that lost the race with the language write is replaced after the write`: boot read answers `en-US` (`Oct 11, 2026`), a `sys_user` write for the signed-in user re-reads, the probe shows `zh-CN|2026年10月11日`. - `the re-read does not join a request still in flight from before the write`: the boot read is held pending; the write's re-read issues its own request and lands `zh-CN`; the held request settling late with `en-US` is dropped. - `a write to another user's row reads nothing again` and `signed out, a sys_user write reads nothing again`. The block resets the shared in-flight registry after each case, so a pending request from a failing case cannot be joined by the next one. <details><summary>Targeted runs and ablations (head eda50a4, before the main merge; re-run after it in Verification)</summary> - `pnpm --dir ui exec vitest run apps/console/src/LocalizationFetchProvider.test.tsx` → `Tests 18 passed (18)`; with `apps/console/src/__tests__/localizationBootDedup.test.tsx` → `Test Files 2 passed (2)`, `Tests 21 passed (21)`. - Ablation 1, through `scripts/ablation-replace.mjs`: effect dependencies `[endpoint, userRowWrites]` → `[endpoint]` (anchor 1 → 0, blob changed). Red: `a read that lost the race…` and `the re-read does not join…`. Restored; file hash equal to HEAD's. - Ablation 2: delete `signal: reread?.signal,` (anchor 1 → 0). Red: `the re-read does not join a request still in flight from before the write` only (`Tests 1 failed | 17 passed`). Restored; hash equal to HEAD's. - No `dist` involved: the console suite resolves the provider and `@object-ui/*` from source. </details> ## Acceptance notes - **Same symptom, second door, not fixed here (reported to the seat):** a signed-in user with no stored `sys_user.locale` whose UI language comes from the device (for example 中文 picked on the sign-in page) gets `Nov 18` dates under Chinese headers on every load, deterministically: the endpoint answers the `Accept-Language` rung (`en-US`), and `useDisplayLocale` ranks it above the UI language. Measured on this stack with the column reset to null and `objectui-locale` = `zh`. Closing it means changing `useDisplayLocale`'s documented precedence or having the endpoint say which rung answered, a design call outside this card. - The `useDisplayLocale` docblock says the tenant locale is "Frequently `undefined`"; on this stack an authenticated read always answered a locale (`en-US` from the header or deployment rung). Not edited here. - The object description staying English under zh-CN (`A unit of work inside a project.`) is the showcase bundle's coverage, out of scope per the card. ## Verification <details><summary>Live reproduction, app-showcase on this branch (backend :4127 --fresh, console vite :5197 from ui/apps/console source)</summary> Script: sign in as admin in an `en-US` browser, open `/apps/showcase_app/showcase_task` (All Tasks), avatar menu → Language → 中文, read the Due Date cells; click "Audit current IA" (same session); then load that record URL fresh. The `sys_user` PATCH was held 1.5 s by a Playwright route to make the race deterministic; a second run held it 0 s. Column reset to null before each run. Before the fix (head 556f323, delay 1.5 s): - `/auth/me/localization` answers: `en-US`, `en-US`, `en-US` (remount read), then the PATCH `{"locale":"zh-CN"}` lands. - Header `截止日期`; cells `Nov 18, Nov 8, Nov 1, Nov 28, Oct 11, Oct 31`. - Record page reached by click: `Oct 11, 2026`. Same record by URL load: `2026年10月11日`, and the grid behind it `11月18日, 11月8日…`. After the fix (delay 1.5 s): - Answers: `en-US`, `en-US`, PATCH released, `en-US` (the remount's stale read), `zh-CN` (the re-read). - Cells `11月18日, 11月8日, 11月1日, 11月28日, 10月11日, 10月31日`; record by click `2026年10月11日`; by URL load `2026年10月11日`. After the fix, delay 0: cells `11月18日…`, record by click and by URL `2026年10月11日`. </details> <details><summary>Local gates on head 416229c (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 1ed3360). Reconciliation `--ran`: 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN. - 32 of the 33 derived commands: exit 0 (every `check:*`, `check-sdui-manifest.mjs` and its `--self-test`, the changeset gates, `check-closing-keyword-parity.mjs`, the release self-tests). - `pnpm --dir ui exec vitest run scripts/`: exit 1, `2 failed | 4899 passed`; both failures are `Test timed out in 15000ms` in tree-wide scans run while a dev backend, a vite server and the gate loop shared the box (`check-hand-rolled-comment-mask.test.ts` "exits 0 as a process, which is what CI reads", `check-vi-mock-inherit.test.ts` "no `@object-ui/i18n` factory freezes the surface"). Re-run alone under the verification lock: `Test Files 2 passed (2)`, `Tests 105 passed (105)`. The diff adds one `vi.mock('@object-ui/auth', …)` factory, which spreads `importOriginal()`. - Outside the derivation: `pnpm --dir ui exec vitest run apps/console/src/` → `Test Files 262 passed (262)`, `Tests 2599 passed (2599)`; `pnpm exec eslint --no-inline-config` on both touched files → exit 0; `pnpm --filter @object-ui/console type-check` → exit 0 after `pnpm --filter '@object-ui/console^...' build` (a first run before that build exited 2 on unresolved `@object-ui/*` declarations only). The console `tsconfig.json` includes `src` with no exclude, so the test file is compiled. - 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_01Utp6CakU47s2AYQsrXttMo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a888ba5 commit 91e9797

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): after a language switch, date and number cells format in the new language, not until the next page load
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Switching the language from the avatar menu changes the UI language first and writes the signed-in user's `sys_user.locale` second. The switch remounts `LocalizationFetchProvider`, whose read of `GET /api/v1/auth/me/localization` could reach the server before that write committed and answer the previous language (or, for a user with no stored language yet, the browser's `Accept-Language`). `useDisplayLocale` ranks that answer above the UI language, so under 中文 a list grid's date cells kept reading `Oct 11` beside Chinese headers until a full page load, while a record page opened by URL read `2026年10月11日`.
11+
12+
The provider now reads again on every write to the signed-in user's own `sys_user` row that reaches the data-invalidation bus (the language switch's write and the profile page's). The re-read starts after the write has settled and opts out of joining a request still in flight from before it, so its answer is the one that lands. The single date formatter (`formatDate` through `useDisplayLocale`) is unchanged.

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

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@
1616

1717
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1818
import { render, screen, waitFor } from '@testing-library/react';
19-
import { LOCALE_SEED_STORAGE_KEY, LOCALE_STORAGE_KEY, useLocalization } from '@object-ui/i18n';
20-
import { formatDateTime, setDisplayTimeZone } from '@object-ui/core';
19+
import { act } from 'react';
20+
import { LOCALE_SEED_STORAGE_KEY, LOCALE_STORAGE_KEY, useDisplayLocale, useLocalization } from '@object-ui/i18n';
21+
import { formatDate, formatDateTime, setDisplayTimeZone } from '@object-ui/core';
22+
import { notifyDataChanged } from '@object-ui/react';
23+
// Test-only and off the `@object-ui/types` barrel; the vitest alias makes this
24+
// the same module instance the provider's `sharedGetJson` uses.
25+
import { resetInflightGetsForTesting } from '../../../packages/types/src/http-inflight';
2126
import { LocalizationFetchProvider } from './LocalizationFetchProvider';
2227
// Not on the `@object-ui/auth` barrel; the vitest alias maps that barrel to
2328
// `packages/auth/src`, so this deep path is the SAME module instance the
@@ -26,6 +31,13 @@ import { SessionUserScope } from '../../../packages/auth/src/ActiveOrganizationS
2631

2732
const ENDPOINT = '/api/v1/auth/me/localization';
2833

34+
/** The signed-in user, swapped per test; `null` = signed out (the default). */
35+
let authUser: { id: string } | null = null;
36+
vi.mock('@object-ui/auth', async (importOriginal) => ({
37+
...(await importOriginal<Record<string, unknown>>()),
38+
useAuth: () => ({ user: authUser }),
39+
}));
40+
2941
const ok = (body: unknown) => ({
3042
ok: true,
3143
status: 200,
@@ -255,4 +267,95 @@ describe('LocalizationFetchProvider', () => {
255267
expect(screen.getByTestId('zone')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM');
256268
});
257269
});
270+
271+
// objectstack#27498 — the language switch writes `sys_user.locale` AFTER it
272+
// remounts this provider, so the remount's read can answer the old language.
273+
describe('re-reads after a write to the signed-in user\'s own row', () => {
274+
const fetchMock = () => globalThis.fetch as never as ReturnType<typeof vi.fn>;
275+
276+
/** The date face a grid cell paints, with the locale `useDisplayLocale` hands it. */
277+
function DisplayProbe() {
278+
const locale = useDisplayLocale();
279+
return (
280+
<span data-testid="display">
281+
{`${locale}|${formatDate('2026-10-11', undefined, { locale, alwaysShowYear: true })}`}
282+
</span>
283+
);
284+
}
285+
286+
function renderDisplayProbe() {
287+
return render(
288+
<LocalizationFetchProvider endpoint={ENDPOINT}>
289+
<DisplayProbe />
290+
</LocalizationFetchProvider>,
291+
);
292+
}
293+
294+
const write = (recordId: string) =>
295+
act(() => { notifyDataChanged({ objectName: 'sys_user', recordId }); });
296+
297+
// A request a failing case left pending would otherwise be joined by the
298+
// next case's boot read through the shared in-flight registry.
299+
afterEach(() => { authUser = null; resetInflightGetsForTesting(); });
300+
301+
it('a read that lost the race with the language write is replaced after the write', async () => {
302+
authUser = { id: 'u1' };
303+
fetchMock()
304+
.mockResolvedValueOnce(ok({ authenticated: true, currency: null, locale: 'en-US', timezone: 'UTC' }))
305+
.mockResolvedValueOnce(ok({ authenticated: true, currency: null, locale: 'zh-CN', timezone: 'UTC' }));
306+
307+
renderDisplayProbe();
308+
await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('en-US|Oct 11, 2026'));
309+
310+
write('u1');
311+
312+
await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('zh-CN|2026年10月11日'));
313+
expect(fetchMock()).toHaveBeenCalledTimes(2);
314+
});
315+
316+
it('the re-read does not join a request still in flight from before the write', async () => {
317+
authUser = { id: 'u1' };
318+
let answerStale: (v: unknown) => void = () => {};
319+
fetchMock()
320+
.mockImplementationOnce(() => new Promise((resolve) => { answerStale = resolve; }))
321+
.mockResolvedValueOnce(ok({ authenticated: true, currency: null, locale: 'zh-CN', timezone: 'UTC' }));
322+
323+
renderDisplayProbe();
324+
await waitFor(() => expect(fetchMock()).toHaveBeenCalledTimes(1));
325+
326+
write('u1');
327+
328+
// A second request went out of its own, and its answer is the one that lands.
329+
await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('zh-CN|2026年10月11日'));
330+
expect(fetchMock()).toHaveBeenCalledTimes(2);
331+
// The request that left before the write settles late and is dropped.
332+
await act(async () => {
333+
answerStale(ok({ authenticated: true, currency: null, locale: 'en-US', timezone: 'UTC' }));
334+
});
335+
expect(screen.getByTestId('display')).toHaveTextContent('zh-CN|2026年10月11日');
336+
});
337+
338+
it('a write to another user\'s row reads nothing again', async () => {
339+
authUser = { id: 'u1' };
340+
fetchMock().mockResolvedValue(ok({ authenticated: true, currency: null, locale: 'en-US', timezone: 'UTC' }));
341+
342+
renderDisplayProbe();
343+
await waitFor(() => expect(fetchMock()).toHaveBeenCalledTimes(1));
344+
345+
write('u2');
346+
await new Promise((r) => setTimeout(r, 20));
347+
expect(fetchMock()).toHaveBeenCalledTimes(1);
348+
});
349+
350+
it('signed out, a sys_user write reads nothing again', async () => {
351+
fetchMock().mockResolvedValue(ok({ authenticated: false }));
352+
353+
renderDisplayProbe();
354+
await waitFor(() => expect(fetchMock()).toHaveBeenCalledTimes(1));
355+
356+
write('u1');
357+
await new Promise((r) => setTimeout(r, 20));
358+
expect(fetchMock()).toHaveBeenCalledTimes(1);
359+
});
360+
});
258361
});

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,31 @@
2525
* `MePermissionsProvider` but not its policy: that one is fail-closed and holds
2626
* its loading state across the waits, this one keeps rendering throughout and
2727
* simply fills the value in if and when an attempt succeeds.
28+
*
29+
* ## Read again after the signed-in user's own row changes (objectstack#27498)
30+
*
31+
* The answer's `locale` is the caller's language: their own `sys_user.locale`
32+
* when set, else the request's `Accept-Language`, else the deployment default.
33+
* `useDisplayLocale` ranks it above the active UI language, so every date and
34+
* number renderer formats with whatever this provider last read. The language
35+
* switch changes the UI language first and writes the column second
36+
* (`useLanguageSelection`), and the switch remounts this provider through the
37+
* console shell's `key={language}`. The remount's read can reach the server
38+
* before the column write commits; it then answers the OLD language (on a user
39+
* with no column yet, the browser's `Accept-Language`), and nothing read again,
40+
* so under 中文 the grid's date cells said `Oct 11` until a full page load.
41+
*
42+
* So the read re-runs on every mutation of the signed-in user's `sys_user` row
43+
* that reaches the data-invalidation bus, which every adapter write does
44+
* (`useMutationInvalidationBridge`): the language switch's own write, and the
45+
* profile page's. That re-read starts after the write has settled, and it does
46+
* not join a request still in flight from before it (the shared in-flight
47+
* registry would hand back the stale answer), so it is the one that lands.
2848
*/
2949
import { useEffect, useState } from 'react';
3050
import { LocalizationProvider, cacheLanguageSeed, type LocalizationValue } from '@object-ui/i18n';
31-
import { getSessionOwnerChangeCount } from '@object-ui/auth';
51+
import { getSessionOwnerChangeCount, useAuth } from '@object-ui/auth';
52+
import { useDataInvalidation } from '@object-ui/react';
3253
import {
3354
HttpFetchError,
3455
backoffMs,
@@ -62,9 +83,17 @@ export function LocalizationFetchProvider({
6283
children: React.ReactNode;
6384
}) {
6485
const [value, setValue] = useState<LocalizationValue>({});
86+
const { user } = useAuth();
87+
const userId = user?.id ? String(user.id) : undefined;
88+
// Bumps on every write to the signed-in user's own row (objectstack#27498).
89+
const userRowWrites = useDataInvalidation(userId ? 'sys_user' : undefined, userId);
6590

6691
useEffect(() => {
6792
let cancelled = false;
93+
// A re-read must not join a request that left before the write it answers
94+
// to: `sharedGetJson` opts a request with a signal out of sharing. The
95+
// boot read keeps sharing with `seedTenantLanguage()` (objectui#5544).
96+
const reread = userRowWrites > 0 ? new AbortController() : undefined;
6897

6998
void (async () => {
7099
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -85,6 +114,7 @@ export function LocalizationFetchProvider({
85114
const json = await sharedGetJson<MeLocalizationResponse>(endpoint, {
86115
credentials: 'include',
87116
headers: { Accept: 'application/json' },
117+
signal: reread?.signal,
88118
});
89119
// Refresh the UI-language seed cache (objectui#4035) — the
90120
// "revalidate" half of stale-while-revalidate. This boot has already
@@ -116,6 +146,8 @@ export function LocalizationFetchProvider({
116146
});
117147
return;
118148
} catch (err) {
149+
// Superseded by a later read (or unmounted): its abort is no failure.
150+
if (cancelled) return;
119151
// A real answer about this caller (401/403/404/500) will not change on
120152
// a retry, and neither will the last attempt — either way, stop and
121153
// leave the empty value. Cosmetic: renderers show plain numbers.
@@ -130,8 +162,9 @@ export function LocalizationFetchProvider({
130162

131163
return () => {
132164
cancelled = true;
165+
reread?.abort();
133166
};
134-
}, [endpoint]);
167+
}, [endpoint, userRowWrites]);
135168

136169
return <LocalizationProvider value={value}>{children}</LocalizationProvider>;
137170
}

0 commit comments

Comments
 (0)